feat: 參考backend
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
HTML Transform 是 prototype evidence 工具。現行 MVP 只做兩件事:
|
HTML Transform 是 prototype evidence 工具。現行 MVP 只做兩件事:
|
||||||
|
|
||||||
- `doctor`:檢查執行 `scan` 需要的前置條件。
|
- `doctor`:檢查執行 `scan` 需要的前置條件。
|
||||||
- `scan`:讀取 HTML prototype,產生瀏覽器證據與頁面級 UI Contract。
|
- `scan`:讀取 HTML prototype、prototype guide 與 backend API docs,產生瀏覽器證據、頁面級 UI Contract 與 implementation contract。
|
||||||
- `app-map.json`:推論每個 prototype 的頁面角色與 layout 使用策略,並用 `prototype/*.md` 的人工 domain guide 輔助補上舊系統對照。
|
- `app-map.json`:推論每個 prototype 的頁面角色、layout 使用策略、API 對應、維護頁建議範本,並用 `prototype/*.md` 的人工 domain guide 輔助補上舊系統對照。
|
||||||
|
|
||||||
不提供 `plan`、`run`、`diff`、`verify`、`go`、`status`。這些舊骨架已移除,避免把尚未完成的自動化流程誤認成可用功能。
|
不提供 `plan`、`run`、`diff`、`verify`、`go`、`status`。這些舊骨架已移除,避免把尚未完成的自動化流程誤認成可用功能。
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ HTML Transform 是 prototype evidence 工具。現行 MVP 只做兩件事:
|
|||||||
Stage 1 Capture
|
Stage 1 Capture
|
||||||
Stage 3-lite Page Contract
|
Stage 3-lite Page Contract
|
||||||
Stage 4-lite Contract Validation
|
Stage 4-lite Contract Validation
|
||||||
|
API Catalog
|
||||||
|
Maintenance Contract
|
||||||
```
|
```
|
||||||
|
|
||||||
Stage 2 decomposition 不執行。現有 prototype 主要是後台系統頁面,重點是表單、查詢條件、表格、actions 與資訊架構;切 region screenshot 對 MVP 價值不高。
|
Stage 2 decomposition 不執行。現有 prototype 主要是後台系統頁面,重點是表單、查詢條件、表格、actions 與資訊架構;切 region screenshot 對 MVP 價值不高。
|
||||||
@@ -69,6 +71,8 @@ pnpm --version
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
[playwright](https://playwright.dev/agent-cli/installation)
|
||||||
|
|
||||||
確認 `playwright-cli` 可用:
|
確認 `playwright-cli` 可用:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -95,11 +99,44 @@ packages/prototype/
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
export default {
|
export default {
|
||||||
prototype: './prototype'
|
prototype: "./prototype",
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
目前 MVP 只使用 `prototype`。`scan` 會讀取其中的 HTML prototype,也會讀取 `prototype/*.md` 作為 app-map 的輔助 domain guide。`frontend`、`backend`、`output`、`plan` 等欄位不會被 `doctor` 或 `scan` 使用,不需要為本 MVP 加入設定檔。
|
`scan` 目前使用:
|
||||||
|
|
||||||
|
- `prototype`:HTML prototype 與 `prototype/*.md` domain guide 來源。
|
||||||
|
- `backendDocs`:backend API markdown / OpenAPI JSON/YAML docs 來源,預設 `./apps/backend`。
|
||||||
|
|
||||||
|
本 repo 的 `backendDocs` 使用預設值即可。若其他專案的 backend docs 不在 `./apps/backend`,可明確指定:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
prototype: "./prototype",
|
||||||
|
backendDocs: "./docs/backend",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
若要同時讀取多個 backend docs 來源,或混用 markdown 與 OpenAPI JSON/YAML,可使用陣列:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
prototype: "./prototype",
|
||||||
|
backendDocs: ["./apps/backend", "./docs/openapi"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
若只有 prototype、沒有 backend docs,可只指定 prototype:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
prototype: "./prototype",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
若 `backendDocs` 目錄不存在或沒有 markdown/OpenAPI 文件,`scan` 仍會完成,只是 `.ht/api-catalog.json` 會沒有 endpoints,頁面 spec 的 `apiContract.endpoints` 也會是空陣列。
|
||||||
|
|
||||||
|
`frontend`、`backend`、`output`、`plan` 等欄位不會被 `doctor` 或 `scan` 使用,不需要為本 MVP 加入設定檔。
|
||||||
|
|
||||||
## Doctor
|
## Doctor
|
||||||
|
|
||||||
@@ -131,8 +168,11 @@ node packages/html-transform/src/cli.js scan
|
|||||||
- 記錄 resource failure 與 console error/warning。
|
- 記錄 resource failure 與 console error/warning。
|
||||||
- 建立頁面級 `pageContract`。
|
- 建立頁面級 `pageContract`。
|
||||||
- 驗證 contract 與 DOM evidence 是否明顯衝突。
|
- 驗證 contract 與 DOM evidence 是否明顯衝突。
|
||||||
- 讀取 `prototype/*.md` 的對照表,擷取 prototype 檔、舊 JSP、舊 PB、對應 Vue view 或功能描述。
|
- 讀取 `prototype/*.md` 的對照表與 checklist,擷取 prototype 檔、舊 JSP、舊 PB、對應 Vue view、功能描述與人工 checklist。
|
||||||
- 產出 `.ht/app-map.json`,供通用 prompt 判斷 auth、legacy shell、feature page、layout 策略與舊系統對照。
|
- 讀取 backend API markdown docs,建立 endpoint catalog、schema 摘要、欄位規則與錯誤格式。
|
||||||
|
- 依 prototype evidence、guide 與 API catalog 建立 `apiContract`。
|
||||||
|
- 依頁面 evidence 建立 `maintenanceContract`,推論頁面型態、操作能力與建議維護頁範本。
|
||||||
|
- 產出 `.ht/app-map.json`,供通用 prompt 判斷 auth、legacy shell、feature page、layout 策略、API 對應與舊系統對照。
|
||||||
|
|
||||||
## 產物
|
## 產物
|
||||||
|
|
||||||
@@ -148,16 +188,44 @@ node packages/html-transform/src/cli.js scan
|
|||||||
每個 HTML 的 contract artifact:
|
每個 HTML 的 contract artifact:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
.ht/api-catalog.json
|
||||||
.ht/app-map.json
|
.ht/app-map.json
|
||||||
.ht/spec/{page}.spec.json
|
.ht/spec/{page}.spec.json
|
||||||
.ht/spec/{page}.validation.json
|
.ht/spec/{page}.validation.json
|
||||||
.ht/spec/{page}.ui-contract.md
|
.ht/spec/{page}.ui-contract.md
|
||||||
```
|
```
|
||||||
|
|
||||||
`.spec.json` 會包含 `pageContract`。`regions` 欄位目前仍保留,目的是相容既有資料形狀;不要把它視為 Stage 2 decomposition。
|
`.ht/api-catalog.json` 是跨頁面的 backend API catalog,來源是 `backendDocs` 目錄下的 markdown 文件。它會包含:
|
||||||
|
|
||||||
|
- `endpoints`
|
||||||
|
- `schemas`
|
||||||
|
- `fieldRules`
|
||||||
|
- `errorContract`
|
||||||
|
|
||||||
|
`.spec.json` 會包含:
|
||||||
|
|
||||||
|
- `pageContract`:頁面文字、欄位、表格、actions、layout evidence 與 Vuetify checklist。
|
||||||
|
- `apiContract`:與該頁匹配的 API endpoints、用途分類、rejected candidates 與錯誤格式。
|
||||||
|
- `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。
|
||||||
|
- `maintenanceContract`:頁面型態、capabilities、recommended template、data model 與 warnings。
|
||||||
|
- `regions`:目前仍保留,目的是相容既有資料形狀;不要把它視為 Stage 2 decomposition。
|
||||||
|
|
||||||
`.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。
|
`.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。
|
||||||
|
|
||||||
|
route 也會包含 implementation hints,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prototype": "venue/applications-list.html",
|
||||||
|
"kind": "feature-page",
|
||||||
|
"pageKind": "maintenance",
|
||||||
|
"recommendedTemplate": "single-record",
|
||||||
|
"primaryEntity": "ApplicationsList",
|
||||||
|
"capabilities": ["back", "search", "edit", "delete", "print"],
|
||||||
|
"apiCount": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
若 `prototype/*.md` 內有 markdown table 對照 prototype HTML,例如 `venue/query-room.html`,`scan` 會把匹配結果寫進 route:
|
若 `prototype/*.md` 內有 markdown table 對照 prototype HTML,例如 `venue/query-room.html`,`scan` 會把匹配結果寫進 route:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -168,15 +236,37 @@ node packages/html-transform/src/cli.js scan
|
|||||||
"legacyJsp": "zte_pro/zte451_02.jsp + zte451_02_1.jsp",
|
"legacyJsp": "zte_pro/zte451_02.jsp + zte451_02_1.jsp",
|
||||||
"legacyPb": "n_zte451.of_zte451_02 / of_zte451_02_1",
|
"legacyPb": "n_zte451.of_zte451_02 / of_zte451_02_1",
|
||||||
"targetView": "RoomQueryView.vue",
|
"targetView": "RoomQueryView.vue",
|
||||||
"description": null
|
"description": null,
|
||||||
|
"checklist": []
|
||||||
},
|
},
|
||||||
"evidence": {
|
"evidence": {
|
||||||
"prototypeGuide": "venue.md"
|
"prototypeGuide": "venue.md",
|
||||||
|
"apiCount": 2,
|
||||||
|
"recommendedTemplate": "none"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
這些 guide 欄位只輔助 route 與舊系統對照理解;HTML capture、DOM summary、UI contract 與 screenshot 仍是畫面內容的主要 evidence。
|
這些 guide 欄位輔助 route、舊系統對照與 checklist 理解;HTML capture、DOM summary、UI contract 與 screenshot 仍是畫面內容的主要 evidence。API catalog 與 `apiContract` 則用來降低 endpoint、DTO 與欄位規則的猜測。
|
||||||
|
|
||||||
|
## 使用方式是否改變
|
||||||
|
|
||||||
|
CLI 使用方式沒有改,仍然是:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node packages/html-transform/src/cli.js doctor
|
||||||
|
node packages/html-transform/src/cli.js scan
|
||||||
|
```
|
||||||
|
|
||||||
|
有改變的是 `scan` 的輸入與輸出:
|
||||||
|
|
||||||
|
- 新增可選輸入:`backendDocs`,預設 `./apps/backend`。
|
||||||
|
- 新增輸出:`.ht/api-catalog.json`。
|
||||||
|
- `.ht/spec/{page}.spec.json` 新增 `apiContract`、`prototypeGuide`、`maintenanceContract`。
|
||||||
|
- `.ht/app-map.json` route 新增 `pageKind`、`recommendedTemplate`、`capabilities`、`primaryEntity`、`apiCount`。
|
||||||
|
- `.ui-contract.md` 會顯示 page kind、recommended template、prototype checklist 與 API endpoints。
|
||||||
|
|
||||||
|
因此既有 `doctor` / `scan` 指令不用改;但使用 `.ht` 產物的 prompt 或下游工具,應改讀新增欄位。
|
||||||
|
|
||||||
## 驗證
|
## 驗證
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,173 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## Improve `.ht` Layout Evidence
|
## 目前狀態
|
||||||
|
|
||||||
Current `scan` evidence is useful for finding page structure, labels, inputs, buttons, tables, screenshots, and app-map hints, but it is still too weak as an implementation constraint for visual layout.
|
`html-transform scan` 已從單純 prototype evidence,推進到可輸出 Page Implementation Contract 的第一版。
|
||||||
|
|
||||||
Observed problem:
|
已完成:
|
||||||
|
|
||||||
- Generated Vue/Vuetify pages can preserve fields, buttons, routes, and APIs while drifting far from prototype layout.
|
- 強化 prototype form/table/action 萃取。
|
||||||
- Reports may pass pages because functional elements exist, even when form density, alignment, spacing, table structure, and first-screen information density are wrong.
|
- 支援 legacy table-form 的 `<th>/<td>` 欄位 label 推論。
|
||||||
- `desktop-default.png` contains this evidence visually, but the generated JSON/Markdown artifacts do not expose enough layout metrics for deterministic checks.
|
- 欄位輸出包含 `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。
|
||||||
|
|
||||||
Future work:
|
已驗證:
|
||||||
|
|
||||||
- Capture bounding boxes for important visible elements:
|
```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
|
- headings
|
||||||
- labels
|
- labels
|
||||||
- inputs/selects/textareas
|
- inputs/selects/textareas
|
||||||
- buttons/links
|
- buttons/links
|
||||||
- tables
|
- tables
|
||||||
- section containers
|
- section containers
|
||||||
- Group form fields into likely rows and columns using bounding boxes.
|
- 用 bounding boxes 將表單欄位分群成可能的 rows / columns。
|
||||||
- Detect table/list layout:
|
- 擷取 first-viewport density:
|
||||||
- table count
|
|
||||||
- table order
|
|
||||||
- header labels
|
|
||||||
- whether multiple tables are stacked vertically
|
|
||||||
- Capture first-viewport density:
|
|
||||||
- visible section count
|
- visible section count
|
||||||
- visible form field count
|
- visible form field count
|
||||||
- visible table/header count
|
- visible table/header count
|
||||||
- approximate occupied content area
|
- approximate occupied content area
|
||||||
- Emit layout hints into `.ht/spec/{page}.spec.json`, for example:
|
- 在 `.ht/spec/{page}.spec.json` 輸出:
|
||||||
- `layoutEvidence.sections`
|
- `layoutEvidence.sections`
|
||||||
- `layoutEvidence.formRows`
|
- `layoutEvidence.formRows`
|
||||||
- `layoutEvidence.tables`
|
- `layoutEvidence.tables`
|
||||||
- `layoutEvidence.primaryActions`
|
- `layoutEvidence.primaryActions`
|
||||||
- `layoutEvidence.firstViewport`
|
- `layoutEvidence.firstViewport`
|
||||||
- Render layout hints into `.ht/spec/{page}.ui-contract.md` so LLMs see layout constraints without manually inferring everything from screenshots.
|
- 在 `.ht/spec/{page}.ui-contract.md` 呈現 layout hints。
|
||||||
- Add validation warnings for likely layout-critical structures:
|
|
||||||
- stacked tables
|
|
||||||
- dense row-based forms
|
|
||||||
- query toolbar with adjacent submit button
|
|
||||||
- multi-row detail tables
|
|
||||||
|
|
||||||
Non-goals:
|
Non-goals:
|
||||||
|
|
||||||
- Do not require pixel-perfect HTML/JSP reproduction.
|
- 不要求 pixel-perfect HTML/JSP reproduction。
|
||||||
- Do not copy legacy CSS.
|
- 不複製 legacy CSS。
|
||||||
- Do not encode exact colors/fonts as hard constraints unless needed for functional recognition.
|
- 除非是功能辨識必要,不把精確 colors/fonts 編成硬限制。
|
||||||
|
|
||||||
Goal:
|
### 2. API Matching 品質
|
||||||
|
|
||||||
- Preserve information architecture, form density, field alignment, table/list relationships, and operation flow while still allowing a modern Vue/Vuetify implementation.
|
目前 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 時的輸出行為
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
|
export function buildApiCatalog(documents = []) {
|
||||||
|
const endpoints = []
|
||||||
|
const fieldRules = []
|
||||||
|
let errorContract = null
|
||||||
|
|
||||||
|
for (const document of documents) {
|
||||||
|
const source = document.source
|
||||||
|
const markdown = document.markdown ?? document.content ?? ''
|
||||||
|
if (/\.ya?ml$/i.test(source)) {
|
||||||
|
const openapi = parseOpenApiDocument(source, markdown)
|
||||||
|
endpoints.push(...openapi.endpoints)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/\.json$/i.test(source)) {
|
||||||
|
const openapi = parseOpenApiDocument(source, markdown)
|
||||||
|
endpoints.push(...openapi.endpoints)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/API_Manual\.md$/i.test(source)) {
|
||||||
|
const manual = parseApiManual(source, markdown)
|
||||||
|
endpoints.push(...manual.endpoints)
|
||||||
|
fieldRules.push(...manual.fieldRules)
|
||||||
|
errorContract ??= manual.errorContract
|
||||||
|
} else {
|
||||||
|
endpoints.push(...parseApiIndex(source, markdown))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergeEndpoints(endpoints)
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
sources: documents.map((document) => document.source),
|
||||||
|
endpoints: merged,
|
||||||
|
schemas: buildSchemas(merged),
|
||||||
|
fieldRules,
|
||||||
|
errorContract
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApiIndex(source, markdown) {
|
||||||
|
const moduleName = inferModuleName(source, markdown)
|
||||||
|
const rows = parseMarkdownTables(markdown)
|
||||||
|
return rows.flatMap((row) => {
|
||||||
|
const method = findCell(row, ['方法', 'method'])?.toUpperCase()
|
||||||
|
const path = normalizeCode(findCell(row, ['路徑', 'path']))
|
||||||
|
if (!method || !path || !/^(GET|POST|PUT|DELETE|PATCH)$/i.test(method)) return []
|
||||||
|
return [{
|
||||||
|
id: endpointId(method, path),
|
||||||
|
source,
|
||||||
|
module: moduleName,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
description: normalizeCode(findCell(row, ['說明', 'description'])) ?? '',
|
||||||
|
auth: normalizeCode(findCell(row, ['授權', 'auth'])) ?? null,
|
||||||
|
query: parseQueryParams(path),
|
||||||
|
requestExample: null,
|
||||||
|
responseExample: null,
|
||||||
|
notes: [],
|
||||||
|
fieldRules: []
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApiManual(source, markdown) {
|
||||||
|
const endpoints = []
|
||||||
|
const fieldRules = []
|
||||||
|
const sections = splitSections(markdown)
|
||||||
|
let currentModule = null
|
||||||
|
let errorContract = null
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
if (/^\d+\.\s+/.test(section.title)) currentModule = cleanHeading(section.title)
|
||||||
|
if (/ProblemDetails|錯誤格式/i.test(section.title)) {
|
||||||
|
errorContract = parseErrorContract(section.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodPath = findMethodPath(section.body)
|
||||||
|
if (!methodPath) continue
|
||||||
|
|
||||||
|
const endpoint = {
|
||||||
|
id: endpointId(methodPath.method, methodPath.path),
|
||||||
|
source,
|
||||||
|
module: currentModule ? moduleSlug(currentModule) : null,
|
||||||
|
section: cleanHeading(section.title),
|
||||||
|
method: methodPath.method,
|
||||||
|
path: methodPath.pathWithoutQuery,
|
||||||
|
description: cleanHeading(section.title).replace(/^\d+(\.\d+)*\s*/, ''),
|
||||||
|
auth: null,
|
||||||
|
query: uniqueQueryParams([...methodPath.query, ...parseQueryMentions(section.body)]),
|
||||||
|
requestExample: findJsonAfterLabel(section.body, 'Request'),
|
||||||
|
responseExample: findJsonAfterLabel(section.body, 'Response'),
|
||||||
|
notes: parseNotes(section.body),
|
||||||
|
fieldRules: parseFieldRules(section.body)
|
||||||
|
}
|
||||||
|
endpoints.push(endpoint)
|
||||||
|
for (const rule of endpoint.fieldRules) {
|
||||||
|
fieldRules.push({ ...rule, endpointId: endpoint.id, source })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { endpoints, fieldRules, errorContract }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchApiEndpoints(route, catalog) {
|
||||||
|
return matchApiEndpointCandidates(route, catalog).matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchApiEndpointCandidates(route, catalog) {
|
||||||
|
if (!catalog?.endpoints?.length) return { matches: [], rejected: [] }
|
||||||
|
const tokens = routeTokens(route)
|
||||||
|
const candidates = catalog.endpoints
|
||||||
|
.map((endpoint) => ({ endpoint, score: scoreEndpoint(tokens, endpoint) }))
|
||||||
|
.sort((a, b) => b.score - a.score || a.endpoint.path.localeCompare(b.endpoint.path))
|
||||||
|
const accepted = candidates.filter((item) => item.score > 0).slice(0, 8)
|
||||||
|
return {
|
||||||
|
matches: accepted.map((item) => endpointMatch(item.endpoint, item.score)),
|
||||||
|
rejected: candidates
|
||||||
|
.filter((item) => !accepted.includes(item))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.endpoint.id,
|
||||||
|
method: item.endpoint.method,
|
||||||
|
path: item.endpoint.path,
|
||||||
|
description: item.endpoint.description,
|
||||||
|
score: item.score,
|
||||||
|
reason: item.score <= 0 ? 'no shared route/prototype/API tokens' : 'lower scoring than accepted candidates'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointMatch(endpoint, score) {
|
||||||
|
return {
|
||||||
|
id: endpoint.id,
|
||||||
|
method: endpoint.method,
|
||||||
|
path: endpoint.path,
|
||||||
|
description: endpoint.description,
|
||||||
|
score,
|
||||||
|
usage: inferEndpointUsage(endpoint),
|
||||||
|
requestSchema: inferSchemaName(endpoint, 'request'),
|
||||||
|
responseSchema: inferSchemaName(endpoint, 'response')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferEndpointUsage(endpoint) {
|
||||||
|
const path = endpoint.path.toLowerCase()
|
||||||
|
const text = `${endpoint.description ?? ''} ${path}`.toLowerCase()
|
||||||
|
if (endpoint.method === 'GET' && /lookup|options|choose|list|代碼|選單/.test(text)) return 'lookup'
|
||||||
|
if (endpoint.method === 'GET' && /print|列印/.test(text)) return 'print'
|
||||||
|
if (endpoint.method === 'GET' && /\{[^}]+\}|detail|明細/.test(path)) return 'detail'
|
||||||
|
if (endpoint.method === 'GET') return 'search'
|
||||||
|
if (endpoint.method === 'POST' && /print|列印/.test(text)) return 'print'
|
||||||
|
if (endpoint.method === 'POST' && /action|approve|cancel|availability|檢核|審核|取消/.test(text)) return 'customAction'
|
||||||
|
if (endpoint.method === 'POST') return 'create'
|
||||||
|
if (endpoint.method === 'PUT' || endpoint.method === 'PATCH') return 'update'
|
||||||
|
if (endpoint.method === 'DELETE') return 'delete'
|
||||||
|
return 'customAction'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenApiDocument(source, content) {
|
||||||
|
let doc = null
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(content)
|
||||||
|
} catch {
|
||||||
|
doc = parseSimpleYamlOpenApi(content)
|
||||||
|
}
|
||||||
|
const endpoints = []
|
||||||
|
for (const [path, methods] of Object.entries(doc?.paths ?? {})) {
|
||||||
|
for (const [method, operation] of Object.entries(methods ?? {})) {
|
||||||
|
if (!/^(get|post|put|delete|patch)$/i.test(method)) continue
|
||||||
|
endpoints.push({
|
||||||
|
id: endpointId(method.toUpperCase(), path),
|
||||||
|
source,
|
||||||
|
module: operation.tags?.[0] ? moduleSlug(operation.tags[0]) : null,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
path,
|
||||||
|
description: operation.summary ?? operation.description ?? '',
|
||||||
|
auth: operation.security ? 'security' : null,
|
||||||
|
query: (operation.parameters ?? []).filter((param) => param.in === 'query').map((param) => param.name),
|
||||||
|
requestExample: null,
|
||||||
|
responseExample: null,
|
||||||
|
notes: [],
|
||||||
|
fieldRules: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { endpoints }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSimpleYamlOpenApi(content) {
|
||||||
|
const paths = {}
|
||||||
|
const lines = content.split('\n')
|
||||||
|
let currentPath = null
|
||||||
|
let currentMethod = null
|
||||||
|
for (const line of lines) {
|
||||||
|
const pathMatch = line.match(/^\s{2}(\/[^:]+):\s*$/)
|
||||||
|
if (pathMatch) {
|
||||||
|
currentPath = pathMatch[1]
|
||||||
|
paths[currentPath] = paths[currentPath] ?? {}
|
||||||
|
currentMethod = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const methodMatch = line.match(/^\s{4}(get|post|put|delete|patch):\s*$/i)
|
||||||
|
if (currentPath && methodMatch) {
|
||||||
|
currentMethod = methodMatch[1].toLowerCase()
|
||||||
|
paths[currentPath][currentMethod] = {}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const summaryMatch = line.match(/^\s{6}(summary|description):\s*(.+)$/)
|
||||||
|
if (currentPath && currentMethod && summaryMatch) paths[currentPath][currentMethod][summaryMatch[1]] = summaryMatch[2].replace(/^['"]|['"]$/g, '')
|
||||||
|
}
|
||||||
|
return { paths }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEndpoints(endpoints) {
|
||||||
|
const index = new Map()
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const current = index.get(endpoint.id)
|
||||||
|
if (!current) {
|
||||||
|
index.set(endpoint.id, endpoint)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index.set(endpoint.id, {
|
||||||
|
...current,
|
||||||
|
...endpoint,
|
||||||
|
description: endpoint.description || current.description,
|
||||||
|
auth: endpoint.auth ?? current.auth,
|
||||||
|
query: uniqueQueryParams([...(current.query ?? []), ...(endpoint.query ?? [])]),
|
||||||
|
requestExample: endpoint.requestExample ?? current.requestExample,
|
||||||
|
responseExample: endpoint.responseExample ?? current.responseExample,
|
||||||
|
notes: [...new Set([...(current.notes ?? []), ...(endpoint.notes ?? [])])],
|
||||||
|
fieldRules: [...(current.fieldRules ?? []), ...(endpoint.fieldRules ?? [])],
|
||||||
|
source: [...new Set([current.source, endpoint.source].filter(Boolean))].join(', ')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...index.values()].sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSchemas(endpoints) {
|
||||||
|
const schemas = {}
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
if (endpoint.requestExample && typeof endpoint.requestExample === 'object') {
|
||||||
|
schemas[inferSchemaName(endpoint, 'request')] = inferSchema(endpoint.requestExample)
|
||||||
|
}
|
||||||
|
if (endpoint.responseExample && typeof endpoint.responseExample === 'object') {
|
||||||
|
schemas[inferSchemaName(endpoint, 'response')] = inferSchema(endpoint.responseExample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schemas
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSchemaName(endpoint, direction) {
|
||||||
|
const parts = endpoint.path
|
||||||
|
.split('/')
|
||||||
|
.filter((part) => part && !part.startsWith('{') && !part.startsWith('_'))
|
||||||
|
.slice(-3)
|
||||||
|
.map((part) => part.replace(/[^a-z0-9]+/gi, ' '))
|
||||||
|
.flatMap((part) => part.split(' '))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||||
|
return `${endpoint.method}${parts.join('')}${direction[0].toUpperCase()}${direction.slice(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSchema(value) {
|
||||||
|
if (Array.isArray(value)) return { type: 'array', items: value.length ? inferSchema(value[0]) : { type: 'unknown' } }
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties: Object.fromEntries(Object.entries(value).map(([key, child]) => [key, inferSchema(child)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: value === null ? 'null' : typeof value, example: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSections(markdown) {
|
||||||
|
const sections = []
|
||||||
|
const matches = [...markdown.matchAll(/^\s*(#{2,4})\s+(.+)$/gm)]
|
||||||
|
for (let index = 0; index < matches.length; index += 1) {
|
||||||
|
const match = matches[index]
|
||||||
|
const next = matches[index + 1]
|
||||||
|
sections.push({
|
||||||
|
title: match[2],
|
||||||
|
body: markdown.slice(match.index + match[0].length, next?.index ?? markdown.length)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdownTables(markdown) {
|
||||||
|
const tables = []
|
||||||
|
const lines = markdown.split('\n')
|
||||||
|
let headers = null
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!/^\s*\|/.test(line)) {
|
||||||
|
headers = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const cells = splitMarkdownRow(line)
|
||||||
|
if (cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))) continue
|
||||||
|
if (!headers) {
|
||||||
|
headers = cells.map(cleanMarkdown)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tables.push(new Map(headers.map((header, index) => [header, cleanMarkdown(cells[index] ?? '')])))
|
||||||
|
}
|
||||||
|
return tables
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCell(row, names) {
|
||||||
|
for (const [key, value] of row) {
|
||||||
|
if (names.some((name) => key?.toLowerCase().includes(name.toLowerCase()))) return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMarkdownRow(line) {
|
||||||
|
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMethodPath(markdown) {
|
||||||
|
const match = markdown.match(/\b(GET|POST|PUT|DELETE|PATCH)\s+([^\s`]+)/i)
|
||||||
|
if (!match) return null
|
||||||
|
const url = match[2]
|
||||||
|
const [pathWithoutQuery, queryString = ''] = url.split('?')
|
||||||
|
return {
|
||||||
|
method: match[1].toUpperCase(),
|
||||||
|
path: url,
|
||||||
|
pathWithoutQuery,
|
||||||
|
query: parseQueryParams(url).concat(queryString ? queryString.split('&').map((part) => part.split('=')[0]).filter(Boolean) : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findJsonAfterLabel(markdown, label) {
|
||||||
|
const pattern = new RegExp(`${label}[^\\n]*[\\s\\S]*?\`\`\`json\\n([\\s\\S]*?)\\n\`\`\``, 'i')
|
||||||
|
const match = markdown.match(pattern)
|
||||||
|
if (!match) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[1])
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFieldRules(markdown) {
|
||||||
|
const rules = []
|
||||||
|
const tableRows = parseMarkdownTables(markdown)
|
||||||
|
for (const row of tableRows) {
|
||||||
|
const field = normalizeCode(findCell(row, ['欄位', 'field']))
|
||||||
|
const rule = normalizeCode(findCell(row, ['規則', 'rule']))
|
||||||
|
if (field && rule) rules.push({ field, rule })
|
||||||
|
}
|
||||||
|
for (const item of markdown.matchAll(/^\s*-\s+`([^`]+)`\s*(.+)$/gm)) {
|
||||||
|
rules.push({ field: item[1], rule: cleanMarkdown(item[2]) })
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNotes(markdown) {
|
||||||
|
return [...markdown.matchAll(/^\s*-\s+(.+)$/gm)]
|
||||||
|
.map((item) => cleanMarkdown(item[1]))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorContract(markdown) {
|
||||||
|
const examples = [...markdown.matchAll(/```json\n([\s\S]*?)\n```/g)].flatMap((match) => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(match[1])]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
format: examples.some((example) => example?.errors) ? 'ProblemDetailsWithValidationErrors' : 'ProblemDetails',
|
||||||
|
examples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQueryParams(path) {
|
||||||
|
const query = path.split('?')[1]
|
||||||
|
if (!query) return []
|
||||||
|
return query.split('&').map((part) => part.split('=')[0]).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQueryMentions(markdown) {
|
||||||
|
return [...markdown.matchAll(/`([A-Za-z][A-Za-z0-9]*)`\s*可省略/g)].map((match) => match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueQueryParams(values) {
|
||||||
|
return [...new Set(values.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointId(method, path) {
|
||||||
|
return `${method.toUpperCase()} ${path.split('?')[0]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferModuleName(source, markdown) {
|
||||||
|
const heading = markdown.match(/^##\s+\d+\.\s+(.+?)(?:\s+\(|$)/m)?.[1]
|
||||||
|
return heading ? moduleSlug(heading) : basename(source).replace(/\.[^.]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleSlug(value) {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeTokens(route) {
|
||||||
|
return tokenize([
|
||||||
|
route.prototype,
|
||||||
|
route.page,
|
||||||
|
route.title,
|
||||||
|
route.module,
|
||||||
|
route.guide?.legacyJsp,
|
||||||
|
route.guide?.legacyPb,
|
||||||
|
route.guide?.targetView,
|
||||||
|
route.guide?.description,
|
||||||
|
...(route.evidence?.actions ?? []),
|
||||||
|
...(route.evidence?.textSamples ?? [])
|
||||||
|
].filter(Boolean).join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreEndpoint(tokens, endpoint) {
|
||||||
|
const endpointTokens = tokenize(`${endpoint.path} ${endpoint.description} ${endpoint.module ?? ''}`)
|
||||||
|
let score = 0
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (endpointTokens.has(token)) score += token.length > 3 ? 2 : 1
|
||||||
|
}
|
||||||
|
if (tokens.has('room') && endpoint.path.includes('/rooms')) score += 4
|
||||||
|
if (tokens.has('facility') && endpoint.path.includes('/facilities')) score += 4
|
||||||
|
if (tokens.has('application') && endpoint.path.includes('/applications')) score += 3
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(value) {
|
||||||
|
const aliases = {
|
||||||
|
場地: 'room',
|
||||||
|
教室: 'room',
|
||||||
|
設備: 'facility',
|
||||||
|
申請: 'application',
|
||||||
|
借用: 'application',
|
||||||
|
查詢: 'query',
|
||||||
|
列印: 'print',
|
||||||
|
修改: 'edit',
|
||||||
|
刪除: 'delete'
|
||||||
|
}
|
||||||
|
const normalized = String(value)
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.toLowerCase()
|
||||||
|
const tokens = new Set(normalized.split(/[^a-z0-9\u4e00-\u9fff]+/).filter((token) => token.length > 1))
|
||||||
|
for (const [text, alias] of Object.entries(aliases)) {
|
||||||
|
if (String(value).includes(text)) tokens.add(alias)
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCode(value) {
|
||||||
|
return value ? cleanMarkdown(value).replace(/^`|`$/g, '') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanMarkdown(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/\*\*/g, '')
|
||||||
|
.replace(/<br\s*\/?>/gi, ' / ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHeading(value) {
|
||||||
|
return cleanMarkdown(value).replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { defineConfig } from '../index.js'
|
|||||||
|
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
prototype: './packages/prototype',
|
prototype: './packages/prototype',
|
||||||
|
backendDocs: './apps/backend',
|
||||||
vision: {
|
vision: {
|
||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 1440, height: 900 },
|
||||||
captureStates: ['default'],
|
captureStates: ['default'],
|
||||||
@@ -55,6 +56,10 @@ function normalizeConfig(config, cwd) {
|
|||||||
...merged,
|
...merged,
|
||||||
cwd,
|
cwd,
|
||||||
prototypeDir: resolve(cwd, merged.prototype),
|
prototypeDir: resolve(cwd, merged.prototype),
|
||||||
|
backendDocsDirs: Array.isArray(merged.backendDocs)
|
||||||
|
? merged.backendDocs.map((path) => resolve(cwd, path))
|
||||||
|
: [resolve(cwd, merged.backendDocs)],
|
||||||
|
backendDocsDir: resolve(cwd, Array.isArray(merged.backendDocs) ? merged.backendDocs[0] : merged.backendDocs),
|
||||||
htDir: resolve(cwd, '.ht')
|
htDir: resolve(cwd, '.ht')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-23
@@ -7,16 +7,9 @@
|
|||||||
export function summarizeHtml(html) {
|
export function summarizeHtml(html) {
|
||||||
const title = matchText(html, /<title[^>]*>([\s\S]*?)<\/title>/i)
|
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 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)
|
const buttons = extractActionElements(html).map((action) => action.label)
|
||||||
.map((item) => cleanText(item[2]))
|
|
||||||
.filter(Boolean)
|
|
||||||
const labels = collect(html, /<label\b[^>]*>([\s\S]*?)<\/label>/gi).map((item) => cleanText(item[1]))
|
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) => ({
|
const inputs = extractFields(html)
|
||||||
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)
|
const textSamples = [...headings, ...labels, ...buttons, ...visibleText(html)].filter(Boolean).slice(0, 3)
|
||||||
return { title, headings, buttons, labels, inputs, textSamples }
|
return { title, headings, buttons, labels, inputs, textSamples }
|
||||||
}
|
}
|
||||||
@@ -135,12 +128,13 @@ export function buildPageContract({ page, source, html, regions, domSummary, scr
|
|||||||
})),
|
})),
|
||||||
forms: buildForms(summary),
|
forms: buildForms(summary),
|
||||||
tables: buildTables(html),
|
tables: buildTables(html),
|
||||||
actions: summary.buttons.map((label, index) => ({
|
actions: extractActionElements(html).map((action, index) => ({
|
||||||
label,
|
...action,
|
||||||
kind: index === 0 ? 'primary' : 'secondary',
|
kind: action.kind ?? (index === 0 ? 'primary' : 'secondary'),
|
||||||
observableResult: null
|
observableResult: null
|
||||||
})),
|
})),
|
||||||
textSamples: summary.textSamples,
|
textSamples: summary.textSamples,
|
||||||
|
layoutEvidence: summary.layoutEvidence ?? buildStaticLayoutEvidence({ html, regions, forms: buildForms(summary), tables: buildTables(html), actions: extractActionElements(html) }),
|
||||||
vuetifyComponents: inferPageComponents(html, regions),
|
vuetifyComponents: inferPageComponents(html, regions),
|
||||||
apiDependencies: [],
|
apiDependencies: [],
|
||||||
warnings: []
|
warnings: []
|
||||||
@@ -221,7 +215,8 @@ export function buildAppMapWithGuides(layoutSpecs, prototypeDir, prototypeGuides
|
|||||||
legacyJsp: guideEntry.legacyJsp,
|
legacyJsp: guideEntry.legacyJsp,
|
||||||
legacyPb: guideEntry.legacyPb,
|
legacyPb: guideEntry.legacyPb,
|
||||||
targetView: guideEntry.targetView,
|
targetView: guideEntry.targetView,
|
||||||
description: guideEntry.description
|
description: guideEntry.description,
|
||||||
|
checklist: guideEntry.checklist ?? []
|
||||||
} : null,
|
} : null,
|
||||||
evidence: {
|
evidence: {
|
||||||
uiContract: `.ht/spec/${relativeSource.replace(/\.html$/, '.ui-contract.md')}`,
|
uiContract: `.ht/spec/${relativeSource.replace(/\.html$/, '.ui-contract.md')}`,
|
||||||
@@ -277,9 +272,14 @@ export function parsePrototypeGuide(source, markdown) {
|
|||||||
legacyJsp: findGuideValue(values, ['舊 JSP']),
|
legacyJsp: findGuideValue(values, ['舊 JSP']),
|
||||||
legacyPb: findGuideValue(values, ['舊 PB', 'PB NVO', 'PB']),
|
legacyPb: findGuideValue(values, ['舊 PB', 'PB NVO', 'PB']),
|
||||||
targetView: findGuideValue(values, ['Vue view', '對應 Vue']),
|
targetView: findGuideValue(values, ['Vue view', '對應 Vue']),
|
||||||
description: findGuideValue(values, ['功能'])
|
description: findGuideValue(values, ['功能']),
|
||||||
|
checklist: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const checklistIndex = parseChecklistIndex(markdown)
|
||||||
|
for (const entry of entries) {
|
||||||
|
entry.checklist = checklistIndex.get(entry.prototype) ?? checklistIndex.get(entry.prototype.split('/').at(-1)) ?? []
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
title: title ? cleanMarkdownCell(title) : null,
|
title: title ? cleanMarkdownCell(title) : null,
|
||||||
@@ -287,6 +287,23 @@ export function parsePrototypeGuide(source, markdown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseChecklistIndex(markdown) {
|
||||||
|
const index = new Map()
|
||||||
|
const sections = [...markdown.matchAll(/^\s*#{2,4}\s+(.+)$/gm)]
|
||||||
|
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex += 1) {
|
||||||
|
const section = sections[sectionIndex]
|
||||||
|
const next = sections[sectionIndex + 1]
|
||||||
|
const title = cleanMarkdownCell(section[1])
|
||||||
|
const prototype = title?.match(/([A-Za-z0-9_./-]+\.html)\b/i)?.[1]
|
||||||
|
if (!prototype) continue
|
||||||
|
const body = markdown.slice(section.index + section[0].length, next?.index ?? markdown.length)
|
||||||
|
const checklist = [...body.matchAll(/^\s*-\s+\[[ xX-]\]\s+(.+)$/gm)].map((item) => cleanMarkdownCell(item[1])).filter(Boolean)
|
||||||
|
index.set(prototype, checklist)
|
||||||
|
index.set(prototype.split('/').at(-1), checklist)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
function buildPrototypeGuideIndex(prototypeGuides) {
|
function buildPrototypeGuideIndex(prototypeGuides) {
|
||||||
const index = new Map()
|
const index = new Map()
|
||||||
for (const guide of prototypeGuides) {
|
for (const guide of prototypeGuides) {
|
||||||
@@ -446,11 +463,57 @@ function mergeSummary(sourceSummary, domSummary = {}) {
|
|||||||
headings: unique([...(domSummary.headings ?? []), ...sourceSummary.headings]),
|
headings: unique([...(domSummary.headings ?? []), ...sourceSummary.headings]),
|
||||||
buttons: unique([...(domSummary.buttons ?? []), ...sourceSummary.buttons]),
|
buttons: unique([...(domSummary.buttons ?? []), ...sourceSummary.buttons]),
|
||||||
labels: unique([...(domSummary.labels ?? []), ...sourceSummary.labels]),
|
labels: unique([...(domSummary.labels ?? []), ...sourceSummary.labels]),
|
||||||
inputs: domSummary.inputs?.length ? domSummary.inputs : sourceSummary.inputs,
|
inputs: mergeInputs(sourceSummary.inputs, domSummary.inputs ?? []),
|
||||||
textSamples: unique([...(domSummary.textSamples ?? []), ...sourceSummary.textSamples]).slice(0, 3)
|
textSamples: unique([...(domSummary.textSamples ?? []), ...sourceSummary.textSamples]).slice(0, 3),
|
||||||
|
layoutEvidence: domSummary.layoutEvidence ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStaticLayoutEvidence({ html, regions, forms, tables, actions }) {
|
||||||
|
const sections = regions.map((region, index) => ({
|
||||||
|
id: region.id,
|
||||||
|
name: region.name,
|
||||||
|
role: region.semantic_role,
|
||||||
|
order: index + 1,
|
||||||
|
bbox: null
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
formRows: forms.flatMap((form) => groupFieldsIntoRows(form.fields)),
|
||||||
|
tables: tables.map((table) => ({ id: table.id, role: table.role, headers: table.headers, bbox: null })),
|
||||||
|
primaryActions: actions.filter((action) => action.kind === 'primary').map((action) => ({ label: action.label, actionType: action.actionType, scope: action.scope, bbox: null })),
|
||||||
|
firstViewport: {
|
||||||
|
visibleSectionCount: sections.length,
|
||||||
|
visibleFormFieldCount: forms.reduce((total, form) => total + form.fields.length, 0),
|
||||||
|
visibleTableCount: tables.length,
|
||||||
|
visibleTableHeaderCount: tables.reduce((total, table) => total + table.headers.length, 0),
|
||||||
|
approximateOccupiedContentArea: /<body\b/i.test(html) ? null : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupFieldsIntoRows(fields) {
|
||||||
|
const bySourceRow = new Map()
|
||||||
|
for (const field of fields) {
|
||||||
|
const key = field.sourceTable && field.sourceRow ? `${field.sourceTable}:${field.sourceRow}` : 'row-1'
|
||||||
|
const row = bySourceRow.get(key) ?? { sourceTable: field.sourceTable ?? null, sourceRow: field.sourceRow ?? null, fields: [] }
|
||||||
|
row.fields.push({ name: field.name, label: field.label, type: field.type, bbox: null })
|
||||||
|
bySourceRow.set(key, row)
|
||||||
|
}
|
||||||
|
return [...bySourceRow.values()].map((row, index) => ({ id: `form-row-${index + 1}`, ...row }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInputs(sourceInputs, domInputs) {
|
||||||
|
if (sourceInputs.length === 0) return domInputs
|
||||||
|
if (domInputs.length === 0) return sourceInputs
|
||||||
|
return sourceInputs.map((sourceInput, index) => ({
|
||||||
|
...domInputs[index],
|
||||||
|
...sourceInput,
|
||||||
|
name: sourceInput.name ?? domInputs[index]?.name ?? null,
|
||||||
|
label: sourceInput.label ?? domInputs[index]?.label ?? null
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function buildForms(summary) {
|
function buildForms(summary) {
|
||||||
const fields = summary.inputs.filter((field) => !['hidden', 'button', 'submit', 'reset', 'image'].includes(field.type ?? ''))
|
const fields = summary.inputs.filter((field) => !['hidden', 'button', 'submit', 'reset', 'image'].includes(field.type ?? ''))
|
||||||
if (fields.length === 0) return []
|
if (fields.length === 0) return []
|
||||||
@@ -458,9 +521,15 @@ function buildForms(summary) {
|
|||||||
labels: summary.labels,
|
labels: summary.labels,
|
||||||
fields: fields.map((field, index) => ({
|
fields: fields.map((field, index) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
label: summary.labels[index] ?? null,
|
label: field.label && field.label !== field.name ? field.label : summary.labels[index] ?? field.label ?? null,
|
||||||
type: field.type ?? field.tag,
|
type: field.type ?? field.tag,
|
||||||
required: field.required
|
required: field.required,
|
||||||
|
readonly: field.readonly ?? false,
|
||||||
|
maxLength: field.maxLength ?? null,
|
||||||
|
options: field.options ?? [],
|
||||||
|
defaultValue: field.defaultValue ?? null,
|
||||||
|
sourceTable: field.sourceTable ?? null,
|
||||||
|
sourceRow: field.sourceRow ?? null
|
||||||
})),
|
})),
|
||||||
primaryActions: summary.buttons.slice(0, 1),
|
primaryActions: summary.buttons.slice(0, 1),
|
||||||
secondaryActions: summary.buttons.slice(1)
|
secondaryActions: summary.buttons.slice(1)
|
||||||
@@ -468,13 +537,170 @@ function buildForms(summary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTables(html) {
|
function buildTables(html) {
|
||||||
return collect(html, /<table\b[^>]*>([\s\S]*?)<\/table>/gi).map((item, index) => ({
|
return collect(html, /<table\b[^>]*>([\s\S]*?)<\/table>/gi).map((item, index) => {
|
||||||
id: `table-${index + 1}`,
|
const headers = collect(item[1], /<th\b[^>]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean)
|
||||||
headers: collect(item[1], /<th\b[^>]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean),
|
const sampleRows = collect(item[1], /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi).slice(0, 3).map((row) =>
|
||||||
sampleRows: collect(item[1], /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi).slice(0, 3).map((row) =>
|
|
||||||
collect(row[1], /<td\b[^>]*>([\s\S]*?)<\/td>/gi).map((cell) => cleanText(cell[1])).filter(Boolean)
|
collect(row[1], /<td\b[^>]*>([\s\S]*?)<\/td>/gi).map((cell) => cleanText(cell[1])).filter(Boolean)
|
||||||
).filter((row) => row.length > 0)
|
).filter((row) => row.length > 0)
|
||||||
}))
|
return {
|
||||||
|
id: `table-${index + 1}`,
|
||||||
|
role: inferTableRole(item[1], headers, sampleRows),
|
||||||
|
headers,
|
||||||
|
sampleRows
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFields(html) {
|
||||||
|
const fields = []
|
||||||
|
const tableMatches = collect(html, /<table\b[^>]*>([\s\S]*?)<\/table>/gi)
|
||||||
|
for (const [tableIndex, table] of tableMatches.entries()) {
|
||||||
|
const rows = collect(table[1], /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)
|
||||||
|
for (const [rowIndex, row] of rows.entries()) {
|
||||||
|
const rowFields = extractRowFields(row[1])
|
||||||
|
for (const field of rowFields) fields.push({ ...field, sourceTable: `table-${tableIndex + 1}`, sourceRow: rowIndex + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const withoutTables = html.replace(/<table\b[\s\S]*?<\/table>/gi, ' ')
|
||||||
|
fields.push(...extractFieldsFromFragment(withoutTables, null))
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRowFields(rowHtml) {
|
||||||
|
const cells = collect(rowHtml, /<(th|td)\b[^>]*>([\s\S]*?)<\/\1>/gi)
|
||||||
|
if (cells.length === 0) return extractFieldsFromFragment(rowHtml, inferRowLabel(rowHtml))
|
||||||
|
const fields = []
|
||||||
|
let lastLabel = null
|
||||||
|
for (const cell of cells) {
|
||||||
|
const cellHtml = cell[2]
|
||||||
|
const ownLabel = normalizeCellLabel(cleanText(stripControls(cellHtml)).replace(/[::]$/, ''))
|
||||||
|
const cellFields = extractFieldsFromFragment(cellHtml, ownLabel || lastLabel)
|
||||||
|
fields.push(...cellFields)
|
||||||
|
if (ownLabel) lastLabel = ownLabel
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFieldsFromFragment(html, rowLabel) {
|
||||||
|
const fields = []
|
||||||
|
for (const item of collect(html, /<(input|select|textarea)\b([^>]*)>(?:([\s\S]*?)<\/\1>)?/gi)) {
|
||||||
|
const tag = item[1].toLowerCase()
|
||||||
|
const attrs = item[2]
|
||||||
|
const type = attr(attrs, 'type') ?? tag
|
||||||
|
const name = attr(attrs, 'name') ?? attr(attrs, 'id') ?? null
|
||||||
|
const label = labelForControl(html, attrs) ?? rowLabel ?? attr(attrs, 'placeholder') ?? name
|
||||||
|
fields.push({
|
||||||
|
tag,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
required: /\brequired\b/i.test(attrs),
|
||||||
|
readonly: /\breadonly\b/i.test(attrs),
|
||||||
|
maxLength: attr(attrs, 'maxlength'),
|
||||||
|
options: tag === 'select' ? collect(item[3] ?? '', /<option\b[^>]*>([\s\S]*?)<\/option>/gi).map((option) => cleanText(option[1])).filter(Boolean) : [],
|
||||||
|
defaultValue: attr(attrs, 'value') ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractActionElements(html) {
|
||||||
|
const actions = []
|
||||||
|
for (const item of collect(html, /<(button|a)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
|
||||||
|
const label = cleanText(item[3]) || attr(item[2], 'aria-label') || attr(item[2], 'title')
|
||||||
|
if (label) actions.push(actionContract(label, item[1], item[2]))
|
||||||
|
}
|
||||||
|
for (const item of collect(html, /<input\b([^>]*)>/gi)) {
|
||||||
|
const type = attr(item[1], 'type')
|
||||||
|
if (!['button', 'submit', 'reset', 'image'].includes(type ?? '')) continue
|
||||||
|
const label = attr(item[1], 'value') || attr(item[1], 'aria-label') || attr(item[1], 'title')
|
||||||
|
if (label) actions.push(actionContract(label, 'input', item[1]))
|
||||||
|
}
|
||||||
|
return dedupeActions(actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionContract(label, tag, attrs) {
|
||||||
|
const actionType = inferActionType(label)
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
actionType,
|
||||||
|
kind: actionType === 'search' || actionType === 'save' || actionType === 'create' ? 'primary' : 'secondary',
|
||||||
|
scope: inferActionScope(label, attrs),
|
||||||
|
tag,
|
||||||
|
disabled: /\bdisabled\b/i.test(attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferActionType(label) {
|
||||||
|
const text = normalizeText(label)
|
||||||
|
if (/(查詢|搜尋|search)/i.test(text)) return 'search'
|
||||||
|
if (/(清除|重填|reset)/i.test(text)) return 'reset'
|
||||||
|
if (/(返回|離開|back)/i.test(text)) return 'back'
|
||||||
|
if (/(新增|建立|create|add)/i.test(text)) return 'create'
|
||||||
|
if (/(檢視|查看|view)/i.test(text)) return 'view'
|
||||||
|
if (/(修改|編輯|edit)/i.test(text)) return 'edit'
|
||||||
|
if (/(刪除|delete)/i.test(text)) return 'delete'
|
||||||
|
if (/(存檔|儲存|送出|確定|save|submit)/i.test(text)) return 'save'
|
||||||
|
if (/(列印|print)/i.test(text)) return 'print'
|
||||||
|
if (/(選取|選擇|select|choose)/i.test(text)) return 'select'
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferActionScope(label, attrs) {
|
||||||
|
if (/(修改|刪除|列印|檢視|edit|delete|print|view)/i.test(label)) return 'rowAction'
|
||||||
|
if (/onclick\s*=/i.test(attrs) || /(存檔|儲存|送出|查詢|清除|reset|save|submit|search)/i.test(label)) return 'formAction'
|
||||||
|
return 'pageAction'
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferTableRole(html, headers, sampleRows) {
|
||||||
|
const text = `${headers.join(' ')} ${sampleRows.flat().join(' ')} ${visibleText(html).join(' ')}`
|
||||||
|
if (/<input|<select|<textarea/i.test(html) && headers.length === 0) return 'searchTable'
|
||||||
|
if (/(申請單號|操作|狀態|修改|刪除|列印)/.test(text)) return 'resultTable'
|
||||||
|
if (/<input|<select|<textarea/i.test(html) && headers.length <= 3) return 'searchTable'
|
||||||
|
if (/(設備明細|場地志願|使用節次|明細)/.test(text)) return 'detailTable'
|
||||||
|
if (/(學校抬頭|列印|申請表)/.test(text)) return 'printTable'
|
||||||
|
if (headers.length > 0) return 'resultTable'
|
||||||
|
return 'layoutTable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRowLabel(rowHtml) {
|
||||||
|
const th = matchText(rowHtml, /<th\b[^>]*>([\s\S]*?)<\/th>/i)
|
||||||
|
if (th) return th
|
||||||
|
const cells = collect(rowHtml, /<td\b[^>]*>([\s\S]*?)<\/td>/gi)
|
||||||
|
for (const cell of cells) {
|
||||||
|
const text = cleanText(stripControls(cell[1]))
|
||||||
|
if (text && /[::]$/.test(text)) return text.replace(/[::]$/, '')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripControls(html) {
|
||||||
|
return html
|
||||||
|
.replace(/<select\b[\s\S]*?<\/select>/gi, ' ')
|
||||||
|
.replace(/<textarea\b[\s\S]*?<\/textarea>/gi, ' ')
|
||||||
|
.replace(/<input\b[^>]*>/gi, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCellLabel(label) {
|
||||||
|
if (!label || /^[~~\-–—::|/\\]+$/.test(label)) return null
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForControl(fragment, attrs) {
|
||||||
|
const id = attr(attrs, 'id')
|
||||||
|
if (!id) return null
|
||||||
|
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
return matchText(fragment, new RegExp(`<label\\b[^>]*for=["']${escaped}["'][^>]*>([\\s\\S]*?)<\\/label>`, 'i'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeActions(actions) {
|
||||||
|
const seen = new Set()
|
||||||
|
return actions.filter((action) => {
|
||||||
|
const key = `${action.label}:${action.scope}:${action.actionType}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPageComponents(html, regions) {
|
function inferPageComponents(html, regions) {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
export function buildMaintenanceContract({ route, spec, apiMatches = [] }) {
|
||||||
|
const pageKind = inferPageKind(route, spec, apiMatches)
|
||||||
|
const capabilities = inferCapabilities(spec, apiMatches)
|
||||||
|
const dataModel = inferDataModel(route, spec, apiMatches)
|
||||||
|
const rowActions = inferRowActions(spec)
|
||||||
|
const template = recommendTemplate(pageKind, spec, capabilities, dataModel)
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageKind,
|
||||||
|
capabilities,
|
||||||
|
recommendedTemplate: template.name,
|
||||||
|
confidence: template.confidence,
|
||||||
|
reasons: template.reasons,
|
||||||
|
warnings: buildWarnings(pageKind, template.name, spec, dataModel, apiMatches, rowActions),
|
||||||
|
dataModel,
|
||||||
|
rowActions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPageKind(route, spec, apiMatches) {
|
||||||
|
const path = route.prototype ?? spec.page
|
||||||
|
const text = contractText(spec)
|
||||||
|
if (['auth', 'auth-support'].includes(route.kind)) return route.targetRole ?? 'auth'
|
||||||
|
if (route.kind === 'reference') return 'reference'
|
||||||
|
if (route.kind === 'legacy-shell-reference') return 'layout-reference'
|
||||||
|
if (route.kind === 'prototype-navigation' || /choose|index/i.test(path) && /(挑選|選擇|導覽|返回雛型導覽)/.test(text)) return 'chooser'
|
||||||
|
if (/print|prt/i.test(path) || /(列印|申請表)/.test(text) && !hasAction(spec, ['edit', 'delete', 'save'])) return 'print'
|
||||||
|
if (hasAction(spec, ['edit', 'delete'])) return 'maintenance'
|
||||||
|
if (hasAction(spec, ['save', 'create'])) return 'application'
|
||||||
|
if (hasAction(spec, ['search']) || spec.pageContract.tables.some((table) => table.role === 'resultTable')) return 'query'
|
||||||
|
if (apiMatches.some((api) => ['PUT', 'DELETE'].includes(api.method))) return 'maintenance'
|
||||||
|
if (apiMatches.some((api) => api.method === 'POST')) return 'application'
|
||||||
|
return route.kind === 'feature-page' ? 'query' : 'reference'
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCapabilities(spec, apiMatches) {
|
||||||
|
const capabilities = new Set()
|
||||||
|
for (const action of spec.pageContract.actions) {
|
||||||
|
if (action.actionType && action.actionType !== 'custom') capabilities.add(action.actionType)
|
||||||
|
}
|
||||||
|
for (const api of apiMatches) {
|
||||||
|
if (api.method === 'GET' && /print-view|print/i.test(api.path)) capabilities.add('print')
|
||||||
|
else if (api.method === 'GET') capabilities.add('search')
|
||||||
|
else if (api.method === 'POST' && /availability/i.test(api.path)) capabilities.add('availabilityCheck')
|
||||||
|
else if (api.method === 'POST') capabilities.add('create')
|
||||||
|
else if (api.method === 'PUT' || api.method === 'PATCH') capabilities.add('edit')
|
||||||
|
else if (api.method === 'DELETE') capabilities.add('delete')
|
||||||
|
}
|
||||||
|
return [...capabilities]
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDataModel(route, spec, apiMatches) {
|
||||||
|
const primaryEntity = inferPrimaryEntity(route, apiMatches)
|
||||||
|
const detailEntities = []
|
||||||
|
const fieldNames = spec.pageContract.forms.flatMap((form) => form.fields.map((field) => field.name ?? field.label)).filter(Boolean)
|
||||||
|
const collectionFields = fieldNames.filter((name) => /s$|list|items|details|明細|志願|節次|設備|場地/i.test(name))
|
||||||
|
for (const field of collectionFields) {
|
||||||
|
detailEntities.push({ name: entityName(field), source: 'field' })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
primaryEntity,
|
||||||
|
detailEntities: dedupeByName(detailEntities),
|
||||||
|
fields: fieldNames,
|
||||||
|
searchFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => field.sourceTable && spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role === 'searchTable'),
|
||||||
|
formFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => !field.sourceTable || spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role !== 'searchTable'),
|
||||||
|
tableColumns: spec.pageContract.tables.flatMap((table) => table.headers.map((header) => ({ tableId: table.id, role: table.role, label: header }))),
|
||||||
|
detailCollections: dedupeByName(detailEntities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPrimaryEntity(route, apiMatches) {
|
||||||
|
const targetView = route.guide?.targetView?.replace(/View\.vue$/i, '').replace(/\.vue$/i, '')
|
||||||
|
if (targetView) return targetView
|
||||||
|
const strongest = apiMatches[0]
|
||||||
|
if (strongest) {
|
||||||
|
const parts = strongest.path.split('/').filter((part) => part && !part.startsWith('{') && !part.startsWith('_'))
|
||||||
|
return entityName(parts.at(-1) ?? parts.at(-2) ?? 'Record')
|
||||||
|
}
|
||||||
|
return entityName((route.page ?? route.prototype ?? 'Record').replace(/\.html$/i, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendTemplate(pageKind, spec, capabilities, dataModel) {
|
||||||
|
if (!['maintenance', 'application'].includes(pageKind)) {
|
||||||
|
return { name: 'none', confidence: 0.9, reasons: [`pageKind=${pageKind} 不套維護頁 CRUD 範本`] }
|
||||||
|
}
|
||||||
|
if (hasEditableResultTable(spec)) {
|
||||||
|
return { name: 'editable-grid', confidence: 0.75, reasons: ['結果表格內含可編輯欄位'] }
|
||||||
|
}
|
||||||
|
if (dataModel.detailEntities.length > 1 || hasGroupedDetails(spec)) {
|
||||||
|
return { name: 'master-detail-b', confidence: 0.65, reasons: ['偵測到多組明細集合或群組結構'] }
|
||||||
|
}
|
||||||
|
if (dataModel.detailEntities.length === 1 || hasDetailEvidence(spec)) {
|
||||||
|
return { name: 'master-detail-c', confidence: 0.7, reasons: ['主檔表單搭配簡單明細集合'] }
|
||||||
|
}
|
||||||
|
if (capabilities.includes('edit') || capabilities.includes('delete') || capabilities.includes('create')) {
|
||||||
|
return { name: 'single-record', confidence: 0.7, reasons: ['具備查詢列表與單筆 CRUD 操作'] }
|
||||||
|
}
|
||||||
|
return { name: 'none', confidence: 0.5, reasons: ['沒有足夠維護頁操作證據'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWarnings(pageKind, recommendedTemplate, spec, dataModel, apiMatches, rowActions = []) {
|
||||||
|
const warnings = []
|
||||||
|
if (pageKind === 'maintenance' && !hasAction(spec, ['search']) && spec.pageContract.tables.length === 0) {
|
||||||
|
warnings.push('maintenance 頁缺少查詢 action 或表格 evidence')
|
||||||
|
}
|
||||||
|
if (recommendedTemplate !== 'none' && !dataModel.primaryEntity) {
|
||||||
|
warnings.push('建議維護頁範本但缺少 primaryEntity')
|
||||||
|
}
|
||||||
|
if (hasAction(spec, ['edit', 'delete']) && apiMatches.length === 0) {
|
||||||
|
warnings.push('有 edit/delete action 但沒有匹配 API endpoint')
|
||||||
|
}
|
||||||
|
for (const action of rowActions) {
|
||||||
|
if (action.enabledWhen === null && action.disabled) warnings.push(`row action ${action.label} 有狀態限制但未產出 enabledWhen`)
|
||||||
|
}
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRowActions(spec) {
|
||||||
|
const guideText = (spec.prototypeGuide?.checklist ?? []).join(' ')
|
||||||
|
return spec.pageContract.actions
|
||||||
|
.filter((action) => action.scope === 'rowAction')
|
||||||
|
.map((action) => ({
|
||||||
|
label: action.label,
|
||||||
|
actionType: action.actionType,
|
||||||
|
disabled: action.disabled,
|
||||||
|
enabledWhen: inferEnabledWhen(`${action.label} ${guideText}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferEnabledWhen(text) {
|
||||||
|
const aprv = text.match(/aprvYn\s*(?:===|==|=|為|是)\s*['"]?([A-Z0-9])['"]?/i)
|
||||||
|
if (aprv) return `aprvYn === '${aprv[1]}'`
|
||||||
|
const quoted = text.match(/`([^`]+)`\s*才(?:能|可)/)
|
||||||
|
if (quoted) return quoted[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEditableResultTable(spec) {
|
||||||
|
return spec.pageContract.tables.some((table) => table.role === 'resultTable' && table.sampleRows.flat().some((cell) => /<input|<select|<textarea/i.test(cell)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGroupedDetails(spec) {
|
||||||
|
const text = contractText(spec)
|
||||||
|
return /(群組|accordion|collapse|多組|階層)/i.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDetailEvidence(spec) {
|
||||||
|
return spec.pageContract.tables.some((table) => table.role === 'detailTable') ||
|
||||||
|
spec.pageContract.forms.some((form) => form.fields.some((field) => /明細|志願|節次|設備|場地|items|details/i.test(`${field.name ?? ''} ${field.label ?? ''}`)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAction(spec, actionTypes) {
|
||||||
|
return spec.pageContract.actions.some((action) => actionTypes.includes(action.actionType))
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractText(spec) {
|
||||||
|
return [
|
||||||
|
spec.pageContract.title,
|
||||||
|
...spec.pageContract.textSamples,
|
||||||
|
...spec.pageContract.actions.map((action) => action.label),
|
||||||
|
...spec.pageContract.tables.flatMap((table) => [...table.headers, ...table.sampleRows.flat()])
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityName(value) {
|
||||||
|
const parts = String(value)
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (parts.length === 0) return 'Record'
|
||||||
|
return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeByName(items) {
|
||||||
|
const seen = new Set()
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (seen.has(item.name)) return false
|
||||||
|
seen.add(item.name)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
+191
-6
@@ -3,7 +3,9 @@ import { readFile, 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'
|
||||||
|
import { buildApiCatalog, matchApiEndpointCandidates } from '../lib/api-docs.js'
|
||||||
import { buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../lib/html.js'
|
import { buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../lib/html.js'
|
||||||
|
import { buildMaintenanceContract } from '../lib/maintenance.js'
|
||||||
import { resolvePlaywrightCli } from '../lib/playwright-cli.js'
|
import { resolvePlaywrightCli } from '../lib/playwright-cli.js'
|
||||||
|
|
||||||
const viewports = [
|
const viewports = [
|
||||||
@@ -20,6 +22,7 @@ export async function scan() {
|
|||||||
const htmlFiles = await listFiles(config.prototypeDir, ['.html'])
|
const htmlFiles = await listFiles(config.prototypeDir, ['.html'])
|
||||||
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)
|
||||||
await ensureDir(config.htDir)
|
await ensureDir(config.htDir)
|
||||||
const plans = []
|
const plans = []
|
||||||
for (const file of htmlFiles) {
|
for (const file of htmlFiles) {
|
||||||
@@ -35,7 +38,16 @@ export async function scan() {
|
|||||||
} finally {
|
} finally {
|
||||||
await server?.close()
|
await server?.close()
|
||||||
}
|
}
|
||||||
await writeJson(join(config.htDir, 'app-map.json'), buildAppMapWithGuides(layoutSpecs, config.prototypeDir, prototypeGuides))
|
const appMap = buildAppMapWithGuides(layoutSpecs, config.prototypeDir, prototypeGuides, apiCatalog)
|
||||||
|
enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, config.prototypeDir)
|
||||||
|
for (const spec of layoutSpecs) {
|
||||||
|
const name = artifactPath(config.prototypeDir, spec.source)
|
||||||
|
await writeJson(join(config.htDir, 'spec', `${name}.spec.json`), spec)
|
||||||
|
await writeJson(join(config.htDir, 'spec', `${name}.validation.json`), spec.validation)
|
||||||
|
await writeFile(join(config.htDir, 'spec', `${name}.ui-contract.md`), renderUiContract(spec))
|
||||||
|
}
|
||||||
|
await writeJson(join(config.htDir, 'api-catalog.json'), apiCatalog)
|
||||||
|
await writeJson(join(config.htDir, 'app-map.json'), appMap)
|
||||||
console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`)
|
console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +61,62 @@ async function readPrototypeGuides(config) {
|
|||||||
return guides.filter((guide) => guide.entries.length > 0)
|
return guides.filter((guide) => guide.entries.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readApiCatalog(config) {
|
||||||
|
const documents = []
|
||||||
|
for (const docsDir of config.backendDocsDirs ?? [config.backendDocsDir]) {
|
||||||
|
const files = await listFiles(docsDir, ['.md', '.json', '.yaml', '.yml'])
|
||||||
|
for (const file of files) {
|
||||||
|
documents.push({
|
||||||
|
source: relative(config.cwd, file).replaceAll('\\', '/'),
|
||||||
|
content: await readFile(file, 'utf8')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildApiCatalog(documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, prototypeDir) {
|
||||||
|
const routeByPrototype = new Map(appMap.routes.map((route) => [route.prototype, route]))
|
||||||
|
for (const spec of layoutSpecs) {
|
||||||
|
const relativeSource = spec.source.startsWith(prototypeDir)
|
||||||
|
? spec.source.slice(prototypeDir.length).replace(/^\/+/, '')
|
||||||
|
: null
|
||||||
|
const route = routeByPrototype.get(relativeSource) ?? appMap.routes.find((item) => item.page === spec.page)
|
||||||
|
if (!route) continue
|
||||||
|
const apiCandidates = shouldMatchApi(route) ? matchApiEndpointCandidates(route, apiCatalog) : { matches: [], rejected: [] }
|
||||||
|
const apiMatches = apiCandidates.matches
|
||||||
|
spec.apiContract = {
|
||||||
|
endpoints: apiMatches,
|
||||||
|
rejectedCandidates: apiCandidates.rejected,
|
||||||
|
errorHandling: apiCatalog.errorContract
|
||||||
|
}
|
||||||
|
spec.apiCatalog = { fieldRules: apiCatalog.fieldRules ?? [] }
|
||||||
|
spec.prototypeGuide = route.guide ? {
|
||||||
|
source: route.guide.source,
|
||||||
|
legacyJsp: route.guide.legacyJsp,
|
||||||
|
legacyPb: route.guide.legacyPb,
|
||||||
|
targetView: route.guide.targetView,
|
||||||
|
description: route.guide.description,
|
||||||
|
checklist: route.guide.checklist ?? []
|
||||||
|
} : null
|
||||||
|
spec.maintenanceContract = buildMaintenanceContract({ route, spec, apiMatches })
|
||||||
|
route.apiCount = apiMatches.length
|
||||||
|
route.primaryEntity = spec.maintenanceContract.dataModel.primaryEntity
|
||||||
|
route.capabilities = spec.maintenanceContract.capabilities
|
||||||
|
route.pageKind = spec.maintenanceContract.pageKind
|
||||||
|
route.recommendedTemplate = spec.maintenanceContract.recommendedTemplate
|
||||||
|
route.evidence.apiCount = apiMatches.length
|
||||||
|
route.evidence.primaryEntity = route.primaryEntity
|
||||||
|
route.evidence.capabilities = route.capabilities
|
||||||
|
route.evidence.recommendedTemplate = route.recommendedTemplate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldMatchApi(route) {
|
||||||
|
if (route.layout === 'ignore' || route.usePrototypeContent === false) return false
|
||||||
|
return ['auth', 'auth-support', 'feature-page'].includes(route.kind)
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareScanFile(config, file) {
|
async function prepareScanFile(config, file) {
|
||||||
const hash = await sha256File(file)
|
const hash = await sha256File(file)
|
||||||
const name = artifactPath(config.prototypeDir, file)
|
const name = artifactPath(config.prototypeDir, file)
|
||||||
@@ -84,7 +152,7 @@ async function scanFile(config, plan, serverUrl) {
|
|||||||
domSummary,
|
domSummary,
|
||||||
screenshotPath: screenshot?.path ?? null
|
screenshotPath: screenshot?.path ?? null
|
||||||
})
|
})
|
||||||
const validationReport = validateSpecs(regionSpecs, pageContract, domSummary)
|
const validationReport = validateSpecs(regionSpecs, pageContract, mergeEvidence(summarizeHtml(html), domSummary))
|
||||||
const layoutSpec = {
|
const layoutSpec = {
|
||||||
source: file,
|
source: file,
|
||||||
hash,
|
hash,
|
||||||
@@ -201,18 +269,71 @@ function captureScript(url, viewport, state, screenshotPath) {
|
|||||||
.map(element => text(element) || element.value || attr(element, 'aria-label') || attr(element, 'title') || '')
|
.map(element => text(element) || element.value || attr(element, 'aria-label') || attr(element, 'title') || '')
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const labels = Array.from(document.querySelectorAll('label')).map(text).filter(Boolean);
|
const labels = Array.from(document.querySelectorAll('label')).map(text).filter(Boolean);
|
||||||
|
const bbox = element => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||||
|
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) };
|
||||||
|
};
|
||||||
|
const visible = element => {
|
||||||
|
const box = bbox(element);
|
||||||
|
return box && box.y < window.innerHeight && box.y + box.height > 0;
|
||||||
|
};
|
||||||
const inputs = Array.from(document.querySelectorAll('input, select, textarea')).map(element => ({
|
const inputs = Array.from(document.querySelectorAll('input, select, textarea')).map(element => ({
|
||||||
tag: element.tagName.toLowerCase(),
|
tag: element.tagName.toLowerCase(),
|
||||||
type: attr(element, 'type'),
|
type: attr(element, 'type'),
|
||||||
name: attr(element, 'name') || attr(element, 'id'),
|
name: attr(element, 'name') || attr(element, 'id'),
|
||||||
required: element.required
|
required: element.required,
|
||||||
|
bbox: bbox(element)
|
||||||
}));
|
}));
|
||||||
|
const boxes = selector => Array.from(document.querySelectorAll(selector)).map((element, index) => ({
|
||||||
|
id: selector.replace(/[^a-z0-9]+/gi, '-') + '-' + (index + 1),
|
||||||
|
label: text(element) || element.value || attr(element, 'aria-label') || attr(element, 'title') || '',
|
||||||
|
tag: element.tagName.toLowerCase(),
|
||||||
|
bbox: bbox(element),
|
||||||
|
firstViewport: Boolean(visible(element))
|
||||||
|
})).filter(item => item.bbox);
|
||||||
|
const tableEvidence = Array.from(document.querySelectorAll('table')).map((element, index) => ({
|
||||||
|
id: 'table-' + (index + 1),
|
||||||
|
headers: Array.from(element.querySelectorAll('th')).map(text).filter(Boolean),
|
||||||
|
bbox: bbox(element),
|
||||||
|
firstViewport: Boolean(visible(element))
|
||||||
|
})).filter(item => item.bbox);
|
||||||
|
const sectionEvidence = Array.from(document.querySelectorAll('main, section, form, fieldset, table')).map((element, index) => ({
|
||||||
|
id: 'section-' + (index + 1),
|
||||||
|
name: text(element).split(' ').slice(0, 8).join(' '),
|
||||||
|
tag: element.tagName.toLowerCase(),
|
||||||
|
bbox: bbox(element),
|
||||||
|
firstViewport: Boolean(visible(element))
|
||||||
|
})).filter(item => item.bbox);
|
||||||
|
const viewportElements = Array.from(document.body.querySelectorAll('main, section, form, fieldset, table, input, select, textarea, button, a, h1,h2,h3,h4,h5,h6')).filter(visible);
|
||||||
|
const occupiedArea = viewportElements.reduce((total, element) => {
|
||||||
|
const box = bbox(element);
|
||||||
|
return total + (box ? Math.min(box.width, window.innerWidth) * Math.min(box.height, window.innerHeight) : 0);
|
||||||
|
}, 0);
|
||||||
|
const layoutEvidence = {
|
||||||
|
sections: sectionEvidence,
|
||||||
|
formRows: Array.from(document.querySelectorAll('tr, .row, form > div, fieldset')).map((element, index) => ({
|
||||||
|
id: 'form-row-' + (index + 1),
|
||||||
|
fields: Array.from(element.querySelectorAll('input, select, textarea')).map(field => ({ name: attr(field, 'name') || attr(field, 'id'), label: attr(field, 'aria-label') || attr(field, 'placeholder') || '', type: attr(field, 'type') || field.tagName.toLowerCase(), bbox: bbox(field) })).filter(field => field.bbox),
|
||||||
|
bbox: bbox(element)
|
||||||
|
})).filter(row => row.fields.length > 0),
|
||||||
|
tables: tableEvidence,
|
||||||
|
primaryActions: boxes('button, a, input[type="button"], input[type="submit"], input[type="reset"]'),
|
||||||
|
firstViewport: {
|
||||||
|
visibleSectionCount: sectionEvidence.filter(item => item.firstViewport).length,
|
||||||
|
visibleFormFieldCount: inputs.filter(item => item.bbox && item.bbox.y < window.innerHeight).length,
|
||||||
|
visibleTableCount: tableEvidence.filter(item => item.firstViewport).length,
|
||||||
|
visibleTableHeaderCount: tableEvidence.filter(item => item.firstViewport).reduce((total, item) => total + item.headers.length, 0),
|
||||||
|
approximateOccupiedContentArea: Math.round(occupiedArea / (window.innerWidth * window.innerHeight) * 1000) / 1000
|
||||||
|
}
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
title: document.title || null,
|
title: document.title || null,
|
||||||
headings,
|
headings,
|
||||||
buttons,
|
buttons,
|
||||||
labels,
|
labels,
|
||||||
inputs,
|
inputs,
|
||||||
|
layoutEvidence,
|
||||||
textSamples: [...headings, ...labels, ...buttons, ...visibleText].filter(Boolean).slice(0, 3)
|
textSamples: [...headings, ...labels, ...buttons, ...visibleText].filter(Boolean).slice(0, 3)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -296,6 +417,17 @@ function dedupeEvents(events) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeEvidence(sourceSummary, domSummary = {}) {
|
||||||
|
return {
|
||||||
|
title: domSummary.title ?? sourceSummary.title,
|
||||||
|
headings: [...new Set([...(domSummary.headings ?? []), ...sourceSummary.headings])],
|
||||||
|
buttons: [...new Set([...(domSummary.buttons ?? []), ...sourceSummary.buttons])],
|
||||||
|
labels: [...new Set([...(domSummary.labels ?? []), ...sourceSummary.labels, ...sourceSummary.inputs.map((field) => field.label).filter(Boolean)])],
|
||||||
|
inputs: sourceSummary.inputs.length ? sourceSummary.inputs : domSummary.inputs ?? [],
|
||||||
|
textSamples: [...new Set([...(domSummary.textSamples ?? []), ...sourceSummary.textSamples])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function isCaptureCacheHit(metadataPath, hash) {
|
async function isCaptureCacheHit(metadataPath, hash) {
|
||||||
if (!(await exists(metadataPath))) return false
|
if (!(await exists(metadataPath))) return false
|
||||||
try {
|
try {
|
||||||
@@ -345,32 +477,85 @@ function renderUiContract(spec) {
|
|||||||
lines.push(`- Title: ${contract.title ?? 'none'}`)
|
lines.push(`- Title: ${contract.title ?? 'none'}`)
|
||||||
lines.push(`- Text: ${contract.textSamples.join(', ') || 'none'}`)
|
lines.push(`- Text: ${contract.textSamples.join(', ') || 'none'}`)
|
||||||
lines.push(`- Vuetify: ${contract.vuetifyComponents.join(', ') || 'none'}`)
|
lines.push(`- Vuetify: ${contract.vuetifyComponents.join(', ') || 'none'}`)
|
||||||
|
if (spec.maintenanceContract) {
|
||||||
|
lines.push(`- Page kind: ${spec.maintenanceContract.pageKind}`)
|
||||||
|
lines.push(`- Recommended template: ${spec.maintenanceContract.recommendedTemplate} (${spec.maintenanceContract.confidence})`)
|
||||||
|
if (spec.maintenanceContract.recommendedTemplate === 'none') lines.push(`- Template reason: ${spec.maintenanceContract.reasons.join('; ')}`)
|
||||||
|
lines.push(`- Capabilities: ${spec.maintenanceContract.capabilities.join(', ') || 'none'}`)
|
||||||
|
lines.push(`- Primary entity: ${spec.maintenanceContract.dataModel.primaryEntity ?? 'none'}`)
|
||||||
|
}
|
||||||
|
if (spec.prototypeGuide) {
|
||||||
|
lines.push(`- Target view: ${spec.prototypeGuide.targetView ?? 'none'}`)
|
||||||
|
lines.push(`- Legacy JSP: ${spec.prototypeGuide.legacyJsp ?? 'none'}`)
|
||||||
|
lines.push(`- Legacy PB: ${spec.prototypeGuide.legacyPb ?? 'none'}`)
|
||||||
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('## Sections', '')
|
lines.push('## Sections', '')
|
||||||
for (const section of contract.sections) {
|
for (const section of contract.sections) {
|
||||||
lines.push(`- ${section.id}: ${section.name} (${section.role})`)
|
lines.push(`- ${section.id}: ${section.name} (${section.role})`)
|
||||||
}
|
}
|
||||||
|
if (spec.prototypeGuide?.checklist?.length) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('## Prototype Checklist', '')
|
||||||
|
for (const item of spec.prototypeGuide.checklist) lines.push(`- ${item}`)
|
||||||
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('## Forms', '')
|
lines.push('## Forms', '')
|
||||||
if (contract.forms.length === 0) lines.push('- none')
|
if (contract.forms.length === 0) lines.push('- none')
|
||||||
for (const form of contract.forms) {
|
for (const form of contract.forms) {
|
||||||
lines.push(`- Labels: ${form.labels.join(', ') || 'none'}`)
|
lines.push(`- Labels: ${form.labels.join(', ') || 'none'}`)
|
||||||
lines.push(`- Fields: ${form.fields.map((field) => `${field.label ?? field.name ?? 'field'}${field.required ? ' required' : ''}`).join(', ') || 'none'}`)
|
lines.push(`- Fields: ${form.fields.map((field) => `${field.label ?? field.name ?? 'field'}${field.required ? ' required' : ''}${field.readonly ? ' readonly' : ''}`).join(', ') || 'none'}`)
|
||||||
lines.push(`- Actions: ${[...form.primaryActions, ...form.secondaryActions].join(', ') || 'none'}`)
|
lines.push(`- Actions: ${[...form.primaryActions, ...form.secondaryActions].join(', ') || 'none'}`)
|
||||||
}
|
}
|
||||||
|
if (spec.maintenanceContract?.dataModel?.detailEntities?.length) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('## Detail Collections', '')
|
||||||
|
for (const detail of spec.maintenanceContract.dataModel.detailEntities) lines.push(`- ${detail.name}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
lines.push('## Row Actions', '')
|
||||||
|
if (!spec.maintenanceContract?.rowActions?.length) lines.push('- none')
|
||||||
|
for (const action of spec.maintenanceContract?.rowActions ?? []) {
|
||||||
|
lines.push(`- ${action.actionType}: ${action.label}${action.enabledWhen ? ` enabledWhen ${action.enabledWhen}` : ''}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
lines.push('## Layout Evidence', '')
|
||||||
|
const layout = contract.layoutEvidence
|
||||||
|
if (!layout) lines.push('- none')
|
||||||
|
else {
|
||||||
|
lines.push(`- First viewport: sections=${layout.firstViewport?.visibleSectionCount ?? 0}, fields=${layout.firstViewport?.visibleFormFieldCount ?? 0}, tables=${layout.firstViewport?.visibleTableCount ?? 0}, occupied=${layout.firstViewport?.approximateOccupiedContentArea ?? 'unknown'}`)
|
||||||
|
lines.push(`- Form rows: ${layout.formRows?.length ?? 0}`)
|
||||||
|
lines.push(`- Primary actions: ${(layout.primaryActions ?? []).map((action) => action.label).filter(Boolean).join(', ') || 'none'}`)
|
||||||
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('## Tables', '')
|
lines.push('## Tables', '')
|
||||||
if (contract.tables.length === 0) lines.push('- none')
|
if (contract.tables.length === 0) lines.push('- none')
|
||||||
for (const table of contract.tables) {
|
for (const table of contract.tables) {
|
||||||
lines.push(`- ${table.id}: ${table.headers.join(', ') || 'no headers'}`)
|
lines.push(`- ${table.id} (${table.role}): ${table.headers.join(', ') || 'no headers'}`)
|
||||||
}
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('## Actions', '')
|
lines.push('## Actions', '')
|
||||||
if (contract.actions.length === 0) lines.push('- none')
|
if (contract.actions.length === 0) lines.push('- none')
|
||||||
for (const action of contract.actions) {
|
for (const action of contract.actions) {
|
||||||
lines.push(`- ${action.kind}: ${action.label}`)
|
lines.push(`- ${action.kind}/${action.scope}/${action.actionType}: ${action.label}`)
|
||||||
}
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
lines.push('## API Endpoints', '')
|
||||||
|
if (!spec.apiContract?.endpoints?.length) lines.push('- none')
|
||||||
|
for (const endpoint of spec.apiContract?.endpoints ?? []) {
|
||||||
|
lines.push(`- ${endpoint.usage ?? 'unknown'}: ${endpoint.method} ${endpoint.path}: ${endpoint.description}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
lines.push('## Field Rules', '')
|
||||||
|
const endpointIds = new Set((spec.apiContract?.endpoints ?? []).map((endpoint) => endpoint.id))
|
||||||
|
const rules = (spec.apiCatalog?.fieldRules ?? []).filter((rule) => endpointIds.has(rule.endpointId))
|
||||||
|
if (rules.length === 0) lines.push('- none')
|
||||||
|
for (const rule of rules) lines.push(`- ${rule.field}: ${rule.rule}`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push('## Rejected API Candidates', '')
|
||||||
|
if (!spec.apiContract?.rejectedCandidates?.length) lines.push('- none')
|
||||||
|
for (const candidate of spec.apiContract?.rejectedCandidates ?? []) lines.push(`- ${candidate.method} ${candidate.path}: ${candidate.reason}`)
|
||||||
|
lines.push('')
|
||||||
lines.push('## Warnings', '')
|
lines.push('## Warnings', '')
|
||||||
const warnings = spec.validation.warnings
|
const warnings = spec.validation.warnings
|
||||||
if (warnings.length === 0) lines.push('- none')
|
if (warnings.length === 0) lines.push('- none')
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ test('CLI runs doctor and scan against one prototype', async () => {
|
|||||||
assert.match(doctor.stdout, /ok prototype directory/)
|
assert.match(doctor.stdout, /ok prototype directory/)
|
||||||
assert.match(contract, /Customer Portal/)
|
assert.match(contract, /Customer Portal/)
|
||||||
assert.equal(spec.pageContract.title, null)
|
assert.equal(spec.pageContract.title, null)
|
||||||
assert.deepEqual(spec.pageContract.forms[0].fields[0], {
|
assert.deepEqual(pick(spec.pageContract.forms[0].fields[0], ['name', 'label', 'type', 'required']), {
|
||||||
name: 'email',
|
name: 'email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
type: 'input',
|
type: 'input',
|
||||||
@@ -57,6 +57,10 @@ test('CLI runs doctor and scan against one prototype', async () => {
|
|||||||
assert.equal(appMap.guideSources[0].source, 'portal.md')
|
assert.equal(appMap.guideSources[0].source, 'portal.md')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function pick(object, keys) {
|
||||||
|
return Object.fromEntries(keys.map((key) => [key, object[key]]))
|
||||||
|
}
|
||||||
|
|
||||||
test('CLI help only exposes MVP commands', async () => {
|
test('CLI help only exposes MVP commands', async () => {
|
||||||
const result = await exec('node', [cli, 'help'])
|
const result = await exec('node', [cli, 'help'])
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
import { buildApiCatalog, matchApiEndpoints } from '../src/lib/api-docs.js'
|
||||||
|
import { buildMaintenanceContract } from '../src/lib/maintenance.js'
|
||||||
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
|
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
|
||||||
|
|
||||||
test('summarizeHtml extracts user-visible contract evidence', () => {
|
test('summarizeHtml extracts user-visible contract evidence', () => {
|
||||||
@@ -58,6 +60,37 @@ test('buildPageContract creates page-level UI contract', () => {
|
|||||||
assert.ok(contract.vuetifyComponents.includes('VTable'))
|
assert.ok(contract.vuetifyComponents.includes('VTable'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('buildPageContract infers legacy table field labels and action semantics', () => {
|
||||||
|
const html = `
|
||||||
|
<main>
|
||||||
|
<form>
|
||||||
|
<table>
|
||||||
|
<tr><th>場地</th><td><select name="rom_id"><option>R001 大禮堂</option></select></td></tr>
|
||||||
|
<tr><th>查詢起日</th><td><input name="startDate" maxlength="7"></td></tr>
|
||||||
|
</table>
|
||||||
|
<input type="button" value="查詢">
|
||||||
|
</form>
|
||||||
|
<table><tr><th>申請單號</th><th>操作</th></tr><tr><td>A001</td><td><input type="button" value="刪除"></td></tr></table>
|
||||||
|
</main>
|
||||||
|
`
|
||||||
|
const contract = buildPageContract({
|
||||||
|
page: 'query-room.html',
|
||||||
|
source: '/prototype/query-room.html',
|
||||||
|
html,
|
||||||
|
regions: extractRegions(html),
|
||||||
|
domSummary: summarizeHtml(html),
|
||||||
|
screenshotPath: null
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(contract.forms[0].fields[0].label, '場地')
|
||||||
|
assert.deepEqual(contract.forms[0].fields[0].options, ['R001 大禮堂'])
|
||||||
|
assert.equal(contract.forms[0].fields[1].maxLength, '7')
|
||||||
|
assert.equal(contract.actions.find((action) => action.label === '查詢').actionType, 'search')
|
||||||
|
assert.equal(contract.actions.find((action) => action.label === '刪除').scope, 'rowAction')
|
||||||
|
assert.equal(contract.tables[0].role, 'searchTable')
|
||||||
|
assert.equal(contract.tables[1].role, 'resultTable')
|
||||||
|
})
|
||||||
|
|
||||||
test('validatePageContract reports evidence mismatches', () => {
|
test('validatePageContract reports evidence mismatches', () => {
|
||||||
const report = validatePageContract({
|
const report = validatePageContract({
|
||||||
textSamples: ['Missing'],
|
textSamples: ['Missing'],
|
||||||
@@ -118,6 +151,110 @@ test('buildAppMap enriches routes with prototype markdown guide entries', () =>
|
|||||||
assert.equal(route.guide.targetView, 'RoomQueryView.vue')
|
assert.equal(route.guide.targetView, 'RoomQueryView.vue')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('parsePrototypeGuide attaches checklist items to guide entries', () => {
|
||||||
|
const guide = parsePrototypeGuide('venue.md', `
|
||||||
|
# Venue
|
||||||
|
|
||||||
|
| 雛型檔 | 舊 JSP 來源 | 對應 Vue view |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [\`apply-room.html\`](venue/apply-room.html) | \`legacy/apply.jsp\` | \`RoomApplyView.vue\` |
|
||||||
|
|
||||||
|
### apply-room.html vs legacy
|
||||||
|
|
||||||
|
- [ ] 活動名稱必填
|
||||||
|
- [ ] 使用節次:14 個 checkbox
|
||||||
|
`)
|
||||||
|
|
||||||
|
assert.deepEqual(guide.entries[0].checklist, ['活動名稱必填', '使用節次:14 個 checkbox'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildApiCatalog parses generic markdown API docs and matches routes', () => {
|
||||||
|
const catalog = buildApiCatalog([
|
||||||
|
{
|
||||||
|
source: 'apps/backend/API.md',
|
||||||
|
markdown: `
|
||||||
|
## Orders Module
|
||||||
|
|
||||||
|
| 方法 | 路徑 | 說明 | 授權 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | \`/api/v1/orders\` | 查詢訂單 | Authorize |
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'apps/backend/API_Manual.md',
|
||||||
|
markdown: `
|
||||||
|
## 5. Orders API
|
||||||
|
|
||||||
|
### 5.1 新增訂單
|
||||||
|
|
||||||
|
\`\`\`text
|
||||||
|
POST /api/v1/orders
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{ "orderName": "Demo", "items": [{ "sku": "A001", "qty": 1 }] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{ "orderNo": "O001" }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
欄位規則:
|
||||||
|
|
||||||
|
| 欄位 | 規則 |
|
||||||
|
| --- | --- |
|
||||||
|
| \`orderName\` | 必填 |
|
||||||
|
`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.ok(catalog.endpoints.some((endpoint) => endpoint.id === 'POST /api/v1/orders'))
|
||||||
|
assert.equal(catalog.fieldRules[0].field, 'orderName')
|
||||||
|
const matches = matchApiEndpoints({
|
||||||
|
prototype: 'orders/apply.html',
|
||||||
|
page: 'apply.html',
|
||||||
|
module: 'orders',
|
||||||
|
title: '訂單申請',
|
||||||
|
guide: { targetView: 'OrderApplyView.vue' },
|
||||||
|
evidence: { actions: ['存檔'], textSamples: ['訂單'] }
|
||||||
|
}, catalog)
|
||||||
|
assert.equal(matches[0].path, '/api/v1/orders')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildMaintenanceContract recommends generic templates from evidence', () => {
|
||||||
|
const spec = buildSpec('/repo/prototype/orders/apply.html', `
|
||||||
|
<main>
|
||||||
|
<h1>訂單申請</h1>
|
||||||
|
<form>
|
||||||
|
<table>
|
||||||
|
<tr><th>訂單名稱</th><td><input name="orderName"></td></tr>
|
||||||
|
<tr><th>明細</th><td><select name="items"><option>A001</option></select></td></tr>
|
||||||
|
</table>
|
||||||
|
<input type="button" value="存檔">
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
`)
|
||||||
|
const route = {
|
||||||
|
kind: 'feature-page',
|
||||||
|
prototype: 'orders/apply.html',
|
||||||
|
page: 'apply.html',
|
||||||
|
guide: { targetView: 'OrderApplyView.vue' }
|
||||||
|
}
|
||||||
|
const contract = buildMaintenanceContract({
|
||||||
|
route,
|
||||||
|
spec,
|
||||||
|
apiMatches: [{ method: 'POST', path: '/api/v1/orders', description: '新增訂單' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(contract.pageKind, 'application')
|
||||||
|
assert.equal(contract.recommendedTemplate, 'master-detail-c')
|
||||||
|
assert.equal(contract.dataModel.primaryEntity, 'OrderApply')
|
||||||
|
})
|
||||||
|
|
||||||
function buildSpec(source, html) {
|
function buildSpec(source, html) {
|
||||||
const regions = extractRegions(html)
|
const regions = extractRegions(html)
|
||||||
const domSummary = summarizeHtml(html)
|
const domSummary = summarizeHtml(html)
|
||||||
|
|||||||
Reference in New Issue
Block a user