(.+?)<\/h[1-6]>/i', $block['innerHTML'], $matches ) ) {
$current_heading = trim( $matches[1] );
// Skip if duplicate of last heading (case-insensitive)
if ( null !== $last_heading_content && strtolower( $current_heading ) === strtolower( $last_heading_content ) ) {
continue;
}
$last_heading_content = $current_heading;
}
} else {
$last_heading_content = null;
}
$cleaned_blocks[] = $block;
}
return $cleaned_blocks;
}
/**
* Parse inline Markdown elements (bold, italic, code, links).
*
* @since 0.1.0
* @param string $text Text with inline Markdown.
* @return array HTML content with inline formatting.
*/
private static function parse_inline_markdown( $text ) {
// Convert inline code.
$text = preg_replace( '/`([^`]+)`/', '$1', $text );
// Convert bold.
$text = preg_replace( '/\*\*(.+?)\*\*/', '$1', $text );
$text = preg_replace( '/__(.+?)__/', '$1', $text );
// Convert italic.
$text = preg_replace( '/\*(.+?)\*/', '$1', $text );
$text = preg_replace( '/_(.+?)_/', '$1', $text );
// Convert links.
$text = preg_replace( '/\[(.+?)\]\((.+?)\)/', '$1', $text );
return $text;
}
/**
* Normalize markdown input to improve block parsing.
*
* @since 0.1.0
* @param string $markdown Raw markdown or HTML-ish content.
* @return string
*/
private static function normalize_markdown( $markdown ) {
if ( null === $markdown ) {
return '';
}
$markdown = (string) $markdown;
$markdown = str_replace(
array( '–', '—', '–', '—', '•' ),
'-',
$markdown
);
$markdown = preg_replace( '/
/i', "\n", $markdown );
$markdown = preg_replace( '/<\\/p>\\s*/i', "\n\n", $markdown );
$markdown = preg_replace( '/<\\/?p>/i', '', $markdown );
$markdown = preg_replace( '/!\\[([^\\]]*)\\]\\([^\\)]*\\)/', '[IMAGE: $1]', $markdown );
$markdown = preg_replace( '/\\[IMAGE:\\s*([^\\]]+)\\]/i', "\n[IMAGE: $1]\n", $markdown );
return $markdown;
}
/**
* Check if a line looks like a markdown table row.
*
* @since 0.1.0
* @param string $line Line content.
* @return bool
*/
private static function is_table_row( $line ) {
if ( '' === $line ) {
return false;
}
return false !== strpos( $line, '|' );
}
/**
* Check if a line is a markdown table separator.
*
* @since 0.1.0
* @param string $line Line content.
* @return bool
*/
private static function is_table_separator( $line ) {
return (bool) preg_match( '/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/', $line );
}
/**
* Split a markdown table row into cells.
*
* @since 0.1.0
* @param string $line Row line.
* @return array
*/
private static function split_table_row( $line ) {
$trimmed = trim( $line );
$trimmed = trim( $trimmed, " \t|" );
if ( '' === $trimmed ) {
return array();
}
return array_map( 'trim', explode( '|', $trimmed ) );
}
/**
* Create a heading block.
*
* @since 0.1.0
* @param string $content Heading content.
* @param int $level Heading level (1-6).
* @return array Gutenberg block.
*/
private static function create_heading_block( $content, $level ) {
$level = min( max( $level, 1 ), 6 ); // Ensure level is between 1-6.
$parsed_content = self::parse_inline_markdown( $content );
$html = '' . $parsed_content . '';
return array(
'blockName' => 'core/heading',
'attrs' => array(
'level' => $level,
'content' => $parsed_content,
),
'innerBlocks' => array(),
'innerContent' => array( $html ),
'innerHTML' => $html,
);
}
/**
* Create a paragraph block.
*
* @since 0.1.0
* @param string $content Paragraph content.
* @return array Gutenberg block.
*/
private static function create_paragraph_block( $content ) {
$parsed_content = self::parse_inline_markdown( $content );
$html = '
' . $parsed_content . '
';
return array(
'blockName' => 'core/paragraph',
'attrs' => array(
'content' => $parsed_content,
),
'innerBlocks' => array(),
'innerContent' => array( $html ),
'innerHTML' => $html,
);
}
/**
* Create a table block.
*
* @since 0.1.0
* @param array $headers Table headers.
* @param array $rows Table rows.
* @return array Gutenberg block.
*/
private static function create_table_block( $headers, $rows ) {
$header_cells = array();
foreach ( $headers as $header ) {
$header_cells[] = '' . self::parse_inline_markdown( $header ) . ' | ';
}
$tbody_rows = array();
foreach ( $rows as $row ) {
$cells = array();
foreach ( $row as $cell ) {
$cells[] = '' . self::parse_inline_markdown( $cell ) . ' | ';
}
if ( empty( $cells ) ) {
continue;
}
$tbody_rows[] = '' . implode( '', $cells ) . '
';
}
$thead_html = '' . implode( '', $header_cells ) . '
';
$tbody_html = '' . implode( '', $tbody_rows ) . '';
$table_html = '' . $thead_html . $tbody_html . '
';
return array(
'blockName' => 'core/table',
'attrs' => array(
'hasFixedLayout' => true,
),
'innerBlocks' => array(),
'innerContent' => array( $table_html ),
'innerHTML' => $table_html,
);
}
/**
* Create a code block.
*
* @since 0.1.0
* @param string $language Programming language.
* @param string $code Code content.
* @return array Gutenberg block.
*/
private static function create_code_block( $language, $code ) {
// Escape HTML entities in code.
$escaped_code = htmlspecialchars( $code, ENT_NOQUOTES, 'UTF-8' );
return array(
'blockName' => 'core/code',
'attrs' => array(
'content' => $code,
'language' => $language ?: 'text',
),
'innerBlocks' => array(),
'innerContent' => array(),
'innerHTML' => '' . $escaped_code . '
',
);
}
/**
* Create a list block.
*
* @since 0.1.0
* @param string $type List type ('ul' or 'ol').
* @param array $items List items.
* @param int|null $start Ordered list start value.
* @return array Gutenberg block.
*/
private static function create_list_block( $type, $items, $start = null ) {
$tag = $type === 'ol' ? 'ol' : 'ul';
$is_ordered = 'ol' === $tag;
$start = $is_ordered ? max( 1, (int) $start ) : null;
$start_attr = $is_ordered && $start > 1 ? ' start="' . $start . '"' : '';
$html = '<' . $tag . $start_attr . '>';
// Create inner blocks for each list item
$inner_blocks = array();
$inner_content = array();
foreach ( $items as $item ) {
$item_content = self::parse_inline_markdown( $item );
$li_html = '' . $item_content . '';
$html .= $li_html;
$inner_content[] = $li_html;
// Create list item with content in attrs
$inner_blocks[] = array(
'blockName' => 'core/list-item',
'attrs' => array(
'content' => $item_content,
),
'innerBlocks' => array(),
'innerContent' => array( $li_html ),
'innerHTML' => $li_html,
);
}
$html .= '' . $tag . '>';
$attrs = array(
'ordered' => $is_ordered,
);
if ( $is_ordered && $start > 1 ) {
$attrs['start'] = $start;
}
return array(
'blockName' => 'core/list',
'attrs' => $attrs,
'innerBlocks' => $inner_blocks,
'innerContent' => $inner_content,
'innerHTML' => $html,
);
}
/**
* Create a quote block.
*
* @since 0.1.0
* @param string $content Quote content.
* @return array Gutenberg block.
*/
private static function create_quote_block( $content ) {
$parsed_content = self::parse_inline_markdown( $content );
$html = '' . $parsed_content . '
';
return array(
'blockName' => 'core/quote',
'attrs' => array(
'value' => $parsed_content,
),
'innerBlocks' => array(),
'innerContent' => array( $html ),
'innerHTML' => $html,
);
}
/**
* Create an image placeholder block.
*
* @since 0.1.0
* @param string $description Image description/alt text.
* @param string $agent_image_id Optional. Agent-assigned image ID for tracking.
* @return array Gutenberg block.
*/
private static function create_image_placeholder_block( $description, $agent_image_id = null ) {
$alt = trim( $description );
// Build className with agent image ID (WordPress preserves className reliably)
$class_name = '';
if ( ! empty( $agent_image_id ) ) {
$class_name = 'wpaw-agent-img-' . esc_attr( $agent_image_id );
}
$attrs = array(
'id' => 0,
'url' => '',
'alt' => '[Image: ' . $alt . ']', // Mark as placeholder
'caption' => '',
'sizeSlug' => 'large',
'linkDestination' => 'none',
);
// Add className and data attribute if agent_image_id provided
if ( ! empty( $agent_image_id ) ) {
$attrs['className'] = $class_name;
$attrs['data-agent-image-id'] = $agent_image_id;
}
$figure_class = 'wp-block-image size-large' . ( $class_name ? ' ' . $class_name : '' );
$html = '
';
return array(
'blockName' => 'core/image',
'attrs' => $attrs,
'innerBlocks' => array(),
'innerContent' => array(),
'innerHTML' => $html,
);
}
/**
* Merge consecutive ordered lists into one continuous list.
* Fixes the "1. 1. 1." issue when numbered items are separated by other content.
*
* @since 0.1.0
* @param array $blocks Array of blocks.
* @return array Merged blocks.
*/
private static function merge_consecutive_ordered_lists( $blocks ) {
$result = array();
$pending_ol = null;
$pending_ol_items = array();
foreach ( $blocks as $block ) {
$is_ordered_list = isset( $block['blockName'] )
&& 'core/list' === $block['blockName']
&& ! empty( $block['attrs']['ordered'] );
if ( $is_ordered_list ) {
// Accumulate ordered list items
if ( ! empty( $block['innerBlocks'] ) ) {
foreach ( $block['innerBlocks'] as $item ) {
$pending_ol_items[] = $item;
}
}
if ( null === $pending_ol ) {
$pending_ol = $block;
}
} else {
// Flush pending ordered list if we have one
if ( null !== $pending_ol && ! empty( $pending_ol_items ) ) {
$start = isset( $pending_ol['attrs']['start'] ) ? (int) $pending_ol['attrs']['start'] : 1;
$result[] = self::rebuild_ordered_list( $pending_ol_items, $start );
$pending_ol = null;
$pending_ol_items = array();
}
$result[] = $block;
}
}
// Flush any remaining ordered list
if ( null !== $pending_ol && ! empty( $pending_ol_items ) ) {
$start = isset( $pending_ol['attrs']['start'] ) ? (int) $pending_ol['attrs']['start'] : 1;
$result[] = self::rebuild_ordered_list( $pending_ol_items, $start );
}
return $result;
}
/**
* Rebuild an ordered list from accumulated items.
*
* @since 0.1.0
* @param array $items List item blocks.
* @param int $start Ordered list start value.
* @return array Ordered list block.
*/
private static function rebuild_ordered_list( $items, $start = 1 ) {
$start = max( 1, (int) $start );
$start_attr = $start > 1 ? ' start="' . $start . '"' : '';
$html = '';
$inner_content = array();
foreach ( $items as $item ) {
$li_html = isset( $item['innerHTML'] ) ? $item['innerHTML'] : '';
$html .= $li_html;
$inner_content[] = $li_html;
}
$html .= '
';
$attrs = array(
'ordered' => true,
);
if ( $start > 1 ) {
$attrs['start'] = $start;
}
return array(
'blockName' => 'core/list',
'attrs' => $attrs,
'innerBlocks' => $items,
'innerContent' => $inner_content,
'innerHTML' => $html,
);
}
/**
* Create a button block from CTA syntax.
*
* @since 0.1.0
* @param string $content CTA content (may include URL in parentheses).
* @return array Gutenberg block.
*/
private static function create_button_block( $content ) {
$text = trim( $content );
$url = '#';
// Check for URL in parentheses: "Button Text (https://example.com)"
if ( preg_match( '/^(.+?)\s*\(([^)]+)\)\s*$/', $content, $matches ) ) {
$text = trim( $matches[1] );
$url = trim( $matches[2] );
}
// Clean up common patterns like "Link ke..." or "(Link..."
$text = preg_replace( '/\s*\(Link\s+ke\s+.*$/i', '', $text );
$text = preg_replace( '/\s*\(Link\s+.*$/i', '', $text );
$button_html = '';
$button_block = array(
'blockName' => 'core/button',
'attrs' => array(
'text' => $text,
'url' => $url,
),
'innerBlocks' => array(),
'innerContent' => array( $button_html ),
'innerHTML' => $button_html,
);
// Wrap in buttons container
$wrapper_html = '' . $button_html . '
';
return array(
'blockName' => 'core/buttons',
'attrs' => array(),
'innerBlocks' => array( $button_block ),
'innerContent' => array( $wrapper_html ),
'innerHTML' => $wrapper_html,
);
}
}