/** * console-ui build — 把 cypher-executor 的三支 UI renderer 在「建置時」跑一次, * 產出純靜態 HTML 到 public/,交給 Cloudflare Pages 託管。 * * 為什麼這樣做(cypher-ui-split 第一刀): * 原本 console/portal/dashboard 的 HTML 由 cypher-executor Worker 在「每次請求時」 * 用 template literal 組出來 → 5,240 行 UI 字串永遠躺在 Worker bundle 裡(748KB), * 連 /health 這種什麼都不做的請求都要付 5-7ms CPU(免費層上限 10ms)。 * UI 是靜態的(單檔 HTML+原生 JS、零外部資源),本來就該待在 Pages。 * * 保持原特性(leo 反覆強調簡化): * - 零打包工具、零 npm 依賴:本檔只用 node 內建 fs/path,正則抽出 renderer 的 * template literal 後求值。不引入 esbuild/vite/rollup。 * - 產出仍是「單檔 HTML+原生 JS hash routing、零外部資源」。 * * 唯一的行為差異=API base: * 原本 UI 與 API 同源,fetch 全用相對路徑('/kbdb/search')。搬上 Pages 後跨網域, * 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。 * 見下方 rewriteFetchPaths()。 */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..'); const SRC = join(ROOT, '..', 'cypher-executor', 'src'); const OUT = join(ROOT, 'public'); // ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)──────────────── // Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的 // 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。 // 具名部署目標(deploy.targets.json):一個目標=帳號+profile+apiBase 綁在一起。 // 帶 DEPLOY_TARGET=personal|enterprise 就套用該組值;個別環境變數仍可覆蓋(除錯用)。 // 立此檔的原因見 deploy.targets.json 的 _readme——散在部署指令裡的參數帶漏過三次。 const TARGET_NAME = process.env.DEPLOY_TARGET || ''; let TARGET = {}; if (TARGET_NAME) { const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8')); TARGET = targets[TARGET_NAME]; if (!TARGET) { const names = Object.keys(targets).filter((k) => !k.startsWith('_')); throw new Error(`未知的 DEPLOY_TARGET:"${TARGET_NAME}"。可用:${names.join(' / ')}`); } console.log(`部署目標:${TARGET_NAME} — ${TARGET.description}`); } const CFG = { brand: process.env.CONSOLE_BRAND || TARGET.brand || 'Arcrun', profile: process.env.CONSOLE_PROFILE || TARGET.profile || 'full', registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev', sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '', // API base 走 runtime 注入(見 public/config.js),這裡只放預設值 apiBase: process.env.ARCRUN_API_BASE || TARGET.apiBase || '', }; /** * 讀 TS 原始碼並取出整個 renderer 函式的**函式主體**(不只 template literal)。 * * 取整個 body 而非只取反引號區塊,是因為 renderer 在 return 之前會先算區域變數 * (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯 * 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。 */ /** * renderer 原始檔的位置:本專案 `console-ui/src/` 優先,找不到才回退 cypher-executor。 * * 為什麼要這層(2026-07-22 修):`5a16484` 把 UI 搬出 cypher-executor 時, * **刪了 console.ts / portal-ui.ts 卻只搬走 build 產物(HTML),原始檔沒跟著搬** * → build.mjs 讀不到來源,`npm run build` 從那天起就 ENOENT 死掉, * 線上 HTML 是刪檔前烤好的、之後再也無法重建(profile 改了也不會生效)。 * 現已從 git 撈回放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意。 * console-dashboard.ts 仍在 cypher-executor(它同時含 API),故保留回退路徑。 */ function resolveSource(file) { const local = join(ROOT, 'src', file.replace(/^routes\//, '')); if (existsSync(local)) return local; return join(SRC, file); } function extractRendererBody(file, fnName) { const code = readFileSync(resolveSource(file), 'utf8'); const start = code.indexOf(`function ${fnName}(`); if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`); const braceStart = code.indexOf('{', code.indexOf(')', start)); if (braceStart < 0) throw new Error(`${fnName} 找不到函式主體`); // 掃到配對的收尾大括號;需略過字串/template literal/註解裡的括號 let i = braceStart + 1; let depth = 1; let mode = null; // null | "'" | '"' | '`' | 'line' | 'block' let tplDepth = 0; while (i < code.length && depth > 0) { const ch = code[i]; const nx = code[i + 1]; if (mode === null) { if (ch === '\\') { i += 2; continue; } if (ch === '/' && nx === '/') { mode = 'line'; i += 2; continue; } if (ch === '/' && nx === '*') { mode = 'block'; i += 2; continue; } if (ch === "'" || ch === '"') { mode = ch; i++; continue; } if (ch === '`') { mode = '`'; tplDepth = 0; i++; continue; } if (ch === '{') depth++; else if (ch === '}') depth--; i++; continue; } if (mode === 'line') { if (ch === '\n') mode = null; i++; continue; } if (mode === 'block') { if (ch === '*' && nx === '/') { mode = null; i += 2; continue; } i++; continue; } if (ch === '\\') { i += 2; continue; } if (mode === '`') { // template literal 內的 ${ … } 是真程式碼,其中的引號/括號要照常計數才不會誤判收尾 if (ch === '$' && nx === '{') { tplDepth++; i += 2; continue; } if (ch === '}' && tplDepth > 0) { tplDepth--; i++; continue; } if (ch === '`' && tplDepth === 0) { mode = null; i++; continue; } i++; continue; } if (ch === mode) mode = null; i++; } // 去掉 TS 的型別註記(本 body 只有 `const x: T =` 這種簡單形態) return code.slice(braceStart + 1, i - 1).replace(/\bconst\s+(\w+):\s*[\w<>[\]|]+\s*=/g, 'const $1 ='); } /** 取出 lib/taipei-time.ts 匯出的 TAIPEI_CLIENT_JS 字串常數(UI 內嵌的客戶端時間工具)。 */ function extractTaipeiClientJs() { const code = readFileSync(join(SRC, 'lib', 'taipei-time.ts'), 'utf8'); // 形態=字串陣列 .join('\n')(見 lib/taipei-time.ts),直接求值該陣列表達式 const m = code.match(/export const TAIPEI_CLIENT_JS\s*=\s*(\[[\s\S]*?\]\.join\('\\n'\));/); if (!m) throw new Error('找不到 TAIPEI_CLIENT_JS'); return new Function(`return ${m[1]};`)(); } /** * 求值 renderer 函式主體。用 new Function 而非 eval——只餵建置期組態, * 輸入是本 repo 自己的原始碼(非使用者輸入),無注入面。 */ function render(body, vars) { const names = Object.keys(vars); const fn = new Function(...names, body); return fn(...names.map((n) => vars[n])); } /** * 把 UI 內原生 JS 的相對路徑 fetch 改成打 API base。 * * 只改 `fetch('/...` 與 `fetch("/...`(開頭是單斜線=同源絕對路徑)這一種形態, * 其餘(fetch(url, …) 這類變數形式)另由各檔的 url 組法在下面單獨處理。 */ function rewriteFetchPaths(html, file) { // ① fetch('/xxx → fetch(API_BASE + '/xxx let out = html.replace(/fetch\((['"])\/(?!\/)/g, 'fetch(API_BASE + $1/'); // ② 變數式 fetch(url, ...):url 由上方 var url = '/kbdb/search?...' 組成 → // 把這類「以單斜線開頭的路徑字面值指派」也補上 API_BASE out = out.replace(/(\bvar\s+url\s*=\s*)(['"])\/(?!\/)/g, '$1API_BASE + $2/'); // ③ portal 的 adminApi(method, path, body):path 由呼叫端傳字面值進來,①② // 都掃不到(8 個呼叫點)。在 helper 內部補前綴=一處修好全部,不必改 8 個呼叫點。 out = out.replace( /(function adminApi\(method, path, body\) \{)/, '$1\n path = API_BASE + path;' ); // 防呆:搬完後不該再有「直接 fetch 同源相對路徑」的殘留。掃到就讓建置失敗, // 免得漏網的呼叫點在 Pages 上打到 Pages 自己(404)才被發現。 // 註:adminApi 的呼叫端仍是相對路徑字面值——那是對的,前綴由 helper 內部(③)加。 const unprefixed = [...out.matchAll(/fetch\((['"])\/(?!\/)[^'"]*/g)].map((m) => m[0]); if (unprefixed.length) { throw new Error( `${file}:有 ${unprefixed.length} 個相對路徑 fetch 沒被改寫成 API_BASE:\n ` + [...new Set(unprefixed)].join('\n ') ); } // adminApi 形態存在時,必須確認 helper 已被加上前綴(否則 8 個呼叫點全會打錯家) if (/function adminApi\(method, path, body\)/.test(out) && !/path = API_BASE \+ path;/.test(out)) { throw new Error(`${file}:偵測到 adminApi helper 但前綴注入失敗`); } return out; } /** 在頁面
注入 config.js(runtime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */ function injectApiBase(html) { const snippet = ` `; const withCfg = html.replace('', `${snippet}\n`); // 內嵌的 IIFE 裡宣告 API_BASE(各頁的主