Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/parser/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { CodeBlockConverter } from './adf-to-markdown/nodes/CodeBlockConverter.j
import { BulletListConverter } from './adf-to-markdown/nodes/BulletListConverter.js';
import { OrderedListConverter } from './adf-to-markdown/nodes/OrderedListConverter.js';
import { ListItemConverter } from './adf-to-markdown/nodes/ListItemConverter.js';
import { TaskListConverter } from './adf-to-markdown/nodes/TaskListConverter.js';
import { TaskItemConverter } from './adf-to-markdown/nodes/TaskItemConverter.js';
import { MediaConverter } from './adf-to-markdown/nodes/MediaConverter.js';
import { MediaSingleConverter } from './adf-to-markdown/nodes/MediaSingleConverter.js';
import { TableConverter } from './adf-to-markdown/nodes/TableConverter.js';
Expand Down Expand Up @@ -275,6 +277,8 @@ export class Parser {
new BulletListConverter(),
new OrderedListConverter(),
new ListItemConverter(),
new TaskListConverter(),
new TaskItemConverter(),
new MediaConverter(),
new MediaSingleConverter(),
new TableConverter(),
Expand Down
68 changes: 68 additions & 0 deletions src/parser/adf-to-markdown/nodes/TaskItemConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @file Task Item node converter
* @see https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/taskItem/
*/

import type { NodeConverter, ConversionContext } from '../../types';
import type { ADFNode, TaskItemNode } from '../../../types';

/**
* Task Item Node Converter
*
* Official Documentation:
* @see https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/taskItem/
*
* Purpose:
* Task Item nodes represent individual checkbox items within a task list
*
* ADF Schema:
* ```json
* {
* "type": "taskItem",
* "attrs": { "localId": "unique-id", "state": "DONE" },
* "content": [
* { "type": "text", "text": "Task content" }
* ]
* }
* ```
*
* Markdown Representation:
* ```markdown
* - [x] Task content (for DONE)
* - [ ] Task content (for TODO)
* ```
*/
export class TaskItemConverter implements NodeConverter {
nodeType = 'taskItem';

toMarkdown(node: ADFNode, context: ConversionContext): string {
const taskItemNode = node as TaskItemNode;

const prefix = taskItemNode.attrs?.state === 'DONE' ? '- [x] ' : '- [ ] ';

if (!taskItemNode.content || taskItemNode.content.length === 0) {
return prefix;
}

// taskItem has inline nodes directly (not paragraph-wrapped)
const content = taskItemNode.content.map(child => {
const converter = context.options.registry?.getNodeConverter(child.type);
if (!converter) return '';
return converter.toMarkdown(child, context);
}).filter(c => c.length > 0).join('');

// Handle multi-line content with 6-space indentation (aligns with content after '- [x] ')
const lines = content.split('\n');
const indentedLines = lines.map((line, index) => {
if (index === 0) {
return `${prefix}${line}`;
} else if (line.trim().length > 0) {
return ` ${line}`; // 6-space indent for continuation
} else {
return line; // Keep empty lines as-is
}
});

return indentedLines.join('\n');
}
}
63 changes: 63 additions & 0 deletions src/parser/adf-to-markdown/nodes/TaskListConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @file Task List node converter
* @see https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/taskList/
*/

import type { NodeConverter, ConversionContext } from '../../types';
import type { ADFNode, TaskListNode } from '../../../types';

/**
* Task List Node Converter
*
* Official Documentation:
* @see https://developer.atlassian.com/cloud/jira/platform/apis/document/nodes/taskList/
*
* Purpose:
* Task List nodes create checkbox task lists with TODO/DONE states
*
* ADF Schema:
* ```json
* {
* "type": "taskList",
* "attrs": { "localId": "unique-id" },
* "content": [
* {
* "type": "taskItem",
* "attrs": { "localId": "unique-id-1", "state": "DONE" },
* "content": [{ "type": "text", "text": "Completed task" }]
* }
* ]
* }
* ```
*
* Markdown Representation:
* ```markdown
* - [x] Completed task
* - [ ] Pending task
* ```
*/
export class TaskListConverter implements NodeConverter {
nodeType = 'taskList';

toMarkdown(node: ADFNode, context: ConversionContext): string {
const taskListNode = node as TaskListNode;

if (!taskListNode.content || taskListNode.content.length === 0) {
return '';
}

const taskItems = taskListNode.content.map(taskItem => {
const itemConverter = context.options.registry?.getNodeConverter('taskItem');
if (itemConverter) {
return itemConverter.toMarkdown(taskItem, {
...context,
depth: context.depth + 1,
parent: taskListNode
});
}
return '';
}).filter(item => item.length > 0);

return taskItems.join('\n');
}
}
4 changes: 4 additions & 0 deletions src/parser/engines/AdfToMarkdownEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { CodeBlockConverter } from '../adf-to-markdown/nodes/CodeBlockConverter.
import { BulletListConverter } from '../adf-to-markdown/nodes/BulletListConverter.js';
import { OrderedListConverter } from '../adf-to-markdown/nodes/OrderedListConverter.js';
import { ListItemConverter } from '../adf-to-markdown/nodes/ListItemConverter.js';
import { TaskListConverter } from '../adf-to-markdown/nodes/TaskListConverter.js';
import { TaskItemConverter } from '../adf-to-markdown/nodes/TaskItemConverter.js';
import { MediaConverter } from '../adf-to-markdown/nodes/MediaConverter.js';
import { MediaSingleConverter } from '../adf-to-markdown/nodes/MediaSingleConverter.js';
import { TableConverter } from '../adf-to-markdown/nodes/TableConverter.js';
Expand Down Expand Up @@ -220,6 +222,8 @@ export class AdfToMarkdownEngine {
new BulletListConverter(),
new OrderedListConverter(),
new ListItemConverter(),
new TaskListConverter(),
new TaskItemConverter(),
new MediaConverter(),
new MediaSingleConverter(),
new TableConverter(),
Expand Down
155 changes: 148 additions & 7 deletions src/parser/markdown-to-adf/ASTBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* @author Extended ADF Parser
*/

import { Token, TokenType, ADFMetadata } from './types.js';
import { Token, TokenType, ADFMetadata, ListItemToken } from './types.js';
import { ADFDocument, ADFNode, ADFMark } from '../../types/adf.types.js';
import type { Root } from 'mdast';
import type { AdfFenceNode } from '../remark/adf-from-markdown.js';
Expand Down Expand Up @@ -235,7 +235,38 @@ export class ASTBuilder {
const listToken = token as any; // ListToken
const isOrdered = listToken.ordered || false;
const customAttrs = this.extractCustomAttributes(token.metadata);


// Check if any child token has a checkbox (checked property)
const children = token.children || [];
const hasCheckbox = children.some((child: any) =>
(child as ListItemToken).checked !== undefined && (child as ListItemToken).checked !== null
);

// If checkbox list and not ordered, attempt taskList conversion
if (hasCheckbox && !isOrdered) {
// Validate all checkbox items have simple content (single paragraph child only)
const allSimple = children.every((child: any) => {
return this.isTokenItemSimple(child);
});

if (allSimple) {
return {
type: 'taskList',
attrs: { localId: crypto.randomUUID() },
content: children.map((child: any) => this.convertTaskItem(child as ListItemToken))
};
}
// Fall back to bulletList if any item has complex content
// Re-prepend checkbox syntax to content so it's not lost in the fallback path
for (const child of children) {
const itemToken = child as ListItemToken;
if (itemToken.checked !== undefined && itemToken.checked !== null) {
const prefix = itemToken.checked ? '[x] ' : '[ ] ';
itemToken.content = prefix + itemToken.content;
}
}
}

const attrs: any = {};
if (isOrdered && listToken.start && listToken.start !== 1) {
attrs.order = listToken.start;
Expand All @@ -254,6 +285,42 @@ export class ASTBuilder {
return node;
}

/**
* Check if a token-path list item has simple content (single paragraph child only)
*/
private isTokenItemSimple(token: Token): boolean {
if (!token.children || token.children.length === 0) return true;
// Simple: single paragraph child with no further block nesting
if (token.children.length === 1 && token.children[0].type === 'paragraph') return true;
// Complex: multiple children, or non-paragraph children (lists, code blocks, etc.)
return false;
}

/**
* Convert a ListItemToken with checked property to a taskItem ADF node (token path)
*/
private convertTaskItem(token: ListItemToken): ADFNode {
const state = token.checked ? 'DONE' : 'TODO';

// Extract inline content from the single paragraph child
let content: ADFNode[] = [];
if (token.children && token.children.length === 1 && token.children[0].type === 'paragraph') {
const paragraphToken = token.children[0];
// Use inline tokens if available, otherwise parse content string
content = paragraphToken.children && paragraphToken.children.length > 0
? this.convertInlineTokensToNodes(paragraphToken.children)
: this.convertInlineContent(paragraphToken.content);
} else if (token.content) {
content = this.convertInlineContent(token.content);
}

return {
type: 'taskItem',
attrs: { localId: crypto.randomUUID(), state },
content
};
}

private convertListItem(token: Token): ADFNode {
const customAttrs = this.extractCustomAttributes(token.metadata);

Expand Down Expand Up @@ -1628,13 +1695,55 @@ export class ASTBuilder {
}

private convertMdastList(node: any): ADFNode {
// Detect checkbox/task list: any child with checked !== null && checked !== undefined
if (!node.ordered && node.children?.some((child: any) => child.checked !== null && child.checked !== undefined)) {
// Validate all checkbox items have simple content (single paragraph child only)
const allSimple = node.children.every((child: any) => this.isCheckboxItemSimple(child));
if (allSimple) {
return {
type: 'taskList',
attrs: { localId: crypto.randomUUID() },
content: node.children.map((child: any) => this.convertMdastTaskItem(child))
};
}
// Fall back to bulletList if any item has complex content
}

return {
type: node.ordered ? 'orderedList' : 'bulletList',
...(node.start && node.start !== 1 && { attrs: { order: node.start } }),
content: this.convertMdastNodesToADF(node.children)
};
}

/**
* Check if a checkbox list item has simple content (single paragraph child only).
* Items with multiple paragraphs, nested lists, code blocks, etc. cannot be taskItems
* since ADF taskItem only allows inline content.
*/
private isCheckboxItemSimple(node: any): boolean {
if (!node.children || node.children.length === 0) return true;
return node.children.length === 1 && node.children[0].type === 'paragraph';
}

/**
* Convert an mdast listItem with checked state to an ADF taskItem.
* Extracts inline children from the first paragraph child since taskItem
* content is inline nodes directly (not wrapped in paragraph).
*/
private convertMdastTaskItem(node: any): ADFNode {
const state = node.checked ? 'DONE' : 'TODO';
const inlineContent = node.children?.[0]?.children
? this.convertMdastInlineNodes(node.children[0].children)
: [];

return {
type: 'taskItem',
attrs: { localId: crypto.randomUUID(), state },
content: inlineContent
};
}

private convertMdastListItem(node: any): ADFNode {
return {
type: 'listItem',
Expand Down Expand Up @@ -2401,11 +2510,43 @@ export class ASTBuilder {
};
}

// 7. Bullet list
// 7. Checkbox task list (before regular bullet list)
const hasCheckboxPattern = lines.some(line => /^\s*[-*+]\s+\[(x|X| )\]\s/.test(line));
if (hasCheckboxPattern) {
// All items in this block must be simple single-line items for taskList
const allBulletLines = lines.filter(line => /^\s*[-*+]\s/.test(line));
const allSimple = allBulletLines.every(line => /^\s*[-*+]\s+\[(x|X| )\]\s/.test(line));

if (allSimple) {
const taskItems: ADFNode[] = [];

for (const line of lines) {
const taskMatch = line.match(/^\s*[-*+]\s+\[(x|X| )\]\s*(.*)$/);
if (taskMatch) {
const state = (taskMatch[1] === 'x' || taskMatch[1] === 'X') ? 'DONE' : 'TODO';
const itemContent = taskMatch[2] ? this.parseInlineContentWithSocialElements(taskMatch[2]) : [];
taskItems.push({
type: 'taskItem',
attrs: { localId: crypto.randomUUID(), state },
content: itemContent
});
}
}

return {
type: 'taskList',
attrs: { localId: crypto.randomUUID() },
content: taskItems
};
}
// Fall through to bulletList if items are complex
}

// 8. Bullet list
const isBulletList = lines.some(line => /^\s*[-*+]\s/.test(line));
if (isBulletList) {
const listItems: ADFNode[] = [];

for (const line of lines) {
const listMatch = line.match(/^\s*[-*+]\s+(.+)$/);
if (listMatch) {
Expand All @@ -2426,13 +2567,13 @@ export class ASTBuilder {
};
}

// 8. Table
// 9. Table
const isTableBlock = lines.some(line => /^\s*\|.*\|\s*$/.test(line));
if (isTableBlock) {
return this.parseTableFromLines(lines);
}
// 9. Default: paragraph

// 10. Default: paragraph
return {
type: 'paragraph',
content: this.parseInlineContentWithSocialElements(blockContent)
Expand Down
Loading