Files
arcrun-collector/cmd/arcrun-app/daemon-notes.mjs
T
Leo 00ccec53f2 fix(docs): 桌面版新版本重新長回文件站——投影不複製(D95 第三輪)
D95 第一輪把 daemon 的 changelog 搬進 collector/ 讓 collector/ 自足,
但文件站的建置沒有任何一步會讀那個新位置 ⇒ 從那一刻起,桌面版的新版本
再也上不了 rag.arcrun.dev/docs/help/changelog/(匿名實測:線上 v0.18.28
有 4 次、v0.18.29 零次;v0.18.29 本來就只到 stage,所以這是未來式的退化,
不是已經在傷害用戶)。

修法:建置時投影,不複製、不改成只給連結。
  collector/CHANGELOG.md
    → collector/cmd/arcrun-app/daemon-notes.mjs 的 releasedSections()(切出已發佈段落)
    → docs-site/remark-daemon-changelog.mjs(remark 階段換掉佔位符)
    → /docs/help/changelog/(網址不變、頁面不變、docs-site 裡零副本)

「哪些段落算已發佈」住在 collector/ 那棵樹——那是 changelog 自己的格式知識,
不是文件站的。方向仍單向:根往內伸手拿,collector 不往外伸手。

順手修掉施工中發現的兩個沉默失敗:
  · Astro 的內容快取只認那一頁自己的檔案有沒有變 ⇒ 只改 collector/CHANGELOG.md
    會建出「成功但內容是舊的」頁面(實測:不加 --force 新段落 0 次命中,加了 4 次)。
    docs-site 的 build 因此帶 --force,理由寫在 package.json 的 //build 與 plugin 檔頭。
  · 佔位符被刪掉時原本會安靜地少掉整條版本歷史 ⇒ 改成當場 throw 並指名修法。

另:第一輪誤把一顆 __pycache__/*.pyc 帶進版控,移出追蹤並補 .gitignore。
(同目錄那些 20MB/11MB 的執行檔與 dmg 是 6b44784/e09f866/c6d7b2c 的舊帳,這輪不動。)

實測
  · 本機建置:v0.18.29 出現 6 次(本輪前為 0);daemon 30 段+雲端 17 段,
    與 D95 之前那一頁的 30/17 完全一致
  · 來源證明:docs-site/src 裡 `^## v0.` 零筆;在 collector/CHANGELOG.md 頂端塞
    v9.9.9 重建 → 頁面命中 4 次,移除後回復
  · 網址:dist/help/changelog/index.html → deploy/docs/…(rsync 鏡射)路徑與本輪前相同;
    線上 /docs/help/changelog 307 → /docs/help/changelog/ 200(平台行為,本輪未動路由)
  · 建置輸出會印「[daemon-changelog] 投影 30 段(v0.18.29 … v0.15.4)|來源 …」
    ——這道閘說得出它實際投影了什麼
  · node --test:installer/scripts/*.test.mjs 與新增的 daemon-notes.test.mjs 共 238 支全綠

Refs: inkstone/InkStoneCo#40

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:00:32 +08:00

199 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* daemon-notes.mjs — 使用者在「版本與更新」畫面看到的那一行,**由 changelog 機械導出**
*
* ── 這支解什麼病(leo 2026-08-08 真機看到 v0.18.24 的更新畫面)────────────
* leo 原話:「**不要這麼長的散文,簡短講改了什麼,細節去 docs 讀。**」
*
* 他看到的是**一整面文字牆**,而且 `**粗體**` 原樣露在畫面上。
* 真兇不是文案沒寫好,是**出貨當下靠人手工排版**:
* 出貨時用一段臨時 python 把 changelog 的換行折掉塞進 `manifest.daemon.notes`
* 於是四條變成一大段;那段轉換每次出貨都要重寫一次,而且**沒人檢查結果長什麼樣**。
*
* ⇒ 與「版本號由內容算」同一種解法:**這一行也由單一真相源導出,不由人當場捏**。
*
* ── 為什麼是「只取粗體標題、串成一行」──────────────────────────────────
* 畫面那個欄位是**純文字**`main.js:215` 是 `<div class="d">${esc(u.notes)}</div>`
* ① `esc()` ⇒ 任何 markdown 符號都會原樣露出來(leo 看到的 `**` 就是這樣來的)
* ② HTML 不保留換行(`.d` 沒有 white-space:pre)⇒ 塞 `\n` 進去也**不會**變成分行
* ⇒ 唯一能讀的形狀就是**一行短句**。而 changelog 每條的 `**粗體標題**` 本來就是
* 那條的一句話摘要——直接拿它,不必另外維護第二份文案(第二份必然漂移)。
*
* ── 🔴 2026-08-18D95 第一輪):搬進 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') // → 一行字,或 nullchangelog 沒這版)
*/
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.292026-08-16`。
* 待發佈的那一段標題是「下一版(未發佈)」,**不符合這個形狀** ⇒ 天然不會被投影出去。
* 這是刻意的:投影器不必另外認一次「未發佈」,少一條要記得的規則。
*/
const RELEASED_HEADING = /^##\s+(v\d+\.\d+\.\d+)(?=\D|$)/;
/**
* 把 changelog 切成「只剩已發佈段落」的 markdown。**給文件站投影用。**
*
* ── 為什麼這件事住在 collector/2026-08-18D95 第三輪)─────────────────
* 文件站要在建置時把桌面版的更新內容接回 `/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('✅ 可以送到使用者眼前');
}