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, / |