diff --git a/README.md b/README.md index 8d16b19..6202105 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ HTML Transform 是 prototype evidence 工具。現行 MVP 只做兩件事: - `doctor`:檢查執行 `scan` 需要的前置條件。 - `scan`:讀取 HTML prototype,產生瀏覽器證據與頁面級 UI Contract。 -- `app-map.json`:推論每個 prototype 的頁面角色與 layout 使用策略。 +- `app-map.json`:推論每個 prototype 的頁面角色與 layout 使用策略,並用 `prototype/*.md` 的人工 domain guide 輔助補上舊系統對照。 不提供 `plan`、`run`、`diff`、`verify`、`go`、`status`。這些舊骨架已移除,避免把尚未完成的自動化流程誤認成可用功能。 @@ -83,27 +83,23 @@ pnpm --filter html-transform exec playwright-cli install-browser chromium ## 設定檔 -沒有設定檔時,預設讀取: +設定檔不是工具執行的絕對必要條件;沒有設定檔時,工具會使用內建預設並讀取: ```text packages/prototype/ ``` -本 repo 根目錄目前使用: +本 repo 的 HTML prototype 位於根目錄 `./prototype`,不是 `./packages/prototype`。因此從 repo 根目錄執行 `node packages/html-transform/src/cli.js scan` 時,需要根目錄 `ht.config.mjs` 覆寫 `prototype` 路徑。 + +本 repo 根目錄目前使用最小設定: ```js export default { - prototype: './prototype', - frontend: './apps/frontend/hwu-re', - backend: './apps/backend', - output: './output', - plan: { - interactiveReview: false - } + prototype: './prototype' } ``` -目前 MVP 只使用 `prototype`。其他欄位可以留著給外部工作流參考,但 `packages/html-transform` 不會執行 frontend/backend/output 相關步驟。 +目前 MVP 只使用 `prototype`。`scan` 會讀取其中的 HTML prototype,也會讀取 `prototype/*.md` 作為 app-map 的輔助 domain guide。`frontend`、`backend`、`output`、`plan` 等欄位不會被 `doctor` 或 `scan` 使用,不需要為本 MVP 加入設定檔。 ## Doctor @@ -135,7 +131,8 @@ node packages/html-transform/src/cli.js scan - 記錄 resource failure 與 console error/warning。 - 建立頁面級 `pageContract`。 - 驗證 contract 與 DOM evidence 是否明顯衝突。 -- 產出 `.ht/app-map.json`,供通用 prompt 判斷 auth、legacy shell、feature page 與 layout 策略。 +- 讀取 `prototype/*.md` 的對照表,擷取 prototype 檔、舊 JSP、舊 PB、對應 Vue view 或功能描述。 +- 產出 `.ht/app-map.json`,供通用 prompt 判斷 auth、legacy shell、feature page、layout 策略與舊系統對照。 ## 產物 @@ -161,6 +158,26 @@ node packages/html-transform/src/cli.js scan `.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。 +若 `prototype/*.md` 內有 markdown table 對照 prototype HTML,例如 `venue/query-room.html`,`scan` 會把匹配結果寫進 route: + +```json +{ + "prototype": "venue/query-room.html", + "guide": { + "source": "venue.md", + "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 + }, + "evidence": { + "prototypeGuide": "venue.md" + } +} +``` + +這些 guide 欄位只輔助 route 與舊系統對照理解;HTML capture、DOM summary、UI contract 與 screenshot 仍是畫面內容的主要 evidence。 + ## 驗證 修改本 package 後至少執行: diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7393336 --- /dev/null +++ b/TODO.md @@ -0,0 +1,54 @@ +# 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. + +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. + +Future work: + +- Capture bounding boxes for important visible elements: + - 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: + - 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: + - `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 + +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. + +Goal: + +- Preserve information architecture, form density, field alignment, table/list relationships, and operation flow while still allowing a modern Vue/Vuetify implementation. diff --git a/src/lib/html.js b/src/lib/html.js index 8fdc342..342327e 100644 --- a/src/lib/html.js +++ b/src/lib/html.js @@ -194,10 +194,16 @@ export function validatePageContract(contract, evidence = {}) { } export function buildAppMap(layoutSpecs, prototypeDir) { + return buildAppMapWithGuides(layoutSpecs, prototypeDir, []) +} + +export function buildAppMapWithGuides(layoutSpecs, prototypeDir, prototypeGuides = []) { + const guideIndex = buildPrototypeGuideIndex(prototypeGuides) const routes = layoutSpecs.map((spec) => { const relativeSource = spec.source.startsWith(prototypeDir) ? spec.source.slice(prototypeDir.length).replace(/^\/+/, '') : spec.source + const guideEntry = guideIndex.get(relativeSource) const route = inferRoute(spec, relativeSource) return { prototype: relativeSource, @@ -210,9 +216,17 @@ export function buildAppMap(layoutSpecs, prototypeDir) { usePrototypeStyle: false, usePrototypeContent: route.usePrototypeContent, routeHint: route.routeHint, + guide: guideEntry ? { + source: guideEntry.source, + legacyJsp: guideEntry.legacyJsp, + legacyPb: guideEntry.legacyPb, + targetView: guideEntry.targetView, + description: guideEntry.description + } : null, evidence: { uiContract: `.ht/spec/${relativeSource.replace(/\.html$/, '.ui-contract.md')}`, spec: `.ht/spec/${relativeSource.replace(/\.html$/, '.spec.json')}`, + prototypeGuide: guideEntry?.source ?? null, screenshot: spec.pageContract.screenshot, textSamples: spec.pageContract.textSamples, actions: spec.pageContract.actions.map((action) => action.label), @@ -230,11 +244,86 @@ export function buildAppMap(layoutSpecs, prototypeDir) { prototypeStylePolicy: 'content-only', prototypeOuterFramePolicy: 'ignore' }, + guideSources: prototypeGuides.map((guide) => ({ + source: guide.source, + title: guide.title, + entryCount: guide.entries.length + })), modules: buildModules(routes), routes } } +export function parsePrototypeGuide(source, markdown) { + const title = matchText(markdown, /^#\s+(.+)$/m) + const entries = [] + let headers = [] + for (const line of markdown.split('\n')) { + if (!/^\s*\|/.test(line)) continue + const cells = splitMarkdownTableRow(line) + if (cells.length === 0 || cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))) continue + if (cells.some((cell) => /雛型檔|prototype/i.test(cell))) { + headers = cells.map(cleanMarkdownCell) + continue + } + const prototypeIndex = cells.findIndex((cell) => /\.html\b/i.test(cell)) + if (prototypeIndex === -1) continue + const prototype = extractPrototypePath(cells[prototypeIndex]) + if (!prototype) continue + const values = new Map(headers.map((header, index) => [header, cleanMarkdownCell(cells[index] ?? '')])) + entries.push({ + prototype, + source, + legacyJsp: findGuideValue(values, ['舊 JSP']), + legacyPb: findGuideValue(values, ['舊 PB', 'PB NVO', 'PB']), + targetView: findGuideValue(values, ['Vue view', '對應 Vue']), + description: findGuideValue(values, ['功能']) + }) + } + return { + source, + title: title ? cleanMarkdownCell(title) : null, + entries + } +} + +function buildPrototypeGuideIndex(prototypeGuides) { + const index = new Map() + for (const guide of prototypeGuides) { + for (const entry of guide.entries) { + index.set(entry.prototype, entry) + } + } + return index +} + +function splitMarkdownTableRow(line) { + return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim()) +} + +function extractPrototypePath(cell) { + const linkTarget = cell.match(/\]\(([^)]+\.html)\)/i)?.[1] + const path = linkTarget ?? cell.match(/([A-Za-z0-9_./-]+\.html)\b/i)?.[1] + return path?.replace(/^\.\//, '') ?? null +} + +function cleanMarkdownCell(cell) { + const value = cell + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*/g, '') + .replace(//gi, ' / ') + .trim() + return value === '' || value === '—' ? null : value +} + +function findGuideValue(values, needles) { + for (const [header, value] of values) { + if (value && needles.some((needle) => header?.includes(needle))) return value + } + return null +} + function inferRoute(spec, relativeSource) { const path = relativeSource.replace(/\\/g, '/') const segments = path.split('/') diff --git a/src/stages/scan.js b/src/stages/scan.js index 73835c5..e66651c 100644 --- a/src/stages/scan.js +++ b/src/stages/scan.js @@ -1,9 +1,9 @@ import { spawn } from 'node:child_process' import { readFile, writeFile } from 'node:fs/promises' -import { basename, join } from 'node:path' +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 { buildAppMap, buildPageContract, extractRegions, inferRegionSpec, summarizeHtml, validatePageContract } from '../lib/html.js' +import { buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../lib/html.js' import { resolvePlaywrightCli } from '../lib/playwright-cli.js' const viewports = [ @@ -19,6 +19,7 @@ export async function scan() { const config = await loadConfig() const htmlFiles = await listFiles(config.prototypeDir, ['.html']) if (htmlFiles.length === 0) throw new Error(`找不到 HTML prototype:${config.prototypeDir}`) + const prototypeGuides = await readPrototypeGuides(config) await ensureDir(config.htDir) const plans = [] for (const file of htmlFiles) { @@ -34,10 +35,20 @@ export async function scan() { } finally { await server?.close() } - await writeJson(join(config.htDir, 'app-map.json'), buildAppMap(layoutSpecs, config.prototypeDir)) + await writeJson(join(config.htDir, 'app-map.json'), buildAppMapWithGuides(layoutSpecs, config.prototypeDir, prototypeGuides)) console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`) } +async function readPrototypeGuides(config) { + const files = await listFiles(config.prototypeDir, ['.md']) + const guides = [] + for (const file of files) { + const source = relative(config.prototypeDir, file).replaceAll('\\', '/') + guides.push(parsePrototypeGuide(source, await readFile(file, 'utf8'))) + } + return guides.filter((guide) => guide.entries.length > 0) +} + async function prepareScanFile(config, file) { const hash = await sha256File(file) const name = artifactPath(config.prototypeDir, file) diff --git a/test/cli-e2e.test.js b/test/cli-e2e.test.js index 1bce474..88b42a4 100644 --- a/test/cli-e2e.test.js +++ b/test/cli-e2e.test.js @@ -23,6 +23,13 @@ test('CLI runs doctor and scan against one prototype', async () => { `) + await writeFile(join(cwd, 'packages/prototype/portal.md'), ` + # Portal Guide + + | 雛型檔 | 舊 JSP 來源 | 功能 | + | --- | --- | --- | + | [\`index.html\`](index.html) | \`legacy/index.jsp\` | Customer portal entry | + `) const doctor = await exec('node', [cli, 'doctor'], { cwd }) await exec('node', [cli, 'scan'], { cwd }) @@ -45,6 +52,9 @@ test('CLI runs doctor and scan against one prototype', async () => { assert.equal(appMap.routes[0].prototype, 'index.html') assert.equal(appMap.routes[0].kind, 'feature-page') assert.equal(appMap.routes[0].layout, 'template-app') + assert.equal(appMap.routes[0].guide.legacyJsp, 'legacy/index.jsp') + assert.equal(appMap.routes[0].guide.description, 'Customer portal entry') + assert.equal(appMap.guideSources[0].source, 'portal.md') }) test('CLI help only exposes MVP commands', async () => { diff --git a/test/html.test.js b/test/html.test.js index 594a94c..8df91b4 100644 --- a/test/html.test.js +++ b/test/html.test.js @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { buildAppMap, buildPageContract, extractRegions, inferRegionSpec, summarizeHtml, validatePageContract } from '../src/lib/html.js' +import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js' test('summarizeHtml extracts user-visible contract evidence', () => { const summary = summarizeHtml(` @@ -97,6 +97,27 @@ test('buildAppMap classifies auth, shell references, and feature pages', () => { assert.equal(appMap.modules.find((module) => module.name === 'venue').kind, 'feature-module') }) +test('buildAppMap enriches routes with prototype markdown guide entries', () => { + const prototypeDir = '/repo/prototype' + const guide = parsePrototypeGuide('venue.md', ` + # Venue 雛型導覽 + + | 雛型檔 | 舊 JSP 來源 | 舊 PB NVO 來源 | 對應 Vue view(M9) | + | --- | --- | --- | --- | + | [\`query-room.html\`](venue/query-room.html) | \`zte_pro/zte451_02.jsp\` + \`zte451_02_1.jsp\` | \`n_zte451.of_zte451_02\` / \`of_zte451_02_1\` | \`RoomQueryView.vue\` | + `) + const appMap = buildAppMapWithGuides([ + buildSpec('/repo/prototype/venue/query-room.html', '

全校場地查詢

') + ], prototypeDir, [guide]) + const route = appMap.routes[0] + + assert.equal(appMap.guideSources[0].source, 'venue.md') + assert.equal(route.evidence.prototypeGuide, 'venue.md') + assert.equal(route.guide.legacyJsp, 'zte_pro/zte451_02.jsp + zte451_02_1.jsp') + assert.equal(route.guide.legacyPb, 'n_zte451.of_zte451_02 / of_zte451_02_1') + assert.equal(route.guide.targetView, 'RoomQueryView.vue') +}) + function buildSpec(source, html) { const regions = extractRegions(html) const domSummary = summarizeHtml(html)