fix: 產出格式修正
This commit is contained in:
@@ -1,190 +0,0 @@
|
|||||||
# Product Requirement Document
|
|
||||||
## HTML Transform
|
|
||||||
|
|
||||||
| 欄位 | 內容 |
|
|
||||||
|------|------|
|
|
||||||
| 版本 | v0.3 |
|
|
||||||
| 日期 | 2026-05-04 |
|
|
||||||
| 狀態 | MVP |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景與目標
|
|
||||||
|
|
||||||
HTML Transform 是 prototype evidence 工具。它的目標不是完整自動化前端生成,而是把 HTML prototype 轉成可 review、可追溯、可交給 coding agent 實作的前置證據。
|
|
||||||
|
|
||||||
目前 MVP 只保留有實際價值的功能:
|
|
||||||
|
|
||||||
- 檢查 `scan` 前置條件。
|
|
||||||
- 用真實瀏覽器擷取 prototype evidence。
|
|
||||||
- 推論跨頁面的 App Structure Map。
|
|
||||||
- 產生頁面級 UI Contract。
|
|
||||||
- 驗證 UI Contract 與 DOM evidence 是否明顯衝突。
|
|
||||||
|
|
||||||
不保留未完成的 pipeline 骨架。`plan`、`run`、`diff`、`verify`、`go`、`status` 已不屬於目前產品面。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. CLI Scope
|
|
||||||
|
|
||||||
| 指令 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| `ht doctor` | 檢查 `scan` 所需環境 |
|
|
||||||
| `ht scan` | 產生 browser evidence、page contract、validation report |
|
|
||||||
|
|
||||||
沒有 Codex slash command。根目錄輸入 `/scan` 不會自動執行本工具。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 設定檔
|
|
||||||
|
|
||||||
MVP 只使用 `prototype`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default {
|
|
||||||
prototype: './prototype'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
本 repo 的根目錄設定檔可以保留其他欄位給外部工作流參考,但 `packages/html-transform` 不會執行 frontend/backend/output 相關流程。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Stage 1:Capture
|
|
||||||
|
|
||||||
目的:取得 prototype 的瀏覽器真相,避免只看 HTML source 或讓 agent 腦補畫面。
|
|
||||||
|
|
||||||
需求:
|
|
||||||
|
|
||||||
| ID | 說明 |
|
|
||||||
|----|------|
|
|
||||||
| F1-1 | 對每個 `.html` 檔案截取 desktop `1440 x 900` full-page screenshot |
|
|
||||||
| F1-2 | 使用本地 Vite static server,不使用 `file://` |
|
|
||||||
| F1-3 | 截圖前等待 `networkidle`,再等待 500ms |
|
|
||||||
| F1-4 | 以 HTML SHA-256 hash 作為 cache key,未變更時跳過重新截圖 |
|
|
||||||
| F1-5 | 截圖寫入 `.ht/cache/prototype/{page}/desktop-default.png` |
|
|
||||||
| F1-6 | 產出 `dom-summary.json` 與 `accessibility-tree.json` |
|
|
||||||
| F1-7 | 產出 `capture-metadata.json`,記錄 resource failure 與 console error/warning |
|
|
||||||
| F1-8 | 產出 `.ht/app-map.json`,推論頁面角色與 layout 策略 |
|
|
||||||
|
|
||||||
產出:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.ht/cache/prototype/{page}/desktop-default.png
|
|
||||||
.ht/cache/prototype/{page}/dom-summary.json
|
|
||||||
.ht/cache/prototype/{page}/accessibility-tree.json
|
|
||||||
.ht/cache/prototype/{page}/capture-metadata.json
|
|
||||||
.ht/app-map.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Stage 2:Decompose
|
|
||||||
|
|
||||||
MVP 不執行。
|
|
||||||
|
|
||||||
不做的項目:
|
|
||||||
|
|
||||||
- 不呼叫 vision agent 切主要區域。
|
|
||||||
- 不產生 `decomposition.json`。
|
|
||||||
- 不用 Playwright `clip` 截取 table/form/main 等局部圖。
|
|
||||||
- 不建立 region-first extraction pipeline。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Stage 3-lite:Page Contract
|
|
||||||
|
|
||||||
目的:從 Stage 1 evidence 與 HTML source 產生頁面級 UI Contract。
|
|
||||||
|
|
||||||
需求:
|
|
||||||
|
|
||||||
| ID | 說明 |
|
|
||||||
|----|------|
|
|
||||||
| F3-1 | 每個 HTML 產出一份 `pageContract` |
|
|
||||||
| F3-2 | `pageContract` 以整頁為單位,不以 region screenshot 為單位 |
|
|
||||||
| F3-3 | 記錄 source、screenshot、title、sections、forms、tables、actions、textSamples |
|
|
||||||
| F3-4 | 建議 Vuetify components 必須來自 MVP 白名單 |
|
|
||||||
| F3-5 | forms 記錄 labels、fields、required 狀態與 actions |
|
|
||||||
| F3-6 | tables 記錄 headers 與少量 sample rows |
|
|
||||||
| F3-7 | 不確定或 evidence 對不上時保留 warnings |
|
|
||||||
|
|
||||||
產出:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.ht/spec/{page}.spec.json
|
|
||||||
.ht/spec/{page}.ui-contract.md
|
|
||||||
```
|
|
||||||
|
|
||||||
`.spec.json` 目前仍保留 `regions` 欄位作為相容層,但它不是 MVP 的主要設計中心。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6.1 App Structure Map
|
|
||||||
|
|
||||||
目的:讓通用 prompt 不必靠人工說明,就能知道每個 prototype 應如何放進前端 app。
|
|
||||||
|
|
||||||
產出:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.ht/app-map.json
|
|
||||||
```
|
|
||||||
|
|
||||||
每個 route 應包含:
|
|
||||||
|
|
||||||
| 欄位 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| `prototype` | prototype 相對路徑 |
|
|
||||||
| `kind` | `auth`、`auth-support`、`legacy-shell-reference`、`prototype-navigation`、`feature-page` 或 `reference` |
|
|
||||||
| `module` | 由資料夾推論的功能模組 |
|
|
||||||
| `targetRole` | 對前端實作的角色提示 |
|
|
||||||
| `layout` | `template-auth`、`template-app` 或 `ignore` |
|
|
||||||
| `usePrototypeStyle` | MVP 固定為 `false` |
|
|
||||||
| `usePrototypeContent` | 是否採用 prototype 的內容資訊架構 |
|
|
||||||
| `routeHint` | 可用的路由建議,無正式頁面時為 `null` |
|
|
||||||
|
|
||||||
推論規則:
|
|
||||||
|
|
||||||
- password input 或登入/忘記密碼文字:`auth`。
|
|
||||||
- `portal/app-layout.html`、`portal/menu-pane.html`、`portal/index.html` 或含登出/隱藏選單:`legacy-shell-reference`。
|
|
||||||
- 非 portal 且有 table/form/action:`feature-page`。
|
|
||||||
- module index 若像 prototype 導覽頁:`prototype-navigation`。
|
|
||||||
- layout 與 login style 來源固定為 `preparation/skt-vuetify-templates`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Stage 4-lite:Validate Contract
|
|
||||||
|
|
||||||
目的:確認 UI Contract 與 browser evidence 沒有明顯衝突。
|
|
||||||
|
|
||||||
需求:
|
|
||||||
|
|
||||||
| ID | 說明 |
|
|
||||||
|----|------|
|
|
||||||
| F4-1 | 驗證 `textSamples` 是否可在 DOM summary 找到 |
|
|
||||||
| F4-2 | 驗證 actions 是否可在 DOM summary 找到 |
|
|
||||||
| F4-3 | 驗證 form labels 是否可在 DOM summary 找到 |
|
|
||||||
| F4-4 | 驗證建議 Vuetify components 是否在白名單內 |
|
|
||||||
| F4-5 | 產出 `ValidationReport`,包含 warnings、nullFieldCount、nullFieldRatio、requiresHumanReview |
|
|
||||||
|
|
||||||
產出:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.ht/spec/{page}.validation.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 非目標
|
|
||||||
|
|
||||||
MVP 不追求:
|
|
||||||
|
|
||||||
- tablet/mobile 多 viewport capture。
|
|
||||||
- region decomposition。
|
|
||||||
- 每個 region 獨立 vision extraction。
|
|
||||||
- TaskList 產生。
|
|
||||||
- 自動寫入 frontend/output。
|
|
||||||
- visual similarity scoring。
|
|
||||||
- flow smoke test。
|
|
||||||
- 全自動 coding agent invocation。
|
|
||||||
|
|
||||||
這些能力只有在實際需求明確出現後才新增。
|
|
||||||
@@ -82,9 +82,23 @@ pnpm --filter html-transform exec playwright-cli --version
|
|||||||
若環境還沒有 Chromium:
|
若環境還沒有 Chromium:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
HOME=/tmp \
|
||||||
|
XDG_CACHE_HOME=/tmp \
|
||||||
|
PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon \
|
||||||
pnpm --filter html-transform exec playwright-cli install-browser chromium
|
pnpm --filter html-transform exec playwright-cli install-browser chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
|
若是在受限制環境中執行(例如 home directory 唯讀、sandbox 不允許寫入 `~/.cache`),建議同時指定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/tmp
|
||||||
|
XDG_CACHE_HOME=/tmp
|
||||||
|
PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
這樣 Playwright 的 browser、cache 與 daemon session 都會寫到可寫目錄。
|
||||||
|
|
||||||
## 設定檔
|
## 設定檔
|
||||||
|
|
||||||
設定檔不是工具執行的絕對必要條件;沒有設定檔時,工具會使用內建預設並讀取:
|
設定檔不是工具執行的絕對必要條件;沒有設定檔時,工具會使用內建預設並讀取:
|
||||||
@@ -138,6 +152,99 @@ export default {
|
|||||||
|
|
||||||
`frontend`、`backend`、`output`、`plan` 等欄位不會被 `doctor` 或 `scan` 使用,不需要為本 MVP 加入設定檔。
|
`frontend`、`backend`、`output`、`plan` 等欄位不會被 `doctor` 或 `scan` 使用,不需要為本 MVP 加入設定檔。
|
||||||
|
|
||||||
|
## 本 repo 實際執行
|
||||||
|
|
||||||
|
這個 monorepo 目前要能穩定跑 `scan`,至少需要處理三件事:
|
||||||
|
|
||||||
|
1. 根目錄 `ht.config.mjs` 將 prototype 指到 `./prototype`
|
||||||
|
2. Playwright Chromium 已安裝
|
||||||
|
3. 若根目錄 `.playwright/cli.config.json` 有指定 `channel`,需避免 `scan` 誤走系統 Chrome
|
||||||
|
|
||||||
|
目前本 repo 的最小設定是:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
prototype: "./prototype",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
若根目錄 `.playwright/cli.config.json` 類似:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"browser": {
|
||||||
|
"browserName": "chromium",
|
||||||
|
"launchOptions": {
|
||||||
|
"channel": "chromium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scan` 可能會失敗,並出現類似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
原因是 `html-transform` 會在 `.ht/cache/playwright-cli` 底下呼叫 `playwright-cli`。在這種情況下,可在該工作目錄放一份最小覆寫設定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.ht/cache/playwright-cli/.playwright/cli.config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
內容:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"browser": {
|
||||||
|
"browserName": "chromium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在這個 repo 內,實際可重現的流程如下。
|
||||||
|
|
||||||
|
1. 確認設定與 CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter html-transform exec playwright-cli --version
|
||||||
|
HOME=/tmp \
|
||||||
|
XDG_CACHE_HOME=/tmp \
|
||||||
|
PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon \
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright \
|
||||||
|
node packages/html-transform/src/cli.js doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 安裝 Chromium:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/tmp \
|
||||||
|
XDG_CACHE_HOME=/tmp \
|
||||||
|
PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon \
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright \
|
||||||
|
pnpm --filter html-transform exec playwright-cli install-browser chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 準備 Playwright CLI 的本地覆寫設定(只有在根目錄 `.playwright/cli.config.json` 含 `channel` 時需要):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .ht/cache/playwright-cli/.playwright
|
||||||
|
printf '%s\n' '{' ' "browser": {' ' "browserName": "chromium"' ' }' '}' > .ht/cache/playwright-cli/.playwright/cli.config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 執行 scan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/tmp \
|
||||||
|
XDG_CACHE_HOME=/tmp \
|
||||||
|
PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon \
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright \
|
||||||
|
node packages/html-transform/src/cli.js scan
|
||||||
|
```
|
||||||
|
|
||||||
|
在受限制 sandbox 中,`scan` 可能因為 Vite 無法綁定本機 port、或 Chromium 啟動時的系統呼叫受限而失敗。這種情況下,需要在不受 sandbox 限制的 shell 執行上面的 `scan` 指令。
|
||||||
|
|
||||||
## Doctor
|
## Doctor
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -157,6 +264,16 @@ node packages/html-transform/src/cli.js doctor
|
|||||||
node packages/html-transform/src/cli.js scan
|
node packages/html-transform/src/cli.js scan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
在本 repo 的受限制環境中,建議實際使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/tmp \
|
||||||
|
XDG_CACHE_HOME=/tmp \
|
||||||
|
PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon \
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright \
|
||||||
|
node packages/html-transform/src/cli.js scan
|
||||||
|
```
|
||||||
|
|
||||||
`scan` 會自動:
|
`scan` 會自動:
|
||||||
|
|
||||||
- 用 Vite static server 提供 prototype。
|
- 用 Vite static server 提供 prototype。
|
||||||
@@ -297,6 +414,6 @@ node packages/html-transform/src/cli.js scan
|
|||||||
```bash
|
```bash
|
||||||
pnpm --filter html-transform typecheck
|
pnpm --filter html-transform typecheck
|
||||||
pnpm --filter html-transform test
|
pnpm --filter html-transform test
|
||||||
node packages/html-transform/src/cli.js doctor
|
HOME=/tmp XDG_CACHE_HOME=/tmp PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright node packages/html-transform/src/cli.js doctor
|
||||||
node packages/html-transform/src/cli.js scan
|
HOME=/tmp XDG_CACHE_HOME=/tmp PLAYWRIGHT_DAEMON_SESSION_DIR=/tmp/ms-playwright/daemon PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright node packages/html-transform/src/cli.js scan
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## 目前狀態
|
|
||||||
|
|
||||||
`html-transform scan` 已從單純 prototype evidence,推進到可輸出 Page Implementation Contract 的第一版。
|
|
||||||
|
|
||||||
已完成:
|
|
||||||
|
|
||||||
- 強化 prototype form/table/action 萃取。
|
|
||||||
- 支援 legacy table-form 的 `<th>/<td>` 欄位 label 推論。
|
|
||||||
- 欄位輸出包含 `label`、`name`、`type`、`required`、`readonly`、`maxLength`、`options`、`defaultValue`、`sourceTable`、`sourceRow`。
|
|
||||||
- action 會輸出 `actionType` 與 `scope`,例如 `search`、`save`、`edit`、`delete`、`print`、`back`、`formAction`、`rowAction`。
|
|
||||||
- table 會輸出 `role`,例如 `searchTable`、`resultTable`、`detailTable`、`printTable`、`layoutTable`。
|
|
||||||
- 解析 `prototype/*.md` guide。
|
|
||||||
- 保留 `legacyJsp`、`legacyPb`、`targetView`、`description`。
|
|
||||||
- 解析各 prototype 段落 checklist,寫入 route guide 與 page spec。
|
|
||||||
- 解析 backend API 文件。
|
|
||||||
- 從 `apps/backend/API.md` 解析 endpoint index。
|
|
||||||
- 從 `apps/backend/API_Manual.md` 解析 method/path、request/response JSON、field rules、notes、ProblemDetails examples。
|
|
||||||
- 輸出 `.ht/api-catalog.json`。
|
|
||||||
- 建立 prototype-to-api matching。
|
|
||||||
- 依 prototype path/module、guide、title/text/actions 與 endpoint path/description tokens 做通用匹配。
|
|
||||||
- 不針對 Venue 寫死規則;Venue 只是目前驗證案例。
|
|
||||||
- 產出維護頁 `maintenanceContract`。
|
|
||||||
- 輸出 `pageKind`、`capabilities`、`recommendedTemplate`、`confidence`、`reasons`、`warnings`、`dataModel`。
|
|
||||||
- 可初步分辨 `maintenance`、`query`、`application`、`print`、`chooser`、`layout-reference`。
|
|
||||||
- 更新 `.ui-contract.md`。
|
|
||||||
- 加入 page kind、recommended template、capabilities、primary entity、target view、legacy source、prototype checklist、API endpoints。
|
|
||||||
- 補測試。
|
|
||||||
- 使用 generic `orders` domain 測試 API catalog、API matching 與 maintenance template 推論,避免只驗證 Venue。
|
|
||||||
|
|
||||||
已驗證:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter html-transform test
|
|
||||||
pnpm --filter html-transform run typecheck
|
|
||||||
node packages/html-transform/src/cli.js scan
|
|
||||||
```
|
|
||||||
|
|
||||||
目前 Venue prototype 重新 scan 後的分類:
|
|
||||||
|
|
||||||
```text
|
|
||||||
applications-list.html maintenance single-record
|
|
||||||
apply-choose.html chooser none
|
|
||||||
apply-facility.html application master-detail-c
|
|
||||||
apply-room-print.html print none
|
|
||||||
apply-room.html application master-detail-c
|
|
||||||
query-choose.html chooser none
|
|
||||||
query-facility.html query none
|
|
||||||
query-room.html query none
|
|
||||||
```
|
|
||||||
|
|
||||||
## 目標
|
|
||||||
|
|
||||||
讓 `html-transform scan` 產出的 `.ht/` artifact 不只描述 prototype 畫面,也能結合 backend API 文件與維護頁範本規則,形成可穩定交給 LLM 生成 Vue/Vuetify 頁面的實作契約。
|
|
||||||
|
|
||||||
核心輸出:
|
|
||||||
|
|
||||||
- prototype 提供畫面結構、欄位文案、表格、按鈕與舊流程線索。
|
|
||||||
- backend API docs 提供 endpoint、request/response DTO、欄位規則、狀態碼與錯誤格式。
|
|
||||||
- template guide 或 maint README 提供專案層的頁面範本選擇規則。
|
|
||||||
- `.ht/app-map.json`、`.ht/api-catalog.json`、`.ht/spec/{page}.spec.json`、`.ht/spec/{page}.ui-contract.md` 說明頁面型態、資料模型、API 對應、建議範本與信心理由。
|
|
||||||
|
|
||||||
## 待辦
|
|
||||||
|
|
||||||
### 1. Layout Evidence
|
|
||||||
|
|
||||||
目前 `scan` evidence 可以找出頁面結構、labels、inputs、buttons、tables、screenshots 與 app-map hints。`GEN-FE-PROMPT.md` 的 UI 實作規則要求 feature page 使用現代 Vuetify 承載 prototype 的功能與資訊架構,不逐像素複刻舊 HTML/JSP,但仍要保留資訊密度、區塊順序、欄位行列對齊與主要操作流。
|
|
||||||
|
|
||||||
待補強:
|
|
||||||
|
|
||||||
- 擷取重要可見元素 bounding boxes:
|
|
||||||
- headings
|
|
||||||
- labels
|
|
||||||
- inputs/selects/textareas
|
|
||||||
- buttons/links
|
|
||||||
- tables
|
|
||||||
- section containers
|
|
||||||
- 用 bounding boxes 將表單欄位分群成可能的 rows / columns。
|
|
||||||
- 擷取 first-viewport density:
|
|
||||||
- visible section count
|
|
||||||
- visible form field count
|
|
||||||
- visible table/header count
|
|
||||||
- approximate occupied content area
|
|
||||||
- 在 `.ht/spec/{page}.spec.json` 輸出:
|
|
||||||
- `layoutEvidence.sections`
|
|
||||||
- `layoutEvidence.formRows`
|
|
||||||
- `layoutEvidence.tables`
|
|
||||||
- `layoutEvidence.primaryActions`
|
|
||||||
- `layoutEvidence.firstViewport`
|
|
||||||
- 在 `.ht/spec/{page}.ui-contract.md` 呈現 layout hints。
|
|
||||||
|
|
||||||
Non-goals:
|
|
||||||
|
|
||||||
- 不要求 pixel-perfect HTML/JSP reproduction。
|
|
||||||
- 不複製 legacy CSS。
|
|
||||||
- 除非是功能辨識必要,不把精確 colors/fonts 編成硬限制。
|
|
||||||
|
|
||||||
### 2. API Matching 品質
|
|
||||||
|
|
||||||
目前 API matching 是通用 token scoring,已可用,但仍偏粗。
|
|
||||||
|
|
||||||
待補強:
|
|
||||||
|
|
||||||
- 降低同 module 但不同 page 的誤配風險。
|
|
||||||
- 把 guide 的 `targetView`、JSP/PB method、prototype path 與 endpoint path 做更細的權重拆分。
|
|
||||||
- 將 matched endpoints 分成用途:
|
|
||||||
- `lookup`
|
|
||||||
- `search`
|
|
||||||
- `detail`
|
|
||||||
- `create`
|
|
||||||
- `update`
|
|
||||||
- `delete`
|
|
||||||
- `print`
|
|
||||||
- `customAction`
|
|
||||||
- 在 `apiContract` 裡輸出 rejected candidates 與 rejection reason,方便檢查錯配。
|
|
||||||
|
|
||||||
### 3. Maintenance Contract 精準度
|
|
||||||
|
|
||||||
目前 `maintenanceContract` 已能初步推薦範本,但仍是 heuristic。
|
|
||||||
|
|
||||||
待補強:
|
|
||||||
|
|
||||||
- 加入 row action rule:
|
|
||||||
- 從 prototype checklist、button disabled、API notes 推論 `enabledWhen`。
|
|
||||||
- 例如 `aprvYn === 'Z'` 才能 edit/delete。
|
|
||||||
- 更精準拆出:
|
|
||||||
- `searchFields`
|
|
||||||
- `formFields`
|
|
||||||
- `tableColumns`
|
|
||||||
- `detailCollections`
|
|
||||||
- `rowActions`
|
|
||||||
- 將 `recommendedTemplate` 與特定前端專案範本解耦。
|
|
||||||
- 通用輸出保留 `single-record`、`master-detail-c` 等抽象 template id。
|
|
||||||
- 專案若有自己的 README 或 guide,再由 prompt 對應到實際檔案。
|
|
||||||
- 加 validation warnings:
|
|
||||||
- `maintenance` 頁缺少 search/table/action。
|
|
||||||
- 有 `edit/delete` action 但沒有對應 API endpoint。
|
|
||||||
- request schema 有欄位但 prototype 找不到對應欄位。
|
|
||||||
- prototype 有欄位但 API schema 找不到對應欄位。
|
|
||||||
- row action 有狀態限制但未產出 `enabledWhen`。
|
|
||||||
|
|
||||||
### 4. Backend Docs 通用性
|
|
||||||
|
|
||||||
目前 backend docs 預設讀 `./apps/backend`,且 parser 針對 markdown heading、table、code fence 做通用解析。
|
|
||||||
|
|
||||||
待補強:
|
|
||||||
|
|
||||||
- 允許 config 指定多個 backend docs 來源。
|
|
||||||
- 支援 OpenAPI JSON/YAML 作為輸入來源。
|
|
||||||
- 支援沒有 `API.md` index、只有 manual 的情境。
|
|
||||||
- 支援不同語言的章節標籤,例如 `Request body`、`Response body`、`Validation`。
|
|
||||||
|
|
||||||
### 5. UI Contract Markdown
|
|
||||||
|
|
||||||
目前 `.ui-contract.md` 已加入主要 contract 摘要。
|
|
||||||
|
|
||||||
待補強:
|
|
||||||
|
|
||||||
- 輸出 field rules 與 schema 摘要。
|
|
||||||
- 輸出 row action rules。
|
|
||||||
- 輸出 layout evidence。
|
|
||||||
- 對 `recommendedTemplate: none` 的頁面明確標示原因,例如 chooser/query/print/layout-reference。
|
|
||||||
|
|
||||||
### 6. 文件與範例
|
|
||||||
|
|
||||||
待補強:
|
|
||||||
|
|
||||||
- 加一個最小 fixture,示範非 Venue domain 如何產出 api catalog 與 maintenance contract。
|
|
||||||
- 在 README 補充 config 範例:
|
|
||||||
- 只指定 `prototype`
|
|
||||||
- 同時指定 `prototype` 與 `backendDocs`
|
|
||||||
- 沒有 backend docs 時的輸出行為
|
|
||||||
+13
-39
@@ -229,29 +229,10 @@ export function buildAppMapWithGuides(layoutSpecs, prototypeDir, prototypeGuides
|
|||||||
tableCount: spec.pageContract.tables.length
|
tableCount: spec.pageContract.tables.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}).filter((route) => ['auth', 'auth-support', 'feature-page'].includes(route.kind))
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
rules: {
|
|
||||||
layoutSource: 'preparation/skt-vuetify-templates',
|
|
||||||
loginStyleSource: 'preparation/skt-vuetify-templates',
|
|
||||||
prototypeStylePolicy: 'content-only',
|
|
||||||
prototypeOuterFramePolicy: 'ignore'
|
|
||||||
},
|
|
||||||
guideSources: prototypeGuides.map((guide) => ({
|
|
||||||
source: guide.source,
|
|
||||||
title: guide.title,
|
|
||||||
entryCount: guide.entries.length,
|
|
||||||
legacyFlowCount: guide.legacyFlows.length
|
|
||||||
})),
|
|
||||||
legacyFlows: prototypeGuides.flatMap((guide) => guide.legacyFlows.map((flow) => ({
|
|
||||||
source: guide.source,
|
|
||||||
title: flow.title,
|
|
||||||
nodeCount: flow.nodes.length,
|
|
||||||
tasks: unique(flow.nodes.map((node) => node.task).filter(Boolean))
|
|
||||||
}))),
|
|
||||||
modules: buildModules(routes),
|
|
||||||
routes
|
routes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,7 +414,9 @@ function buildPrototypeGuideIndex(prototypeGuides) {
|
|||||||
const index = new Map()
|
const index = new Map()
|
||||||
for (const guide of prototypeGuides) {
|
for (const guide of prototypeGuides) {
|
||||||
for (const entry of guide.entries) {
|
for (const entry of guide.entries) {
|
||||||
index.set(entry.prototype, entry)
|
const normalized = normalizePrototypeGuidePath(entry.prototype)
|
||||||
|
index.set(normalized, entry)
|
||||||
|
index.set(`module/${normalized}`, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return index
|
return index
|
||||||
@@ -469,7 +452,7 @@ function findGuideValue(values, needles) {
|
|||||||
function inferRoute(spec, relativeSource) {
|
function inferRoute(spec, relativeSource) {
|
||||||
const path = relativeSource.replace(/\\/g, '/')
|
const path = relativeSource.replace(/\\/g, '/')
|
||||||
const segments = path.split('/')
|
const segments = path.split('/')
|
||||||
const module = segments.length > 1 ? segments[0] : 'root'
|
const module = inferModuleName(segments)
|
||||||
const basename = segments.at(-1) ?? spec.page
|
const basename = segments.at(-1) ?? spec.page
|
||||||
const text = [
|
const text = [
|
||||||
spec.pageContract.title,
|
spec.pageContract.title,
|
||||||
@@ -548,24 +531,15 @@ function inferRoute(spec, relativeSource) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModules(routes) {
|
function normalizePrototypeGuidePath(path) {
|
||||||
const modules = new Map()
|
return String(path ?? '').replace(/^\.?\/*/, '').replace(/^module\//, '')
|
||||||
for (const route of routes) {
|
|
||||||
const current = modules.get(route.module) ?? {
|
|
||||||
name: route.module,
|
|
||||||
featurePageCount: 0,
|
|
||||||
authPageCount: 0,
|
|
||||||
referencePageCount: 0
|
|
||||||
}
|
}
|
||||||
if (route.kind === 'feature-page') current.featurePageCount += 1
|
|
||||||
else if (route.kind === 'auth' || route.kind === 'auth-support') current.authPageCount += 1
|
function inferModuleName(segments) {
|
||||||
else current.referencePageCount += 1
|
if (segments.length === 1) return 'root'
|
||||||
modules.set(route.module, current)
|
if (segments[0] === 'module' && segments[1]) return segments[1]
|
||||||
}
|
if (segments[0] === 'guide' && segments[1]) return segments[1]
|
||||||
return [...modules.values()].map((module) => ({
|
return segments[0] ?? 'root'
|
||||||
...module,
|
|
||||||
kind: module.featurePageCount > 0 ? 'feature-module' : module.authPageCount > 0 ? 'entry-module' : 'reference-module'
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPasswordField(spec) {
|
function hasPasswordField(spec) {
|
||||||
|
|||||||
+41
-2
@@ -1,5 +1,5 @@
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
import { basename, join, relative } from 'node:path'
|
import { basename, join, relative } from 'node:path'
|
||||||
import { loadConfig } from '../lib/config.js'
|
import { loadConfig } from '../lib/config.js'
|
||||||
import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js'
|
import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js'
|
||||||
@@ -19,11 +19,12 @@ const viewports = [
|
|||||||
*/
|
*/
|
||||||
export async function scan() {
|
export async function scan() {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const htmlFiles = await listFiles(config.prototypeDir, ['.html'])
|
const htmlFiles = await listScanHtmlFiles(config)
|
||||||
if (htmlFiles.length === 0) throw new Error(`找不到 HTML prototype:${config.prototypeDir}`)
|
if (htmlFiles.length === 0) throw new Error(`找不到 HTML prototype:${config.prototypeDir}`)
|
||||||
const prototypeGuides = await readPrototypeGuides(config)
|
const prototypeGuides = await readPrototypeGuides(config)
|
||||||
const apiCatalog = await readApiCatalog(config)
|
const apiCatalog = await readApiCatalog(config)
|
||||||
await ensureDir(config.htDir)
|
await ensureDir(config.htDir)
|
||||||
|
if (isModuleScopedScan(config, htmlFiles)) await resetModuleScopedArtifacts(config)
|
||||||
const plans = []
|
const plans = []
|
||||||
for (const file of htmlFiles) {
|
for (const file of htmlFiles) {
|
||||||
plans.push(await prepareScanFile(config, file))
|
plans.push(await prepareScanFile(config, file))
|
||||||
@@ -51,6 +52,22 @@ export async function scan() {
|
|||||||
console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`)
|
console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listScanHtmlFiles(config) {
|
||||||
|
const moduleDir = join(config.prototypeDir, 'module')
|
||||||
|
if (await exists(moduleDir)) return listFiles(moduleDir, ['.html'])
|
||||||
|
return listFiles(config.prototypeDir, ['.html'])
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModuleScopedScan(config, htmlFiles) {
|
||||||
|
const moduleDir = join(config.prototypeDir, 'module')
|
||||||
|
return htmlFiles.length > 0 && htmlFiles.every((file) => file.startsWith(moduleDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetModuleScopedArtifacts(config) {
|
||||||
|
await rm(join(config.htDir, 'spec'), { recursive: true, force: true })
|
||||||
|
await rm(join(config.htDir, 'cache/prototype'), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取 prototype 旁的人工導覽文件。
|
* 讀取 prototype 旁的人工導覽文件。
|
||||||
* 這些 guide 是 legacy JSP/PB、checklist 與 flow refs 的來源,後續 BDD 與 maintenance contract 都會使用。
|
* 這些 guide 是 legacy JSP/PB、checklist 與 flow refs 的來源,後續 BDD 與 maintenance contract 都會使用。
|
||||||
@@ -213,6 +230,7 @@ async function captureRenderedPrototype(config, plan, serverUrl) {
|
|||||||
|
|
||||||
const playwrightCwd = join(config.htDir, 'cache/playwright-cli')
|
const playwrightCwd = join(config.htDir, 'cache/playwright-cli')
|
||||||
await ensureDir(playwrightCwd)
|
await ensureDir(playwrightCwd)
|
||||||
|
await ensurePlaywrightCliConfig(playwrightCwd, config.cwd)
|
||||||
const commandCwd = resolved.source === 'npx-local' ? config.cwd : playwrightCwd
|
const commandCwd = resolved.source === 'npx-local' ? config.cwd : playwrightCwd
|
||||||
const session = `ht-${process.pid}-${plan.hash.slice(0, 8)}`
|
const session = `ht-${process.pid}-${plan.hash.slice(0, 8)}`
|
||||||
const url = new URL(`${encodePath(plan.name)}.html`, serverUrl).href
|
const url = new URL(`${encodePath(plan.name)}.html`, serverUrl).href
|
||||||
@@ -261,6 +279,27 @@ async function captureRenderedPrototype(config, plan, serverUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensurePlaywrightCliConfig(playwrightCwd, repoCwd) {
|
||||||
|
const sourcePath = join(repoCwd, '.playwright/cli.config.json')
|
||||||
|
let browserName = 'chromium'
|
||||||
|
if (await exists(sourcePath)) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await readFile(sourcePath, 'utf8'))
|
||||||
|
browserName = parsed?.browser?.browserName ?? browserName
|
||||||
|
} catch {
|
||||||
|
browserName = 'chromium'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const override = {
|
||||||
|
browser: {
|
||||||
|
browserName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const targetDir = join(playwrightCwd, '.playwright')
|
||||||
|
await ensureDir(targetDir)
|
||||||
|
await writeFile(join(targetDir, 'cli.config.json'), `${JSON.stringify(override, null, 2)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 建立 Playwright CLI 執行的頁面擷取程式。
|
* 建立 Playwright CLI 執行的頁面擷取程式。
|
||||||
* 這段字串刻意集中處理 rendered DOM evidence,讓 source HTML 與實際瀏覽器狀態的差異能被 capture artifacts 保留下來。
|
* 這段字串刻意集中處理 rendered DOM evidence,讓 source HTML 與實際瀏覽器狀態的差異能被 capture artifacts 保留下來。
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ test('CLI runs doctor and scan against one prototype', async () => {
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [\`index.html\`](index.html) | \`legacy/index.jsp\` | Customer portal entry |
|
| [\`index.html\`](index.html) | \`legacy/index.jsp\` | Customer portal entry |
|
||||||
`)
|
`)
|
||||||
|
await mkdir(join(cwd, '.playwright'), { recursive: true })
|
||||||
|
await writeFile(join(cwd, '.playwright/cli.config.json'), JSON.stringify({
|
||||||
|
browser: {
|
||||||
|
browserName: 'chromium'
|
||||||
|
}
|
||||||
|
}, null, 2))
|
||||||
|
|
||||||
const doctor = await exec('node', [cli, 'doctor'], { cwd })
|
const doctor = await exec('node', [cli, 'doctor'], { cwd })
|
||||||
await exec('node', [cli, 'scan'], { cwd })
|
await exec('node', [cli, 'scan'], { cwd })
|
||||||
@@ -59,7 +65,6 @@ test('CLI runs doctor and scan against one prototype', async () => {
|
|||||||
assert.equal(appMap.routes[0].evidence.recommendedTemplate, undefined)
|
assert.equal(appMap.routes[0].evidence.recommendedTemplate, undefined)
|
||||||
assert.equal(appMap.routes[0].guide.legacyJsp, 'legacy/index.jsp')
|
assert.equal(appMap.routes[0].guide.legacyJsp, 'legacy/index.jsp')
|
||||||
assert.equal(appMap.routes[0].guide.description, 'Customer portal entry')
|
assert.equal(appMap.routes[0].guide.description, 'Customer portal entry')
|
||||||
assert.equal(appMap.guideSources[0].source, 'portal.md')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function pick(object, keys) {
|
function pick(object, keys) {
|
||||||
|
|||||||
+2
-7
@@ -131,7 +131,7 @@ test('validatePageContract reports evidence mismatches', () => {
|
|||||||
assert.match(report.warnings.join('\n'), /Save/)
|
assert.match(report.warnings.join('\n'), /Save/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildAppMap classifies auth, shell references, and feature pages', () => {
|
test('buildAppMap only keeps AI-implementable auth and feature pages', () => {
|
||||||
const prototypeDir = '/repo/prototype'
|
const prototypeDir = '/repo/prototype'
|
||||||
const specs = [
|
const specs = [
|
||||||
buildSpec('/repo/prototype/portal/login.html', '<main><input type="password" name="pwd"><button>登入</button></main>'),
|
buildSpec('/repo/prototype/portal/login.html', '<main><input type="password" name="pwd"><button>登入</button></main>'),
|
||||||
@@ -146,11 +146,9 @@ test('buildAppMap classifies auth, shell references, and feature pages', () => {
|
|||||||
assert.equal(buildAppMap([
|
assert.equal(buildAppMap([
|
||||||
buildSpec('/repo/prototype/portal/forget-password.html', '<main><h1>忘記密碼</h1><input name="idno"><button>發送至信箱</button></main>')
|
buildSpec('/repo/prototype/portal/forget-password.html', '<main><h1>忘記密碼</h1><input name="idno"><button>發送至信箱</button></main>')
|
||||||
], prototypeDir).routes[0].targetRole, 'forgot-password')
|
], prototypeDir).routes[0].targetRole, 'forgot-password')
|
||||||
assert.equal(appMap.routes.find((route) => route.prototype === 'portal/app-layout.html').kind, 'legacy-shell-reference')
|
assert.equal(appMap.routes.find((route) => route.prototype === 'portal/app-layout.html'), undefined)
|
||||||
assert.equal(appMap.routes.find((route) => route.prototype === 'portal/app-layout.html').usePrototypeContent, false)
|
|
||||||
assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').kind, 'feature-page')
|
assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').kind, 'feature-page')
|
||||||
assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').layout, 'template-app')
|
assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').layout, 'template-app')
|
||||||
assert.equal(appMap.modules.find((module) => module.name === 'venue').kind, 'feature-module')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildAppMap enriches routes with prototype markdown guide entries', () => {
|
test('buildAppMap enriches routes with prototype markdown guide entries', () => {
|
||||||
@@ -177,13 +175,10 @@ test('buildAppMap enriches routes with prototype markdown guide entries', () =>
|
|||||||
], prototypeDir, [guide])
|
], prototypeDir, [guide])
|
||||||
const route = appMap.routes[0]
|
const route = appMap.routes[0]
|
||||||
|
|
||||||
assert.equal(appMap.guideSources[0].source, 'venue.md')
|
|
||||||
assert.equal(route.evidence.prototypeGuide, 'venue.md')
|
assert.equal(route.evidence.prototypeGuide, 'venue.md')
|
||||||
assert.equal(route.guide.legacyJsp, 'zte_pro/zte451_02.jsp + zte451_02_1.jsp')
|
assert.equal(route.guide.legacyJsp, 'zte_pro/zte451_02.jsp + zte451_02_1.jsp')
|
||||||
assert.equal(route.guide.legacyPb, 'n_zte451.of_zte451_02 / of_zte451_02_1')
|
assert.equal(route.guide.legacyPb, 'n_zte451.of_zte451_02 / of_zte451_02_1')
|
||||||
assert.equal(route.guide.targetView, 'RoomQueryView.vue')
|
assert.equal(route.guide.targetView, 'RoomQueryView.vue')
|
||||||
assert.equal(appMap.guideSources[0].legacyFlowCount, 1)
|
|
||||||
assert.equal(appMap.legacyFlows[0].tasks[0], '場地查詢')
|
|
||||||
assert.equal(route.guide.flowRefs[0].tasks[0], '場地查詢')
|
assert.equal(route.guide.flowRefs[0].tasks[0], '場地查詢')
|
||||||
assert.deepEqual(route.guide.flowRefs[0].matchedKeys, ['zte451_02.jsp', 'of_zte451_02', 'zte451_02_1.jsp', 'of_zte451_02_1'])
|
assert.deepEqual(route.guide.flowRefs[0].matchedKeys, ['zte451_02.jsp', 'of_zte451_02', 'zte451_02_1.jsp', 'of_zte451_02_1'])
|
||||||
assert.equal(route.guide.flowRefs[0].nodes[0].nodeType, 'query')
|
assert.equal(route.guide.flowRefs[0].nodes[0].nodeType, 'query')
|
||||||
|
|||||||
Reference in New Issue
Block a user