feat: 參考backend

This commit is contained in:
skytek_xinliang
2026-05-12 11:03:46 +08:00
parent 10843227a8
commit 58a5a525d7
9 changed files with 1488 additions and 69 deletions
+101 -11
View File
@@ -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 或下游工具,應改讀新增欄位。
## 驗證 ## 驗證
+147 -28
View File
@@ -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 時的輸出行為
+470
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+183
View File
@@ -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
View File
@@ -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')
+5 -1
View File
@@ -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'])
+137
View File
@@ -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)