diff --git a/scripts/issue-body.js b/scripts/issue-body.js index c0f63cb..e6d5119 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -102,17 +102,111 @@ export function listSection(sections, name) { } /** - * 取出兩欄表格型段落。以分隔列(|---|---|)為界,之後才是資料列; - * 沒有分隔列就當成沒有資料,避免把表頭當成一筆名詞。 + * 取出巢狀的待辦清單:上層是待辦,縮排一層是該項自己的驗收。 * - * 只處理兩欄:多出來的欄會被丟掉。目前唯一的使用者是需求議題的領域名詞表。 - * 工作包的介面契約是四欄(介面/產出者/消費者/形狀),wp-extract 需要另一個 - * 保留全部欄位的版本,不能直接沿用這一支。 + * 只有這一支收 body 而不收切好的段落,因為它要交出 `raw`——下游靠 `raw` 在整份 body 上 + * 做精確字串替換來勾選 checkbox,那一行必須逐字等於 body 裡的原樣, + * 連縮排、行尾空白與 \r 都不能動。段落切分會修掉前後空白,拿不到這種保證。 + * + * 兩種畸形寫法都不丟內容,寧可放在稍微不對的位置也不要靜靜消失: + * - 巢狀超過一層 → 攤進所在待辦的驗收 + * - 還沒有上層待辦就先出現縮排項目 → 升格成待辦 + * + * @param {string} body 議題 body + * @param {string} section 段落名稱,例如 '待辦' + * @returns {{text: string, done: boolean, raw: string, 驗收: {text: string, done: boolean, raw: string}[]}[]} + */ +export function checklistInSection(body, section) { + const rows = [...eachLine(body)]; + const { start, end } = sectionBounds(rows, section); + if (start === -1) return []; + + const todos = []; + let topIndent = null; + + for (let i = start + 1; i < end; i += 1) { + if (rows[i].inFence) continue; + const item = parseChecklistItem(rows[i].line); + if (!item) continue; + + const nested = topIndent !== null && todos.length > 0 && item.indent > topIndent; + if (nested) { + todos.at(-1).驗收.push(item.value); + continue; + } + // 比目前認定的上層還淺時,把上層改認成更淺的那一層:第一項剛好縮排時, + // 後面出現的真正上層才不會被當成它的驗收。 + topIndent = topIndent === null ? item.indent : Math.min(topIndent, item.indent); + todos.push({ ...item.value, 驗收: [] }); + } + return todos; +} + +/** + * 拆一行清單項。符號清單與編號清單一視同仁,checkbox 可有可無—— + * 忘了寫 checkbox 的項目仍是一項待辦,只是 done 為 false。 + * @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null} + */ +function parseChecklistItem(line) { + // [\s\S] 而非 . 的理由同 listSection:CRLF 的 body 行尾有 \r,. 不吃它。 + // text 靠 trim 修掉 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。 + const item = line.match(/^(\s*)(?:[-*+]|\d+\.)\s+([\s\S]*)$/); + if (!item) return null; + + const box = item[2].match(/^\[([ xX])\]\s*([\s\S]*)$/); + const text = (box ? box[2] : item[2]).trim(); + if (text === '') return null; + + return { + indent: item[1].length, + value: { text, done: box ? box[1].toLowerCase() === 'x' : false, raw: line }, + }; +} + +/** + * 在段落裡找出「標籤:#編號」那一行的編號,例如關聯段落的 `需求議題:#7`。 + * 全形與半形冒號都認;找不到回 null——沒填不是解析失敗。 + * @param {Map} sections + * @param {string} name 段落名稱 + * @param {string} label 標籤,例如 '需求議題' + * @returns {number|null} + */ +export function referencedIndex(sections, name, label) { + for (const { line, inFence } of eachLine(sections.get(name))) { + if (inFence) continue; + const at = line.indexOf(label); + if (at === -1) continue; + + const value = line.slice(at + label.length).match(/^\s*[::]\s*#?(\d+)/); + if (value) return Number(value[1]); + } + return null; +} + +/** + * 取出兩欄表格型段落,欄位固定命名為 term 與 def。 + * 需求議題的領域名詞表用它;四欄的介面契約請用 tableRows。 * @param {Map} sections * @param {string} name * @returns {{term: string, def: string}[]} */ export function tableSection(sections, name) { + return tableRows(sections, name, ['term', 'def']); +} + +/** + * 取出表格型段落的資料列,欄位依 columns 命名。以分隔列(|---|---|)為界, + * 之後才是資料列;沒有分隔列就當成沒有資料,避免把表頭當成一筆資料。 + * + * 資料列比 columns 短時補空字串而不是讓欄位消失——下游拿到的形狀要固定, + * 少一欄是內容的問題,不該變成「欄位不存在」讓下游多寫一種分支。 + * + * @param {Map} sections + * @param {string} name + * @param {string[]} columns 由左到右的欄位名稱;多出來的欄會被丟掉 + * @returns {Record[]} + */ +export function tableRows(sections, name, columns) { const rows = []; for (const { line, inFence } of eachLine(sections.get(name))) { @@ -125,10 +219,10 @@ export function tableSection(sections, name) { return rows .slice(separator + 1) - // 同一段落裡若不慎貼了第二張表,它的分隔列不該變成一筆 {term:'---'} + // 同一段落裡若不慎貼了第二張表,它的分隔列不該變成一筆資料 .filter((cells) => !isSeparator(cells)) - .filter((cells) => cells.length >= 2 && cells.some((cell) => cell !== '')) - .map(([term, def]) => ({ term, def })); + .filter((cells) => cells.some((cell) => cell !== '')) + .map((cells) => Object.fromEntries(columns.map((column, i) => [column, cells[i] ?? '']))); } /** 以未被逸脫的直線切欄,再把 `\|` 還原成內容裡的直線 */