Skip to content

Table cells contain text nodes directly instead of wrapping in paragraph — Jira rejects with INVALID_INPUT - #9

Open
acreeger wants to merge 3 commits into
JeromeErasmus:mainfrom
acreeger:fix/issue-7__table-cells-wrap-text
Open

Table cells contain text nodes directly instead of wrapping in paragraph — Jira rejects with INVALID_INPUT#9
acreeger wants to merge 3 commits into
JeromeErasmus:mainfrom
acreeger:fix/issue-7__table-cells-wrap-text

Conversation

@acreeger

Copy link
Copy Markdown

Fixes #7

Table cells contain text nodes directly instead of wrapping in paragraph — Jira rejects with INVALID_INPUT

When converting markdown tables to ADF, tableCell and tableHeader nodes contain text nodes as direct children. The Jira ADF spec requires block-level children (e.g., paragraph) inside table cells.

Input:

| a | b |
|---|---|
| c | d |

Current output (invalid):

{ "type": "tableCell", "content": [{ "type": "text", "text": "c" }] }

Expected output:

{ "type": "tableCell", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "c" }] }] }

Jira's v3 REST API returns a 400 INVALID_INPUT error when posting comments or issue descriptions containing tables. Note that validateAdf() does not catch this — it passes validation, but Jira rejects it.


This PR was created automatically by iloom.

@acreeger

acreeger commented Feb 16, 2026

Copy link
Copy Markdown
Author

Complexity Assessment

Classification: SIMPLE

Metrics:

  • Estimated files affected: 1-2
  • Estimated lines of code: 40
  • Breaking changes: No
  • Database migrations: No
  • Cross-cutting changes: No
  • File architecture quality: Good - ASTBuilder.ts (2578 LOC) is well-structured with clear converter methods
  • Architectural signals triggered: None
  • Overall risk level: Low

Reasoning: This is a localized change to wrap table cell content in paragraph nodes. The fix requires modifying only 2 conversion methods in ASTBuilder.ts with ~20-40 LOC total. The expected behavior is clearly documented in the ADF fixture file (table-document.adf), and the change is isolated to table cell handling with no impact on other system components.

@acreeger

acreeger commented Feb 16, 2026

Copy link
Copy Markdown
Author

Combined Analysis & Plan - Issue #7

Executive Summary

Table cells (tableCell/tableHeader) produced by the markdown-to-ADF conversion contain inline text nodes as direct children instead of wrapping them in paragraph nodes as required by the Jira ADF spec. The fix modifies two conversion methods in ASTBuilder.ts to wrap inline content in paragraph nodes, and updates existing test assertions that reference the old (flat) structure.

Implementation Overview

High-Level Execution Phases

  1. Add paragraph-wrapping helper: Create a private helper method in ASTBuilder that wraps inline content arrays in a paragraph node (or passes through already-block-level content)
  2. Fix token-based path: Update convertTableCell() to use the helper
  3. Fix mdast-based path: Update convertMdastTableCell() to use the helper
  4. Update tests: Fix existing test assertions to account for the new paragraph wrapping layer
  5. Add targeted test: Add a test that explicitly verifies table cells contain paragraph children (not bare text)

Quick Stats

  • 2 files to modify
  • 0 new files to create
  • 0 files to delete
  • Dependencies: None

Complete Analysis & Implementation Details (click to expand)

Research Findings

Problem Space

  • Problem: Jira v3 REST API returns 400 INVALID_INPUT when table cells contain text nodes directly instead of paragraph-wrapped content.
  • Architectural context: Two parallel conversion paths exist -- token-based (micromark) and mdast-based (remark) -- both need the same fix.
  • Edge cases: Empty cells should get { type: "paragraph", content: [] }. Cells containing already-block-level content (codeBlock, bulletList, blockquote) should NOT be double-wrapped.

Codebase Research

  • Entry point (token path): /src/parser/markdown-to-adf/ASTBuilder.ts:308-342 - convertTableCell() builds inline content via convertInlineTokensToNodes/convertInlineContent and sets it directly as cell content.
  • Entry point (mdast path): /src/parser/markdown-to-adf/ASTBuilder.ts:1680-1693 - convertMdastTableCell() calls convertMdastNodesToADF(node.children) which returns inline nodes for simple table cells.
  • Similar patterns: convertHeading() at line 140 uses the same convertInlineTokensToNodes/convertInlineContent pattern but headings are allowed to contain inline content directly per ADF spec.
  • Fixtures: Both table-document.adf and comprehensive-tables.adf already show the correct expected structure with paragraph wrapping inside cells.

Affected Files

  • /src/parser/markdown-to-adf/ASTBuilder.ts:308-342 - convertTableCell() method, needs paragraph wrapping of inline content
  • /src/parser/markdown-to-adf/ASTBuilder.ts:1680-1693 - convertMdastTableCell() method, needs paragraph wrapping of inline content
  • /src/__tests__/table-panel-conversion.test.ts:43-54,96,101 - Test assertions that access content[0].text directly (need extra .content[0] indirection)

Integration Points

  • The comprehensive-tables.adf fixture test at tests/integration/markdown-to-adf-fixtures.test.ts:104-116 only checks high-level attrs, so it won't break.
  • The adf-structure-validation.test.ts validates fixture structure but doesn't test markdown-to-ADF conversion output.

Implementation Plan

Automated Test Cases to Create

Test File: /src/__tests__/table-panel-conversion.test.ts (MODIFY)

// Add new test case inside 'Table Conversion' describe block:
it('should wrap table cell content in paragraph nodes per ADF spec', async () => {
  // Parse simple markdown table
  // Assert each tableHeader.content[0].type === 'paragraph'
  // Assert each tableCell.content[0].type === 'paragraph'
  // Assert paragraph.content[0].type === 'text'
  // Assert empty cells have { type: 'paragraph', content: [] }
});

Files to Modify

1. /src/parser/markdown-to-adf/ASTBuilder.ts:308-342

Change: In convertTableCell(), wrap the content array in a paragraph node before assigning to the cell. If the content is empty, use [{ type: 'paragraph', content: [] }].

// After computing content (line 313-315), wrap it:
// const wrappedContent = this.wrapCellContentInParagraph(content);
// Then use wrappedContent instead of content on line 319

2. /src/parser/markdown-to-adf/ASTBuilder.ts:1680-1693

Change: In convertMdastTableCell(), wrap the result of convertMdastNodesToADF(node.children) in a paragraph node if the children are inline (not already block-level).

// After getting content from convertMdastNodesToADF (line 1683):
// const wrappedContent = this.wrapCellContentInParagraph(content);
// Use wrappedContent in the adfNode

3. /src/parser/markdown-to-adf/ASTBuilder.ts (new private method, add near line 340)

Change: Add a shared wrapCellContentInParagraph(content: ADFNode[]): ADFNode[] helper that:

  • Returns [{ type: 'paragraph', content: [] }] for empty content
  • Returns content as-is if first child is already a block-level type (paragraph, codeBlock, bulletList, orderedList, blockquote, heading, mediaSingle, rule, panel)
  • Otherwise wraps the entire content array in a single { type: 'paragraph', content: [...] } node
// private wrapCellContentInParagraph(content: ADFNode[]): ADFNode[] {
//   if (!content || content.length === 0) return [{ type: 'paragraph', content: [] }];
//   const blockTypes = ['paragraph', 'codeBlock', 'bulletList', 'orderedList',
//                       'blockquote', 'heading', 'mediaSingle', 'rule', 'panel',
//                       'mediaGroup', 'table'];
//   const hasBlockContent = content.some(n => blockTypes.includes(n.type));
//   if (hasBlockContent) return content;
//   return [{ type: 'paragraph', content }];
// }

4. /src/__tests__/table-panel-conversion.test.ts:43-54,96,101

Change: Update test assertions to navigate through the new paragraph wrapper.

Lines 43-44: content[0].content[0].text becomes content[0].content[0].content[0].text
Lines 53-54: Same pattern
Line 96: Same pattern
Line 101: Same pattern

Detailed Execution Order

NOTE: These steps are executed in a SINGLE implementation run.

  1. Add helper method

    • File: /src/parser/markdown-to-adf/ASTBuilder.ts
    • Add wrapCellContentInParagraph() private method after convertTableCell() (around line 342) -> Verify: method compiles
  2. Fix token-based path

    • File: /src/parser/markdown-to-adf/ASTBuilder.ts:313-319
    • Call wrapCellContentInParagraph(content) and assign result to cell content -> Verify: convertTableCell returns cells with paragraph children
  3. Fix mdast-based path

    • File: /src/parser/markdown-to-adf/ASTBuilder.ts:1681-1684
    • Call wrapCellContentInParagraph() on the result of convertMdastNodesToADF(node.children) -> Verify: convertMdastTableCell returns cells with paragraph children
  4. Update existing test assertions

    • File: /src/__tests__/table-panel-conversion.test.ts:43,44,53,54,96,101
    • Adjust content path to navigate through paragraph wrapper -> Verify: existing tests pass
  5. Add explicit paragraph-wrapping test

    • File: /src/__tests__/table-panel-conversion.test.ts
    • Add test that verifies tableCell.content[0].type === 'paragraph' -> Verify: new test passes
  6. Run full test suite

    • Command: node --experimental-vm-modules node_modules/.bin/jest
    • Verify: All tests pass

Dependencies and Configuration

None

@acreeger

acreeger commented Feb 16, 2026

Copy link
Copy Markdown
Author

Implementation Complete

Summary

Wrapped table cell and header content in paragraph nodes as required by the Jira ADF spec. Both the token-based (micromark) and mdast-based (remark) conversion paths now produce valid ADF where tableCell and tableHeader nodes contain block-level children.

Changes Made

  • src/parser/markdown-to-adf/ASTBuilder.ts: Added wrapCellContentInParagraph() helper, applied in both convertTableCell() and convertMdastTableCell()
  • src/parser/engines/MarkdownToAdfEngine.ts: Fixed cleanupEmptyParagraphs() to preserve empty paragraphs in table cells
  • src/tests/table-panel-conversion.test.ts: Updated assertions + added new spec test
  • src/tests/working-elements-validation.test.ts: Updated table assertions
  • src/tests/new-features-validation.test.ts: Updated table assertions
  • tests/unit/parser/ASTBuilder.block-parsing.test.ts: Updated table cell assertions
  • tests/unit/parser/ASTBuilder.nested-elements.test.ts: Updated table cell assertions

Validation Results

  • Tests: 551 passed / 551 total (34 suites with pre-existing ESM import failures unchanged)
  • Typecheck: Passed
  • Lint: Passed (pre-existing warnings only)

Detailed Changes by File (click to expand)

src/parser/markdown-to-adf/ASTBuilder.ts

Changes: Added paragraph wrapping for table cells

  • Added wrapCellContentInParagraph() private helper method that wraps inline content in a paragraph node while passing through already-block-level content (codeBlock, bulletList, etc.)
  • Applied wrapper in convertTableCell() (token/micromark path, line ~319)
  • Applied wrapper in convertMdastTableCell() (mdast/remark path, line ~1699)

src/parser/engines/MarkdownToAdfEngine.ts

Changes: Fixed cleanup to preserve empty paragraphs in table cells

  • Added node.content.length > 0 check in cleanupEmptyParagraphs() to prevent Array.every on empty arrays from stripping paragraph wrappers in empty table cells

Test Files (5 files)

Changes: Updated assertions to navigate through new paragraph wrapper layer

  • All table cell content access patterns updated from cell.content[0] to cell.content[0].content[0]
  • Added new dedicated test: "should wrap table cell content in paragraph nodes per ADF spec"

Table cells and headers now contain block-level paragraph nodes wrapping
inline content, as required by the Jira ADF specification. Fixes Jira
v3 REST API returning 400 INVALID_INPUT for tables.

Changes:
- Add wrapCellContentInParagraph() helper in ASTBuilder
- Apply wrapping in all three table cell conversion paths
- Fix cleanupEmptyParagraphs to preserve empty paragraphs in cells
- Update test assertions for new paragraph wrapper layer

Fixes JeromeErasmus#7
@acreeger
acreeger force-pushed the fix/issue-7__table-cells-wrap-text branch from 29e2b4a to 36d5c6a Compare February 16, 2026 06:44
Cover the parseTableFromLines code path for tables nested inside
panel and expand blocks, verifying cell content is wrapped in
paragraph nodes per ADF spec.

Fixes JeromeErasmus#7
…Fixes JeromeErasmus#7

- Add comprehensive tests for paragraph wrapping in panel and expand table cells
- Verify cells with text nodes are wrapped in paragraph nodes per ADF spec
- Verify cells with block-level content remain unchanged
- Test nested table and list scenarios within table cells
@acreeger
acreeger marked this pull request as ready for review February 16, 2026 07:28
@acreeger

Copy link
Copy Markdown
Author

iloom Session Summary

Key Themes:

  • Table cell conversion happens through three distinct code paths that all needed the same paragraph wrapping fix
  • ADF spec requires block-level children in table cells, but empty paragraph nodes with empty content arrays are valid
  • Jest's ESM handling requires --experimental-vm-modules flag; tests that import ASTBuilder won't run with plain npx jest

Session Details (click to expand)

Key Insights

  • Three conversion paths for table cells: convertTableCell() (token/micromark path, line 308), convertMdastTableCell() (mdast/remark path, line 1680), and parseTableFromLines() (inline parsing for tables inside panel/expand blocks, line 2482). All three needed the same fix.
  • Block type allowlist is critical: The wrapCellContentInParagraph() helper must recognize all valid ADF block types (paragraph, codeBlock, bulletList, orderedList, blockquote, heading, mediaSingle, rule, panel, mediaGroup, table, expand, nestedExpand) to avoid double-wrapping block content that's already valid as a direct cell child.
  • Empty cells get empty paragraphs: Per ADF spec, empty table cells should contain { type: 'paragraph', content: [] } rather than no content at all.
  • cleanupEmptyParagraphs() has edge cases: The cleanup function uses Array.every() which returns true for empty arrays (vacuously true). Without a node.content.length > 0 guard, it would strip paragraph wrappers from empty table cells.
  • Custom markdown extensions: This parser extends standard markdown with ::: panel syntax and ~~~panel/~~~expand fence blocks to express Atlassian-specific ADF structures that have no GitHub-Flavored Markdown equivalent.

Decisions Made

  • Shared helper method: Created wrapCellContentInParagraph() to avoid duplicating wrapping logic across the three conversion paths. The helper detects block-level content and passes it through unchanged, while wrapping inline content in a paragraph node.
  • Comprehensive block type list: Added expand and nestedExpand to the block types allowlist after code review identified the risk of double-wrapping expand blocks inside table cells.

Challenges Resolved

  • Third code path missed initially: Code review caught that parseTableFromLines() (used for tables inside panels/expands) wasn't applying the paragraph wrapper. This path is less obvious because it parses raw text lines rather than going through the token/AST-based flows.
  • Test coverage gap: Initially added tests to src/__tests__/table-panel-conversion.test.ts that imported from src/index.ts, which pulled in unified (ESM-only). These tests appeared to run during implementation but were actually failing to load. Fixed by importing ASTBuilder and MarkdownTokenizer directly and constructing a local parser helper, avoiding the ESM dependency chain.
  • Jest ESM handling: The project uses node --experimental-vm-modules to run tests (via npm test / yarn test / iloom test). Running npx jest directly fails on any test importing ASTBuilder because it transitively imports unist-util-visit, an ESM-only package. This isn't a bug in the tests—it's the expected behavior without the proper node flag.

Lessons Learned

  • Look for all code paths: Table conversion logic isn't just in the obvious convertTableCell() method. Panel/expand parsing has its own inline table parser that bypasses the normal AST flows. When fixing structural issues like paragraph wrapping, grep for all places that construct tableCell or tableHeader nodes.
  • Test fixtures as specification: The tests/fixtures/adf/ files (like table-document.adf, comprehensive-tables.adf) document the expected ADF structure more clearly than comments or READMEs. These fixtures already showed paragraph wrapping as the correct behavior.
  • validateAdf() doesn't catch this: The Jira ADF spec requires paragraph wrappers in table cells, but the local validateAdf() function passes unwrapped inline content. Only the Jira v3 REST API rejects it with 400 INVALID_INPUT. Local validation passing doesn't guarantee Jira acceptance.

Generated with 🤖❤️ by iloom.ai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Table cells contain text nodes directly instead of wrapping in paragraph — Jira rejects with INVALID_INPUT

1 participant