Skip to content

Commit 36b5fff

Browse files
committed
fix: render unparsed task detail content
Taskr: 2026-05-14-render-unparsed-detail-content
1 parent 8308bca commit 36b5fff

7 files changed

Lines changed: 122 additions & 10 deletions

File tree

src/board-client.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ export const boardClientScript = ` let model = window.__TASKR_BOARD__;
112112
"Implementation Plan": "Implementation Plan",
113113
"Progress Log": "Progress Log",
114114
"Agent Notes": "Agent Notes",
115-
"Completion Summary": "Completion Summary"
115+
"Completion Summary": "Completion Summary",
116+
"Unparsed Content": "Additional content"
116117
},
117118
actions: {
118119
edit: "Edit",
@@ -232,7 +233,8 @@ export const boardClientScript = ` let model = window.__TASKR_BOARD__;
232233
"Implementation Plan": "实现计划",
233234
"Progress Log": "进度日志",
234235
"Agent Notes": "代理备注",
235-
"Completion Summary": "完成总结"
236+
"Completion Summary": "完成总结",
237+
"Unparsed Content": "其他内容"
236238
},
237239
actions: {
238240
edit: "编辑",
@@ -644,17 +646,20 @@ export const boardClientScript = ` let model = window.__TASKR_BOARD__;
644646
fragment.append(commitPanel(task));
645647
}
646648
647-
for (const name of detailSectionNames(task)) {
648-
fragment.append(section(name, task.sections[name] || t("empty")));
649+
for (const name of coreSectionNames()) {
650+
if (Object.prototype.hasOwnProperty.call(task.sections || {}, name)) {
651+
fragment.append(section(name, task.sections[name] || t("empty")));
652+
}
653+
}
654+
if (task.unsectionedBody && task.unsectionedBody.trim()) {
655+
fragment.append(section("Unparsed Content", task.unsectionedBody));
649656
}
650657
fragment.append(dangerZone(task));
651658
return fragment;
652659
}
653660
654-
function detailSectionNames(task) {
655-
const coreSections = ["Request", "Acceptance Criteria", "Implementation Plan", "Progress Log", "Agent Notes", "Completion Summary"];
656-
const extraSections = Object.keys(task.sections || {}).filter((name) => !coreSections.includes(name));
657-
return [...coreSections, ...extraSections];
661+
function coreSectionNames() {
662+
return ["Request", "Acceptance Criteria", "Implementation Plan", "Progress Log", "Agent Notes", "Completion Summary"];
658663
}
659664
660665
function dangerZone(task) {

src/board-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface BoardTask {
1212
commitDetails: BoardCommitDetail[];
1313
verification: unknown;
1414
sections: Record<string, string>;
15+
unsectionedBody: string;
1516
criteria: {
1617
checked: number;
1718
total: number;

src/board.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { renderBoardHtml } from "./board-template.js";
44
import {
55
deleteTask,
66
extractSections,
7+
extractUnsectionedBody,
78
listTasks,
89
loadTaskById,
910
normalizeCommitIds,
@@ -219,6 +220,7 @@ function boardTask(document: TaskDocument, repoRoot: string): BoardTask {
219220
commitDetails: commits.map((commit) => commitDetail(repoRoot, commit)),
220221
verification: document.metadata.verification ?? null,
221222
sections,
223+
unsectionedBody: extractUnsectionedBody(document.body),
222224
criteria: countCriteria(sections["Acceptance Criteria"] ?? ""),
223225
};
224226
}

src/markdown.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ export function renderMarkdownHtml(markdown: string): string {
1212
continue;
1313
}
1414

15+
const heading = headingItem(line);
16+
if (heading) {
17+
blocks.push(`<h${heading.level}>${renderInline(heading.text)}</h${heading.level}>`);
18+
index += 1;
19+
continue;
20+
}
21+
1522
// Detect standalone HTML fragment lines (lines that start with an HTML tag) and pass them through safely
1623
if (HTML_FRAGMENT_PATTERN.test(line)) {
1724
// Collect multi-line HTML blocks by looking for continuation lines
@@ -83,7 +90,8 @@ export function renderMarkdownHtml(markdown: string): string {
8390
lines[index].trim() !== "" &&
8491
!todoItem(lines[index]) &&
8592
!unorderedItem(lines[index]) &&
86-
!orderedItem(lines[index])
93+
!orderedItem(lines[index]) &&
94+
!headingItem(lines[index])
8795
) {
8896
paragraph.push(lines[index]);
8997
index += 1;
@@ -108,6 +116,7 @@ ${renderInline.toString()}
108116
${todoItem.toString()}
109117
${unorderedItem.toString()}
110118
${orderedItem.toString()}
119+
${headingItem.toString()}
111120
${sanitizeHtml.toString()}
112121
${escapeHtml.toString()}
113122
window.renderTaskrMarkdown = renderMarkdownHtml;
@@ -164,6 +173,15 @@ function orderedItem(line: string): string | null {
164173
return match ? match[1] : null;
165174
}
166175

176+
function headingItem(line: string): { level: number; text: string } | null {
177+
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
178+
if (!match) return null;
179+
return {
180+
level: match[1].length,
181+
text: match[2],
182+
};
183+
}
184+
167185
// Allowed HTML tags for task markdown fragments
168186
const ALLOWED_TAGS = new Set([
169187
"section",

src/protocol.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,45 @@ export function extractSections(body: string): Record<string, string> {
465465
return sections;
466466
}
467467

468+
export function extractUnsectionedBody(
469+
body: string,
470+
sectionNames: readonly string[] = REQUIRED_SECTIONS,
471+
): string {
472+
const knownSections = new Set(sectionNames);
473+
const matches = [...body.matchAll(SECTION_RE)];
474+
if (matches.length === 0) {
475+
return stripLeadingDocumentTitle(body).trim();
476+
}
477+
478+
const chunks: string[] = [];
479+
const preambleEnd = matches[0].index ?? 0;
480+
const preamble = stripLeadingDocumentTitle(body.slice(0, preambleEnd)).trim();
481+
if (preamble) {
482+
chunks.push(preamble);
483+
}
484+
485+
for (let index = 0; index < matches.length; index += 1) {
486+
const match = matches[index];
487+
const title = match[1].trim();
488+
if (knownSections.has(title)) {
489+
continue;
490+
}
491+
const start = match.index ?? 0;
492+
const end =
493+
index + 1 < matches.length ? (matches[index + 1].index ?? body.length) : body.length;
494+
const chunk = body.slice(start, end).trim();
495+
if (chunk) {
496+
chunks.push(chunk);
497+
}
498+
}
499+
500+
return chunks.join("\n\n").trim();
501+
}
502+
503+
function stripLeadingDocumentTitle(body: string): string {
504+
return body.replace(/^\s*# [^\n]*(?:\n+|$)/, "");
505+
}
506+
468507
export function replaceSection(body: string, section: string, content: string): string {
469508
const matches = [...body.matchAll(SECTION_RE)];
470509
for (let index = 0; index < matches.length; index += 1) {

tests/markdown.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ describe("Taskr Markdown renderer", () => {
3333
expect(html).toContain("<ol><li>First</li><li>Second</li></ol>");
3434
});
3535

36+
it("renders Markdown headings", () => {
37+
const html = renderMarkdownHtml("## Test HTML Fragments");
38+
39+
expect(html).toBe("<h2>Test HTML Fragments</h2>");
40+
});
41+
3642
it("emits a browser script with the renderer entrypoint", () => {
3743
const script = markdownBrowserScript();
3844

tests/protocol.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
defaultTaskId,
1414
addNote,
1515
extractSections,
16+
extractUnsectionedBody,
1617
initProtocol,
1718
loadTask,
1819
slugify,
@@ -108,6 +109,45 @@ describe("Taskr protocol", () => {
108109
expect(sections["Progress Log"]).toBe("Empty.");
109110
});
110111

112+
it("keeps content outside protocol sections available for detail rendering", () => {
113+
const body = [
114+
"# Task title already shown in detail header",
115+
"",
116+
"Intro note before sections.",
117+
"",
118+
"## Request",
119+
"",
120+
"Do the work.",
121+
"",
122+
"## Test HTML Fragments",
123+
"",
124+
'<section class="demo">',
125+
" <h3>Rendered</h3>",
126+
"</section>",
127+
"",
128+
"## Acceptance Criteria",
129+
"",
130+
"- [ ] It works.",
131+
].join("\n");
132+
133+
expect(extractSections(body)).toMatchObject({
134+
Request: "Do the work.",
135+
"Acceptance Criteria": "- [ ] It works.",
136+
"Test HTML Fragments": '<section class="demo">\n <h3>Rendered</h3>\n</section>',
137+
});
138+
expect(extractUnsectionedBody(body)).toBe(
139+
[
140+
"Intro note before sections.",
141+
"",
142+
"## Test HTML Fragments",
143+
"",
144+
'<section class="demo">',
145+
" <h3>Rendered</h3>",
146+
"</section>",
147+
].join("\n"),
148+
);
149+
});
150+
111151
it("creates opt-in research report files and records task references", () => {
112152
const repo = tempRepo();
113153
initProtocol(repo);
@@ -215,6 +255,7 @@ describe("Taskr protocol", () => {
215255
expect(
216256
model.tasks.find((task) => task.id === "implement-board-visualization")?.sections.Request,
217257
).toContain("Implement board visualization");
258+
expect(model.tasks[0].unsectionedBody).toBe("");
218259
expect(model.tasks[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
219260
expect(html).toContain("Taskr Board");
220261
expect(html).toContain(boardStyles);
@@ -267,7 +308,7 @@ describe("Taskr protocol", () => {
267308
expect(html).toContain("commitStatusLabel");
268309
expect(html).toContain("renderTaskrMarkdown");
269310
expect(html).toContain("markdown-content");
270-
expect(html).toContain("detailSectionNames");
311+
expect(html).toContain("coreSectionNames");
271312
expect(html).toContain("grid-template-columns: auto minmax(0, 1fr);");
272313
expect(html).toContain("appearance: none;");
273314
expect(html).toContain("border-color: rgba(34, 197, 94, 0.72);");

0 commit comments

Comments
 (0)