From 00ccec53f243bdcd995cd5d01b6b57d88d805a40 Mon Sep 17 00:00:00 2001 From: richblack Date: Tue, 18 Aug 2026 14:00:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(docs):=20=E6=A1=8C=E9=9D=A2=E7=89=88?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=E9=87=8D=E6=96=B0=E9=95=B7=E5=9B=9E?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AB=99=E2=80=94=E2=80=94=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E4=B8=8D=E8=A4=87=E8=A3=BD=EF=BC=88D95=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E8=BC=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/arcrun-app/.gitignore | 4 + .../daemon-version.cpython-314.pyc | Bin 13107 -> 0 bytes cmd/arcrun-app/daemon-notes.mjs | 64 ++++++++++++++ cmd/arcrun-app/daemon-notes.test.mjs | 80 ++++++++++++++++++ 4 files changed, 148 insertions(+) delete mode 100644 cmd/arcrun-app/__pycache__/daemon-version.cpython-314.pyc create mode 100644 cmd/arcrun-app/daemon-notes.test.mjs diff --git a/cmd/arcrun-app/.gitignore b/cmd/arcrun-app/.gitignore index de0474b..4a24edc 100644 --- a/cmd/arcrun-app/.gitignore +++ b/cmd/arcrun-app/.gitignore @@ -12,3 +12,7 @@ dist-msix/root/ # 進版控只會讓 repo 膨脹,且每次重編都變動、每次都來吵未推警察。 dist/ dist-msix/ + +# Python 匯入快取(daemon-version.py 被 import 時產生)。可重現的產物,且綁 Python 版本 +# (檔名帶 cpython-314)⇒ 不進版控。2026-08-18 D95 第三輪:第一輪搬檔時誤把一顆帶了進來。 +__pycache__/ diff --git a/cmd/arcrun-app/__pycache__/daemon-version.cpython-314.pyc b/cmd/arcrun-app/__pycache__/daemon-version.cpython-314.pyc deleted file mode 100644 index b17b4c5ac06dd685cc3d3964446fc7ebf7d42eac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13107 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 cX5WYRR%+d~Px# diff --git a/cmd/arcrun-app/daemon-notes.mjs b/cmd/arcrun-app/daemon-notes.mjs index 027df39..fc306a0 100644 --- a/cmd/arcrun-app/daemon-notes.mjs +++ b/cmd/arcrun-app/daemon-notes.mjs @@ -49,6 +49,70 @@ export function stripMarkdown(s) { .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 取某一版的「一句話摘要」清單。 * 規則:只認**頂層條目**(行首 `- `)的第一個粗體片段——那就是該條的標題。 diff --git a/cmd/arcrun-app/daemon-notes.test.mjs b/cmd/arcrun-app/daemon-notes.test.mjs new file mode 100644 index 0000000..7614786 --- /dev/null +++ b/cmd/arcrun-app/daemon-notes.test.mjs @@ -0,0 +1,80 @@ +/** + * daemon-notes.test.mjs — `releasedSections()` 的演練(D95 第三輪) + * + * 這支存在的理由:`releasedSections()` 的產出**會直接變成使用者讀的那一頁** + * (`rag.arcrun.dev/docs/help/changelog/`)。它多切一段、少切一段、或把給維護者看的 + * preamble 漏出去,都是使用者當場看得到的錯,而建置不會報任何錯。 + * + * 跑法:node --test collector/cmd/arcrun-app/daemon-notes.test.mjs + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { releasedSections, releasedSectionsFromFile, CHANGELOG_PATH } from './daemon-notes.mjs'; + +const FIXTURE = [ + '# Arcrun 桌面版(daemon)版本說明', + '', + '> 給維護者看的說明,不該出現在網站上。', + '', + '## 怎麼出新版(不要手打版本號)', + '', + '在本檔最上面加一個二級標題:', + '', + ' <二級標題> 下一版(未發佈)', + '', + '---', + '', + '## 下一版(未發佈)', + '', + '- 🔑 **還沒戳版號的東西**:不該出現在網站上。', + '', + '## v0.18.29(2026-08-16)', + '', + '**建議更新**', + '', + '- 🔑 **甲**:內文甲。', + '', + '### 小標題也要留著', + '', + '- 乙', + '', + '## v0.18.28(2026-08-16)', + '', + '- 丙', + '', +].join('\n'); + +test('只留已發佈段落:preamble 與「下一版(未發佈)」都不會被投影出去', () => { + const { markdown, versions } = releasedSections(FIXTURE); + assert.deepEqual(versions, ['v0.18.29', 'v0.18.28']); + for (const forbidden of ['給維護者看的說明', '怎麼出新版', '<二級標題>', '下一版(未發佈)', '還沒戳版號']) { + assert.ok(!markdown.includes(forbidden), `不該出現在使用者眼前:${forbidden}`); + } + assert.ok(markdown.startsWith('## v0.18.29(2026-08-16)'), '第一行就該是最新的已發佈版本'); +}); + +test('段落內文原樣保留,`###` 小標題不會被當成段落結束', () => { + const { markdown } = releasedSections(FIXTURE); + assert.ok(markdown.includes('### 小標題也要留著')); + assert.ok(markdown.includes('- 乙'), '`###` 之後的內容仍屬於同一個版本段'); + assert.ok(markdown.includes('**建議更新**') && markdown.includes('- 丙')); +}); + +test('一段都沒有就 throw——不准安靜回空字串(那會建出一頁少一半的東西)', () => { + assert.throws( + () => releasedSectionsFromFile('/nonexistent/CHANGELOG.md'), + /找不到 daemon 的版本說明檔/); + // 只有未發佈段落時同樣要停 + const onlyUnreleased = '# 標題\n\n## 下一版(未發佈)\n\n- 甲\n'; + assert.deepEqual(releasedSections(onlyUnreleased).versions, []); +}); + +test('對真的 collector/CHANGELOG.md 跑一次:段數=檔案裡 `## vX.Y.Z` 的行數', () => { + const raw = readFileSync(CHANGELOG_PATH, 'utf8'); + const expected = raw.split('\n').filter((l) => /^##\s+v\d+\.\d+\.\d+/.test(l)).length; + const { versions, markdown } = releasedSectionsFromFile(); + assert.equal(versions.length, expected); + assert.ok(expected > 0, '真檔裡至少要有一個已發佈版本'); + assert.ok(!markdown.includes('單一真相源'), '檔頭那段維護者說明不該被投影出去'); +});