feat(議題解析): 把議題 body 的 markdown 解析抽成純函式
段落切分、列表、兩欄表格三種解析,需求議題與工作包議題共用同一套, 所以獨立成一支不碰網路也不碰檔案系統的模組,而不是塞進 lib。 段落切分會追蹤圍欄狀態:mermaid 或程式碼區塊裡的井字號不得被當成標題, 否則流程圖一畫,後面的段落就全被切碎。 缺段落回傳空值而非報錯——缺段落是模板的正常變體,不是解析失敗。 名詞表以分隔列為界,之後才是資料列,避免把表頭當成一筆名詞。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 議題 body 的 markdown 解析。
|
||||||
|
*
|
||||||
|
* 純函式,不碰網路也不碰檔案系統:抽取類腳本的解析全部走這裡,
|
||||||
|
* 解析規則只寫一次,需求議題與工作包議題共用同一套。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依 `## 標題` 切出各段落。
|
||||||
|
* 圍欄區塊(```)內的內容一律不當成標題,否則 mermaid 或程式碼裡的井字號會把段落切碎。
|
||||||
|
* @param {string} body 議題 body
|
||||||
|
* @returns {Map<string, string>} 段落名稱 → 該段內容(前後空白已修掉)
|
||||||
|
*/
|
||||||
|
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<string, string>} sections
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function textSection(sections, name) {
|
||||||
|
return sections.get(name) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出列表型段落的每一項。符號清單與編號清單一視同仁,
|
||||||
|
* checkbox 只留文字不留標記 —— 需求議題的驗收標準不追蹤勾選狀態。
|
||||||
|
* @param {Map<string, string>} 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<string, string>} 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 }));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user