diff --git a/README.md b/README.md index 6202105..e4d47f7 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ HTML Transform 是 prototype evidence 工具。現行 MVP 只做兩件事: - `doctor`:檢查執行 `scan` 需要的前置條件。 -- `scan`:讀取 HTML prototype,產生瀏覽器證據與頁面級 UI Contract。 -- `app-map.json`:推論每個 prototype 的頁面角色與 layout 使用策略,並用 `prototype/*.md` 的人工 domain guide 輔助補上舊系統對照。 +- `scan`:讀取 HTML prototype、prototype guide 與 backend API docs,產生瀏覽器證據、頁面級 UI Contract 與 implementation contract。 +- `app-map.json`:推論每個 prototype 的頁面角色、layout 使用策略、API 對應、維護頁建議範本,並用 `prototype/*.md` 的人工 domain guide 輔助補上舊系統對照。 不提供 `plan`、`run`、`diff`、`verify`、`go`、`status`。這些舊骨架已移除,避免把尚未完成的自動化流程誤認成可用功能。 @@ -16,6 +16,8 @@ HTML Transform 是 prototype evidence 工具。現行 MVP 只做兩件事: Stage 1 Capture Stage 3-lite Page Contract Stage 4-lite Contract Validation +API Catalog +Maintenance Contract ``` Stage 2 decomposition 不執行。現有 prototype 主要是後台系統頁面,重點是表單、查詢條件、表格、actions 與資訊架構;切 region screenshot 對 MVP 價值不高。 @@ -69,6 +71,8 @@ pnpm --version pnpm install ``` +[playwright](https://playwright.dev/agent-cli/installation) + 確認 `playwright-cli` 可用: ```bash @@ -95,11 +99,44 @@ packages/prototype/ ```js 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 @@ -131,8 +168,11 @@ node packages/html-transform/src/cli.js scan - 記錄 resource failure 與 console error/warning。 - 建立頁面級 `pageContract`。 - 驗證 contract 與 DOM evidence 是否明顯衝突。 -- 讀取 `prototype/*.md` 的對照表,擷取 prototype 檔、舊 JSP、舊 PB、對應 Vue view 或功能描述。 -- 產出 `.ht/app-map.json`,供通用 prompt 判斷 auth、legacy shell、feature page、layout 策略與舊系統對照。 +- 讀取 `prototype/*.md` 的對照表與 checklist,擷取 prototype 檔、舊 JSP、舊 PB、對應 Vue view、功能描述與人工 checklist。 +- 讀取 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: ```text +.ht/api-catalog.json .ht/app-map.json .ht/spec/{page}.spec.json .ht/spec/{page}.validation.json .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 只提供內容與功能證據。 +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: ```json @@ -168,15 +236,37 @@ node packages/html-transform/src/cli.js scan "legacyJsp": "zte_pro/zte451_02.jsp + zte451_02_1.jsp", "legacyPb": "n_zte451.of_zte451_02 / of_zte451_02_1", "targetView": "RoomQueryView.vue", - "description": null + "description": null, + "checklist": [] }, "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 或下游工具,應改讀新增欄位。 ## 驗證 diff --git a/TODO.md b/TODO.md index 7393336..1a414c2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,54 +1,173 @@ # 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. -- Reports may pass pages because functional elements exist, even when form density, alignment, spacing, table structure, and first-screen information density are wrong. -- `desktop-default.png` contains this evidence visually, but the generated JSON/Markdown artifacts do not expose enough layout metrics for deterministic checks. +- 強化 prototype form/table/action 萃取。 + - 支援 legacy table-form 的 `/` 欄位 label 推論。 + - 欄位輸出包含 `label`、`name`、`type`、`required`、`readonly`、`maxLength`、`options`、`defaultValue`、`sourceTable`、`sourceRow`。 + - action 會輸出 `actionType` 與 `scope`,例如 `search`、`save`、`edit`、`delete`、`print`、`back`、`formAction`、`rowAction`。 + - table 會輸出 `role`,例如 `searchTable`、`resultTable`、`detailTable`、`printTable`、`layoutTable`。 +- 解析 `prototype/*.md` guide。 + - 保留 `legacyJsp`、`legacyPb`、`targetView`、`description`。 + - 解析各 prototype 段落 checklist,寫入 route guide 與 page spec。 +- 解析 backend API 文件。 + - 從 `apps/backend/API.md` 解析 endpoint index。 + - 從 `apps/backend/API_Manual.md` 解析 method/path、request/response JSON、field rules、notes、ProblemDetails examples。 + - 輸出 `.ht/api-catalog.json`。 +- 建立 prototype-to-api matching。 + - 依 prototype path/module、guide、title/text/actions 與 endpoint path/description tokens 做通用匹配。 + - 不針對 Venue 寫死規則;Venue 只是目前驗證案例。 +- 產出維護頁 `maintenanceContract`。 + - 輸出 `pageKind`、`capabilities`、`recommendedTemplate`、`confidence`、`reasons`、`warnings`、`dataModel`。 + - 可初步分辨 `maintenance`、`query`、`application`、`print`、`chooser`、`layout-reference`。 +- 更新 `.ui-contract.md`。 + - 加入 page kind、recommended template、capabilities、primary entity、target view、legacy source、prototype checklist、API endpoints。 +- 補測試。 + - 使用 generic `orders` domain 測試 API catalog、API matching 與 maintenance template 推論,避免只驗證 Venue。 -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 - labels - inputs/selects/textareas - buttons/links - tables - section containers -- Group form fields into likely rows and columns using bounding boxes. -- Detect table/list layout: - - table count - - table order - - header labels - - whether multiple tables are stacked vertically -- Capture first-viewport density: +- 用 bounding boxes 將表單欄位分群成可能的 rows / columns。 +- 擷取 first-viewport density: - visible section count - visible form field count - visible table/header count - approximate occupied content area -- Emit layout hints into `.ht/spec/{page}.spec.json`, for example: +- 在 `.ht/spec/{page}.spec.json` 輸出: - `layoutEvidence.sections` - `layoutEvidence.formRows` - `layoutEvidence.tables` - `layoutEvidence.primaryActions` - `layoutEvidence.firstViewport` -- Render layout hints into `.ht/spec/{page}.ui-contract.md` so LLMs see layout constraints without manually inferring everything from screenshots. -- Add validation warnings for likely layout-critical structures: - - stacked tables - - dense row-based forms - - query toolbar with adjacent submit button - - multi-row detail tables +- 在 `.ht/spec/{page}.ui-contract.md` 呈現 layout hints。 -Non-goals: +Non-goals: -- Do not require pixel-perfect HTML/JSP reproduction. -- Do not copy legacy CSS. -- Do not encode exact colors/fonts as hard constraints unless needed for functional recognition. +- 不要求 pixel-perfect HTML/JSP reproduction。 +- 不複製 legacy CSS。 +- 除非是功能辨識必要,不把精確 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 時的輸出行為 diff --git a/src/lib/api-docs.js b/src/lib/api-docs.js new file mode 100644 index 0000000..a2b3491 --- /dev/null +++ b/src/lib/api-docs.js @@ -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(//gi, ' / ') + .trim() +} + +function cleanHeading(value) { + return cleanMarkdown(value).replace(/\s+/g, ' ').trim() +} diff --git a/src/lib/config.js b/src/lib/config.js index f208677..d9cd7d9 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -5,6 +5,7 @@ import { defineConfig } from '../index.js' const defaultConfig = { prototype: './packages/prototype', + backendDocs: './apps/backend', vision: { viewport: { width: 1440, height: 900 }, captureStates: ['default'], @@ -55,6 +56,10 @@ function normalizeConfig(config, cwd) { ...merged, cwd, 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') } } diff --git a/src/lib/html.js b/src/lib/html.js index 342327e..5a288ed 100644 --- a/src/lib/html.js +++ b/src/lib/html.js @@ -7,16 +7,9 @@ export function summarizeHtml(html) { const title = matchText(html, /]*>([\s\S]*?)<\/title>/i) const headings = collect(html, /]*>([\s\S]*?)<\/h\1>/gi).map((item) => cleanText(item[2])) - const buttons = collect(html, /<(button|a)\b[^>]*>([\s\S]*?)<\/\1>/gi) - .map((item) => cleanText(item[2])) - .filter(Boolean) + const buttons = extractActionElements(html).map((action) => action.label) const labels = collect(html, /]*>([\s\S]*?)<\/label>/gi).map((item) => cleanText(item[1])) - const inputs = collect(html, /<(input|select|textarea)\b([^>]*)>/gi).map((item) => ({ - tag: item[1].toLowerCase(), - type: attr(item[2], 'type') ?? null, - name: attr(item[2], 'name') ?? attr(item[2], 'id') ?? null, - required: /\brequired\b/i.test(item[2]) - })) + const inputs = extractFields(html) const textSamples = [...headings, ...labels, ...buttons, ...visibleText(html)].filter(Boolean).slice(0, 3) return { title, headings, buttons, labels, inputs, textSamples } } @@ -135,12 +128,13 @@ export function buildPageContract({ page, source, html, regions, domSummary, scr })), forms: buildForms(summary), tables: buildTables(html), - actions: summary.buttons.map((label, index) => ({ - label, - kind: index === 0 ? 'primary' : 'secondary', + actions: extractActionElements(html).map((action, index) => ({ + ...action, + kind: action.kind ?? (index === 0 ? 'primary' : 'secondary'), observableResult: null })), textSamples: summary.textSamples, + layoutEvidence: summary.layoutEvidence ?? buildStaticLayoutEvidence({ html, regions, forms: buildForms(summary), tables: buildTables(html), actions: extractActionElements(html) }), vuetifyComponents: inferPageComponents(html, regions), apiDependencies: [], warnings: [] @@ -221,7 +215,8 @@ export function buildAppMapWithGuides(layoutSpecs, prototypeDir, prototypeGuides legacyJsp: guideEntry.legacyJsp, legacyPb: guideEntry.legacyPb, targetView: guideEntry.targetView, - description: guideEntry.description + description: guideEntry.description, + checklist: guideEntry.checklist ?? [] } : null, evidence: { uiContract: `.ht/spec/${relativeSource.replace(/\.html$/, '.ui-contract.md')}`, @@ -277,9 +272,14 @@ export function parsePrototypeGuide(source, markdown) { legacyJsp: findGuideValue(values, ['舊 JSP']), legacyPb: findGuideValue(values, ['舊 PB', 'PB NVO', 'PB']), 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 { source, 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) { const index = new Map() for (const guide of prototypeGuides) { @@ -446,11 +463,57 @@ function mergeSummary(sourceSummary, domSummary = {}) { headings: unique([...(domSummary.headings ?? []), ...sourceSummary.headings]), buttons: unique([...(domSummary.buttons ?? []), ...sourceSummary.buttons]), labels: unique([...(domSummary.labels ?? []), ...sourceSummary.labels]), - inputs: domSummary.inputs?.length ? domSummary.inputs : sourceSummary.inputs, - textSamples: unique([...(domSummary.textSamples ?? []), ...sourceSummary.textSamples]).slice(0, 3) + inputs: mergeInputs(sourceSummary.inputs, domSummary.inputs ?? []), + 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: / ({ 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) { const fields = summary.inputs.filter((field) => !['hidden', 'button', 'submit', 'reset', 'image'].includes(field.type ?? '')) if (fields.length === 0) return [] @@ -458,9 +521,15 @@ function buildForms(summary) { labels: summary.labels, fields: fields.map((field, index) => ({ 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, - 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), secondaryActions: summary.buttons.slice(1) @@ -468,13 +537,170 @@ function buildForms(summary) { } function buildTables(html) { - return collect(html, /]*>([\s\S]*?)<\/table>/gi).map((item, index) => ({ - id: `table-${index + 1}`, - headers: collect(item[1], /]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean), - sampleRows: collect(item[1], /]*>([\s\S]*?)<\/tr>/gi).slice(0, 3).map((row) => + return collect(html, /]*>([\s\S]*?)<\/table>/gi).map((item, index) => { + const headers = collect(item[1], /]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean) + const sampleRows = collect(item[1], /]*>([\s\S]*?)<\/tr>/gi).slice(0, 3).map((row) => collect(row[1], /]*>([\s\S]*?)<\/td>/gi).map((cell) => cleanText(cell[1])).filter(Boolean) ).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, /]*>([\s\S]*?)<\/table>/gi) + for (const [tableIndex, table] of tableMatches.entries()) { + const rows = collect(table[1], /]*>([\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(//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] ?? '', /]*>([\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, /]*)>/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 (/ 0) return 'resultTable' + return 'layoutTable' +} + +function inferRowLabel(rowHtml) { + const th = matchText(rowHtml, /]*>([\s\S]*?)<\/th>/i) + if (th) return th + const cells = collect(rowHtml, /]*>([\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(//gi, ' ') + .replace(//gi, ' ') + .replace(/]*>/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(`]*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) { diff --git a/src/lib/maintenance.js b/src/lib/maintenance.js new file mode 100644 index 0000000..8ef1bdb --- /dev/null +++ b/src/lib/maintenance.js @@ -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) => / 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 + }) +} diff --git a/src/stages/scan.js b/src/stages/scan.js index e66651c..437d681 100644 --- a/src/stages/scan.js +++ b/src/stages/scan.js @@ -3,7 +3,9 @@ import { readFile, writeFile } from 'node:fs/promises' import { basename, join, relative } from 'node:path' import { loadConfig } from '../lib/config.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 { buildMaintenanceContract } from '../lib/maintenance.js' import { resolvePlaywrightCli } from '../lib/playwright-cli.js' const viewports = [ @@ -20,6 +22,7 @@ export async function scan() { const htmlFiles = await listFiles(config.prototypeDir, ['.html']) if (htmlFiles.length === 0) throw new Error(`找不到 HTML prototype:${config.prototypeDir}`) const prototypeGuides = await readPrototypeGuides(config) + const apiCatalog = await readApiCatalog(config) await ensureDir(config.htDir) const plans = [] for (const file of htmlFiles) { @@ -35,7 +38,16 @@ export async function scan() { } finally { 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 檔案`) } @@ -49,6 +61,62 @@ async function readPrototypeGuides(config) { 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) { const hash = await sha256File(file) const name = artifactPath(config.prototypeDir, file) @@ -84,7 +152,7 @@ async function scanFile(config, plan, serverUrl) { domSummary, screenshotPath: screenshot?.path ?? null }) - const validationReport = validateSpecs(regionSpecs, pageContract, domSummary) + const validationReport = validateSpecs(regionSpecs, pageContract, mergeEvidence(summarizeHtml(html), domSummary)) const layoutSpec = { source: file, hash, @@ -201,18 +269,71 @@ function captureScript(url, viewport, state, screenshotPath) { .map(element => text(element) || element.value || attr(element, 'aria-label') || attr(element, 'title') || '') .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 => ({ tag: element.tagName.toLowerCase(), type: attr(element, 'type'), 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 { title: document.title || null, headings, buttons, labels, inputs, + layoutEvidence, 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) { if (!(await exists(metadataPath))) return false try { @@ -345,32 +477,85 @@ function renderUiContract(spec) { lines.push(`- Title: ${contract.title ?? 'none'}`) lines.push(`- Text: ${contract.textSamples.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('## Sections', '') for (const section of contract.sections) { 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('## Forms', '') if (contract.forms.length === 0) lines.push('- none') for (const form of contract.forms) { 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'}`) } + 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('## Tables', '') if (contract.tables.length === 0) lines.push('- none') 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('## Actions', '') if (contract.actions.length === 0) lines.push('- none') 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('## 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', '') const warnings = spec.validation.warnings if (warnings.length === 0) lines.push('- none') diff --git a/test/cli-e2e.test.js b/test/cli-e2e.test.js index 88b42a4..48c6483 100644 --- a/test/cli-e2e.test.js +++ b/test/cli-e2e.test.js @@ -42,7 +42,7 @@ test('CLI runs doctor and scan against one prototype', async () => { assert.match(doctor.stdout, /ok prototype directory/) assert.match(contract, /Customer Portal/) 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', label: 'Email', type: 'input', @@ -57,6 +57,10 @@ test('CLI runs doctor and scan against one prototype', async () => { 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 () => { const result = await exec('node', [cli, 'help']) diff --git a/test/html.test.js b/test/html.test.js index 8df91b4..bf70024 100644 --- a/test/html.test.js +++ b/test/html.test.js @@ -1,5 +1,7 @@ import test from 'node:test' 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' test('summarizeHtml extracts user-visible contract evidence', () => { @@ -58,6 +60,37 @@ test('buildPageContract creates page-level UI contract', () => { assert.ok(contract.vuetifyComponents.includes('VTable')) }) +test('buildPageContract infers legacy table field labels and action semantics', () => { + const html = ` +
+
+ + + +
場地
查詢起日
+ +
+
申請單號操作
A001
+
+ ` + 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', () => { const report = validatePageContract({ textSamples: ['Missing'], @@ -118,6 +151,110 @@ test('buildAppMap enriches routes with prototype markdown guide entries', () => 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', ` +
+

訂單申請

+
+ + + +
訂單名稱
明細
+ +
+
+ `) + 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) { const regions = extractRegions(html) const domSummary = summarizeHtml(html)