Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/core/task-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ const BLOCK_RE = /<!-- ats:context -->\r?\n```ats\r?\n([\s\S]*?)\r?\n```\r?\n<!-
// The machine block keeps only intent/lifecycle/security/hierarchy.
const RELATED_HEADING = '## Related';
const RELATED_SECTION_RE = /(?:^|\n)##\s+Related[ \t]*\n([\s\S]*?)(?=\n#{1,6}\s|\n<!-- ats:context -->|$)/;
const RELATED_LINE_RE = /^-\s+([a-z][a-z-]*):\s*\[([^\]]*)\]\(([^)]+)\)\s*$/;
// Title capture is greedy so a label containing `]` (e.g. a task titled
// "Spec [draft]") still round-trips by backtracking to the final `](url)`.
const RELATED_LINE_RE = /^-\s+([a-z][a-z-]*):\s*\[(.*)\]\(([^)\s]+)\)\s*$/;

const emptyMetadata = () => ({
version: TASK_CONTEXT_VERSION,
Expand Down Expand Up @@ -228,7 +230,11 @@ function renderRelatedSection(links) {
// link added without a url (e.g. a direct writeTaskMetadata call) still
// round-trips its projectId/taskId.
const href = link.url || `ats://task/${link.projectId}/${link.taskId}`;
return `- ${link.type}: [${link.title || link.taskId}](${href})`;
// Bracket chars in the label would break the markdown link, so soften them
// to parens — keeps the link well-formed and clickable. The URL carries the
// canonical id, so the display label can be lossy.
const label = String(link.title || link.taskId).replace(/[[\]]/g, (ch) => (ch === '[' ? '(' : ')'));
return `- ${link.type}: [${label}](${href})`;
});
return `${RELATED_HEADING}\n${lines.join('\n')}`;
}
Expand Down
20 changes: 20 additions & 0 deletions packages/core/test/task-context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,26 @@ test('links render as a human-readable Related deep-link section, not JSON', ()
);
});

test('a link title containing brackets stays well-formed and round-trips', () => {
const content = writeTaskMetadata('Body.', {
links: [{
type: 'depends-on',
projectId: 'p1',
taskId: 't1',
title: 'Spec [draft]',
url: 'https://ticktick.com/webapp/#p/p1/tasks/t1',
}],
});
// Bracket chars in the label are softened so the markdown link is well-formed.
assert.match(content, /## Related\n- depends-on: \[Spec \(draft\)\]\(https:\/\/ticktick\.com\/webapp\/#p\/p1\/tasks\/t1\)/);
// And it still round-trips into the typed-link model.
const links = parseTaskMetadata(content).links;
assert.deepEqual(links.map((l) => [l.type, l.projectId, l.taskId]), [['depends-on', 'p1', 't1']]);
// Greedy parse also recovers a label that already contains a stray `]`.
const recovered = parseTaskMetadata('## Related\n- supports: [Old [v2]](demo://p2/t2)');
assert.deepEqual(recovered.links.map((l) => [l.type, l.taskId]), [['supports', 't2']]);
});

test('legacy links inside the machine block are read and migrate to Related on write', () => {
const legacy = `Body.\n\n<!-- ats:context -->\n\`\`\`ats\n${JSON.stringify({
version: 1,
Expand Down
Loading