From 849d90f2081f0e0ff95c441268dfa9b476b3cc4b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 12:55:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AD=B0=E9=A1=8C=E8=A7=A3=E6=9E=90):=20?= =?UTF-8?q?=E6=8A=8A=E8=AD=B0=E9=A1=8C=20body=20=E7=9A=84=20markdown=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=8A=BD=E6=88=90=E7=B4=94=E5=87=BD=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 段落切分、列表、兩欄表格三種解析,需求議題與工作包議題共用同一套, 所以獨立成一支不碰網路也不碰檔案系統的模組,而不是塞進 lib。 段落切分會追蹤圍欄狀態:mermaid 或程式碼區塊裡的井字號不得被當成標題, 否則流程圖一畫,後面的段落就全被切碎。 缺段落回傳空值而非報錯——缺段落是模板的正常變體,不是解析失敗。 名詞表以分隔列為界,之後才是資料列,避免把表頭當成一筆名詞。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-body.js | 94 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 scripts/issue-body.js diff --git a/scripts/issue-body.js b/scripts/issue-body.js new file mode 100644 index 0000000..241d655 --- /dev/null +++ b/scripts/issue-body.js @@ -0,0 +1,94 @@ +/** + * 議題 body 的 markdown 解析。 + * + * 純函式,不碰網路也不碰檔案系統:抽取類腳本的解析全部走這裡, + * 解析規則只寫一次,需求議題與工作包議題共用同一套。 + */ + +/** + * 依 `## 標題` 切出各段落。 + * 圍欄區塊(```)內的內容一律不當成標題,否則 mermaid 或程式碼裡的井字號會把段落切碎。 + * @param {string} body 議題 body + * @returns {Map} 段落名稱 → 該段內容(前後空白已修掉) + */ +export function parseSections(body) { + const sections = new Map(); + const buffer = []; + let current = null; + let inFence = false; + + const flush = () => { + if (current !== null) sections.set(current, buffer.join('\n').trim()); + buffer.length = 0; + }; + + for (const line of (body ?? '').split('\n')) { + if (/^\s*```/.test(line)) inFence = !inFence; + + const heading = inFence ? null : line.match(/^##\s+(.+?)\s*$/); + if (heading) { + flush(); + current = heading[1]; + continue; + } + if (current !== null) buffer.push(line); + } + flush(); + + return sections; +} + +/** + * 取出文字型段落的整段內容。段落不存在時回空字串,不報錯 —— + * 缺段落是模板的正常變體,不是解析失敗。 + * @param {Map} sections + * @param {string} name + * @returns {string} + */ +export function textSection(sections, name) { + return sections.get(name) ?? ''; +} + +/** + * 取出列表型段落的每一項。符號清單與編號清單一視同仁, + * checkbox 只留文字不留標記 —— 需求議題的驗收標準不追蹤勾選狀態。 + * @param {Map} sections + * @param {string} name + * @returns {string[]} + */ +export function listSection(sections, name) { + const content = sections.get(name); + if (!content) return []; + + return content + .split('\n') + .map((line) => line.match(/^\s*(?:[-*+]|\d+\.)\s+(.*)$/)) + .filter((match) => match !== null) + .map((match) => match[1].replace(/^\[[ xX]\]\s*/, '').trim()) + .filter((text) => text !== ''); +} + +/** + * 取出兩欄表格型段落。以分隔列(|---|---|)為界,之後才是資料列; + * 沒有分隔列就當成沒有資料,避免把表頭當成一筆名詞。 + * @param {Map} sections + * @param {string} name + * @returns {{term: string, def: string}[]} + */ +export function tableSection(sections, name) { + const content = sections.get(name); + if (!content) return []; + + const rows = content + .split('\n') + .filter((line) => line.trim().startsWith('|')) + .map((line) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim())); + + const separator = rows.findIndex((cells) => cells.every((cell) => /^:?-+:?$/.test(cell))); + if (separator === -1) return []; + + return rows + .slice(separator + 1) + .filter((cells) => cells.length >= 2 && cells.some((cell) => cell !== '')) + .map(([term, def]) => ({ term, def })); +}