/** * daemon-notes.mjs — 使用者在「版本與更新」畫面看到的那一行,**由 changelog 機械導出** * * ── 這支解什麼病(leo 2026-08-08 真機看到 v0.18.24 的更新畫面)──────────── * leo 原話:「**不要這麼長的散文,簡短講改了什麼,細節去 docs 讀。**」 * * 他看到的是**一整面文字牆**,而且 `**粗體**` 原樣露在畫面上。 * 真兇不是文案沒寫好,是**出貨當下靠人手工排版**: * 出貨時用一段臨時 python 把 changelog 的換行折掉塞進 `manifest.daemon.notes`, * 於是四條變成一大段;那段轉換每次出貨都要重寫一次,而且**沒人檢查結果長什麼樣**。 * * ⇒ 與「版本號由內容算」同一種解法:**這一行也由單一真相源導出,不由人當場捏**。 * * ── 為什麼是「只取粗體標題、串成一行」────────────────────────────────── * 畫面那個欄位是**純文字**:`main.js:215` 是 `
${esc(u.notes)}
`, * ① `esc()` ⇒ 任何 markdown 符號都會原樣露出來(leo 看到的 `**` 就是這樣來的) * ② HTML 不保留換行(`.d` 沒有 white-space:pre)⇒ 塞 `\n` 進去也**不會**變成分行 * ⇒ 唯一能讀的形狀就是**一行短句**。而 changelog 每條的 `**粗體標題**` 本來就是 * 那條的一句話摘要——直接拿它,不必另外維護第二份文案(第二份必然漂移)。 * * ── 🔴 2026-08-18(D95 第一輪):搬進 collector/,且不再往上伸手 ────────── * 本檔原本住在 `installer/scripts/`,讀的是 repo 根的 `docs-site/.../changelog.md`。 * 那讓 **daemon 的更新說明投影器住在 daemon 之外**,而它讀的檔也在 daemon 之外 * ⇒ `collector/` 沒辦法自己交出「這一版對用戶意味什麼」這句話。 * * 現在:實作住這裡,讀的是**同一棵樹裡的** `collector/CHANGELOG.md`(自我定位,不問 git)。 * `installer/scripts/daemon-notes.mjs` 變成薄殼,轉呼叫本檔—— * **根可以往內伸手,collector 不可以往外伸手**,方向是單向的。 * * 用法(collector 內部): * import { notesForVersion } from './daemon-notes.mjs'; * notesForVersion('v0.18.24') // → 一行字,或 null(changelog 沒這版) */ import { readFileSync, existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; /** daemon 的 changelog=`collector/CHANGELOG.md`。由本檔位置往上兩層推出來,不問 git、不問 repo 根。 */ export const CHANGELOG_PATH = join(import.meta.dirname, '..', '..', 'CHANGELOG.md'); /** 畫面上一行讀得完的上限。超過就截,並改叫使用者去看說明文件。 */ export const NOTES_MAX = 100; const TAIL = '(細節見說明文件)'; /** 把 markdown 行內語法剝成純文字——畫面不渲染 markdown,留著就是雜訊。 */ export function stripMarkdown(s) { return String(s) .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [字](連結) → 字 .replace(/[*_`]+/g, '') // 粗體/斜體/行內碼標記 .replace(/\s+/g, ' ') // 換行與連續空白 → 單一空白 .trim(); } /** * 「已發佈」的版本段落長這樣:`## v0.18.29(2026-08-16)`。 * 待發佈的那一段標題是「下一版(未發佈)」,**不符合這個形狀** ⇒ 天然不會被投影出去。 * 這是刻意的:投影器不必另外認一次「未發佈」,少一條要記得的規則。 */ const RELEASED_HEADING = /^##\s+(v\d+\.\d+\.\d+)(?=\D|$)/; /** * 把 changelog 切成「只剩已發佈段落」的 markdown。**給文件站投影用。** * * ── 為什麼這件事住在 collector/(2026-08-18,D95 第三輪)───────────────── * 文件站要在建置時把桌面版的更新內容接回 `/docs/help/changelog/` 那一頁 * (網址不變、使用者看到的東西不變)。**接的方式必須是「讀本檔」而不是「複製一份」** * ——第一輪把這條線搬進 `collector/CHANGELOG.md` 就是為了拔掉「同一份 changelog 存兩地」。 * * 而「哪些段落算已發佈、preamble 要丟掉」是 **changelog 自己的格式知識**, * 不是文件站的知識 ⇒ 解析住在 daemon 這棵樹裡,文件站只負責把結果貼上去。 * 方向仍然單向:**根(docs-site)往內伸手拿,collector 不往外伸手。** * * 丟掉的:檔頭那段給維護者看的說明(含「怎麼出新版」與那個 `<二級標題>` 範例)、 * 以及任何還沒戳版號的段落。留下的:每一個 `## vX.Y.Z(日期)` 段的原文,一字不改。 * * @param {string} changelogText changelog 全文 * @returns {{markdown: string, versions: string[]}} markdown=只剩已發佈段落;versions=由新到舊 */ export function releasedSections(changelogText) { const lines = String(changelogText).split('\n'); const kept = []; const versions = []; let inside = false; for (const l of lines) { // 只有二級標題會切換「現在在不在一個已發佈段落裡」。 // `### 小標` 不會(`^##\s` 要求第三個字是空白),所以段落內的結構完整保留。 if (/^##\s/.test(l)) { const m = l.match(RELEASED_HEADING); inside = Boolean(m); if (m) versions.push(m[1]); } if (inside) kept.push(l); } return { markdown: kept.join('\n').trim(), versions }; } /** * 同 `releasedSections()`,但直接讀檔,且**問不出東西就 throw**。 * * 🔴 為什麼是 throw 不是回空字串:這支的呼叫端是文件站的建置。 * 回空字串=建置成功、頁面少了整條桌面版版本歷史、**沒有任何人會發現**—— * 那正是 D95 第二輪抓到的那個形狀(出貨線問錯檔案就整站安靜跳過)。 * 壞掉要當場停,不要安靜地產出一個少一半的頁面。 */ export function releasedSectionsFromFile(changelogPath = CHANGELOG_PATH) { if (!existsSync(changelogPath)) { throw new Error(`找不到 daemon 的版本說明檔:${changelogPath}(單一真相源=collector/CHANGELOG.md)`); } const r = releasedSections(readFileSync(changelogPath, 'utf8')); if (!r.versions.length) { throw new Error( `${changelogPath} 裡沒有任何**已發佈**的版本段(\`## vX.Y.Z(日期)\`)。\n` + ' → 若只有「下一版(未發佈)」,先跑 `daemon-version.py --stamp` 戳成正式版號。'); } return { ...r, path: changelogPath }; } /** * 從 changelog 取某一版的「一句話摘要」清單。 * 規則:只認**頂層條目**(行首 `- `)的第一個粗體片段——那就是該條的標題。 * 沒有粗體的條目退而取整行(截短),因為「有寫總比漏掉好」。 */ export function headlinesFor(changelogText, version) { const lines = changelogText.split('\n'); const start = lines.findIndex((l) => new RegExp(`^##\\s+${version.replace(/[.\\]/g, '\\$&')}(\\D|$)`).test(l.trim())); if (start < 0) return null; const out = []; for (let i = start + 1; i < lines.length; i++) { const l = lines[i]; if (/^##\s/.test(l)) break; // 下一版開始 if (!/^-\s/.test(l)) continue; // 只取頂層條目(續行、巢狀一律略過) const body = l.replace(/^-\s*/, ''); const bold = body.match(/\*\*([^*]+)\*\*/); let h = stripMarkdown(bold ? bold[1] : body); h = h.replace(/^[^\p{L}\p{N}「((]+/u, ''); // 去掉開頭的 emoji/符號 h = h.replace(/[::,,。.]+$/, ''); // 去掉尾標點(要串接) if (h) out.push(h); } return out; } /** * 組成畫面上那一行。回傳 null=changelog 裡沒有這一版(呼叫端該當成錯誤)。 * `changelogPath` 只給測試/薄殼覆寫用;正常呼叫不帶,走 collector 自己的 CHANGELOG.md。 */ export function notesForVersion(version, changelogPath = CHANGELOG_PATH) { if (!existsSync(changelogPath)) return null; const heads = headlinesFor(readFileSync(changelogPath, 'utf8'), version); if (!heads || !heads.length) return null; let line = heads.join('・'); if (line.length > NOTES_MAX) { // 截到「最後一個完整條目」為止,再掛尾巴——不要把句子切一半給使用者看。 const kept = []; for (const h of heads) { if ([...kept, h].join('・').length + TAIL.length > NOTES_MAX) break; kept.push(h); } line = (kept.length ? kept.join('・') : heads[0].slice(0, NOTES_MAX - TAIL.length)) + TAIL; } return line; } /** * 機械閘用:這一行本身可不可以送到使用者眼前? * 回傳問題清單(空=通過)。這道閘存在的理由=**手寫的那一行沒有任何人檢查**。 */ export function checkNotes(notes) { const problems = []; const s = String(notes ?? ''); if (!s.trim()) return ['manifest.daemon.notes 是空的——使用者按「檢查更新」看不到這版改了什麼']; if (/[*_`#]|\]\(/.test(s)) { problems.push(`manifest.daemon.notes 裡有 markdown 符號,畫面是純文字會原樣露出來:${JSON.stringify(s.slice(0, 60))}`); } if (/\n/.test(s)) { problems.push('manifest.daemon.notes 有換行——畫面不保留換行(.d 沒有 white-space:pre),會擠成一坨'); } if (s.length > NOTES_MAX + TAIL.length) { problems.push(`manifest.daemon.notes 太長(${s.length} 字,上限 ${NOTES_MAX + TAIL.length})——leo 08-08:「不要這麼長的散文,簡短講改了什麼,細節去 docs 讀」`); } return problems; } // CLI:印出某版會顯示的那一行(出貨前想先看一眼時用) // 🔴 2026-08-18:判斷「是不是直接跑本檔」要比**絕對路徑**,不能比檔名尾綴—— // `installer/scripts/daemon-notes.mjs` 薄殼同名,用尾綴比會讓兩支 CLI 一起開火 // (實撞:問雲端版號時本檔先 process.exit(1),薄殼根本沒機會查 docs-site)。 if (process.argv[1] && resolve(process.argv[1]) === import.meta.filename) { const v = process.argv[2]; if (!v) { console.error('用法:node collector/cmd/arcrun-app/daemon-notes.mjs <版本,例 v0.18.24>'); process.exit(2); } const line = notesForVersion(v); if (!line) { console.error(`❌ changelog 沒有 ${v} 這一版(${CHANGELOG_PATH})`); process.exit(1); } // stdout **只有那一行**——它會被別的腳本(changelog-section.sh)直接取用, // 多印一個字就會被塞進 manifest。其餘一律走 stderr。 console.log(line); console.error(`(${line.length} 字)`); const probs = checkNotes(line); if (probs.length) { probs.forEach((p) => console.error('❌ ' + p)); process.exit(1); } console.error('✅ 可以送到使用者眼前'); }