|::/', $trimmed ) ) { return true; } return false; }; $line_count = count( $lines ); for ( $i = 0; $i < $line_count; $i++ ) { $line = $lines[ $i ]; $trimmed = trim( $line ); if ( $in_auto_code_block ) { if ( '' === $trimmed ) { $blocks[] = self::create_code_block( $auto_code_language, implode( "\n", $auto_code_lines ) ); $auto_code_lines = array(); $auto_code_language = 'text'; $in_auto_code_block = false; continue; } if ( $is_code_like_line( $trimmed ) || preg_match( '/^\\s+/', $line ) ) { $auto_code_lines[] = $line; continue; } $blocks[] = self::create_code_block( $auto_code_language, implode( "\n", $auto_code_lines ) ); $auto_code_lines = array(); $auto_code_language = 'text'; $in_auto_code_block = false; // Continue processing current line normally below. } // Handle image placeholders: [IMAGE: description] if ( preg_match( '/^\[IMAGE:\s*(.+)\]$/i', $trimmed, $matches ) ) { // Flush any pending paragraph. if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } // Flush any pending list. if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } // Create image placeholder block. $blocks[] = self::create_image_placeholder_block( $matches[1] ); continue; } // Handle CTA/Button placeholders: [CTA: text] or [CTA: text (url)] if ( preg_match( '/^\[CTA:\s*(.+)\]$/i', $trimmed, $matches ) ) { // Flush any pending paragraph. if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } // Flush any pending list. if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } // Create button block. $blocks[] = self::create_button_block( $matches[1] ); continue; } // Detect unfenced code lines and create a code block automatically. if ( ! $in_code_block && $is_code_like_line( $trimmed ) ) { // Flush any pending paragraph. if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } // Flush any pending list. if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } $auto_code_language = preg_match( '/^(<\\?php|define\\s*\\(|\\$[A-Za-z_])/', $trimmed ) ? 'php' : 'text'; $in_auto_code_block = true; $auto_code_lines[] = $line; continue; } // Handle code blocks. if ( preg_match( '/^```(\w*)/', $trimmed, $matches ) ) { if ( $in_code_block ) { // End code block. $blocks[] = self::create_code_block( $code_language, implode( "\n", $code_lines ) ); $code_lines = array(); $code_language = ''; $in_code_block = false; } else { // Start code block. // Flush any pending paragraph. if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } // Flush any pending list. if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } $in_code_block = true; $code_language = $matches[1]; } continue; } if ( $in_code_block ) { $code_lines[] = $line; continue; } // Handle headings. if ( preg_match( '/^(#{1,6})\s+(.+)$/', $trimmed, $matches ) ) { // Flush any pending paragraph. if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } // Flush any pending list. if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } $level = strlen( $matches[1] ); $content = $matches[2]; $blocks[] = self::create_heading_block( $content, $level ); continue; } // Handle markdown tables (header + separator + rows). if ( ! $in_list && self::is_table_row( $trimmed ) && $i + 1 < $line_count ) { $next_line = trim( $lines[ $i + 1 ] ); if ( self::is_table_separator( $next_line ) ) { if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } $headers = self::split_table_row( $trimmed ); $rows = array(); $i += 2; for ( ; $i < $line_count; $i++ ) { $row_line = trim( $lines[ $i ] ); if ( '' === $row_line || ! self::is_table_row( $row_line ) ) { $i -= 1; break; } $rows[] = self::split_table_row( $row_line ); } $blocks[] = self::create_table_block( $headers, $rows ); continue; } } // Handle horizontal rules. if ( preg_match( '/^[-*_]{3,}/', $trimmed ) ) { if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } // Gutenberg doesn't have a native separator block, use a spacer. $blocks[] = array( 'blockName' => 'core/spacer', 'attrs' => array( 'height' => '20px', ), 'innerBlocks' => array(), 'innerContent' => array(), ); continue; } // Handle unordered lists (supports common dash/bullet variants). if ( preg_match( '/^[\*\-+\x{2022}\x{2023}\x{2013}\x{2014}]\s+(.+)$/u', $trimmed, $matches ) ) { if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } if ( $in_list && $list_type !== 'ul' ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); } $in_list = true; $list_type = 'ul'; $list_items[] = self::parse_inline_markdown( $matches[1] ); continue; } // Handle ordered lists. if ( preg_match( '/^\d+\.\s+(.+)$/', $trimmed, $matches ) ) { if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } if ( $in_list && $list_type !== 'ol' ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); } $in_list = true; $list_type = 'ol'; $list_items[] = self::parse_inline_markdown( $matches[1] ); continue; } // Handle blockquotes. if ( preg_match( '/^>\s+(.+)$/', $trimmed, $matches ) ) { if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } $blocks[] = self::create_quote_block( $matches[1] ); continue; } // Handle empty lines. if ( empty( $trimmed ) ) { // Flush paragraph. if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } // Flush list. if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); $list_items = array(); $in_list = false; } continue; } // Accumulate paragraph text. if ( ! empty( $current_paragraph ) ) { $current_paragraph .= ' ' . $trimmed; } else { $current_paragraph = $trimmed; } // Check if paragraph ends with punctuation suggesting end of sentence. if ( preg_match( '/[.!?]$/', $trimmed ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); $current_paragraph = ''; } } if ( $in_auto_code_block && ! empty( $auto_code_lines ) ) { $blocks[] = self::create_code_block( $auto_code_language, implode( "\n", $auto_code_lines ) ); } // Flush any remaining content. if ( ! empty( $current_paragraph ) ) { $blocks[] = self::create_paragraph_block( $current_paragraph ); } if ( $in_list ) { $blocks[] = self::create_list_block( $list_type, $list_items ); } // Merge consecutive ordered lists (fix 1. 1. 1. issue) $merged_blocks = self::merge_consecutive_ordered_lists( $blocks ); // Remove duplicate adjacent headings before returning $cleaned_blocks = array(); $last_heading_content = null; foreach ( $merged_blocks as $block ) { if ( isset( $block['blockName'] ) && 'core/heading' === $block['blockName'] ) { if ( preg_match( '/(.+?)<\/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. * @return array Gutenberg block. */ private static function create_list_block( $type, $items ) { $tag = $type === 'ol' ? 'ol' : 'ul'; $html = '<' . $tag . '>'; // 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 .= ''; return array( 'blockName' => 'core/list', 'attrs' => array( 'ordered' => $type === 'ol', ), '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. * @return array Gutenberg block. */ private static function create_image_placeholder_block( $description ) { $alt = trim( $description ); $attrs = array( 'id' => 0, 'url' => '', 'alt' => $alt, 'caption' => '', 'sizeSlug' => 'large', 'linkDestination' => 'none', ); $html = '
    ' . esc_attr( $alt ) . '
    '; 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 ) ) { $result[] = self::rebuild_ordered_list( $pending_ol_items ); $pending_ol = null; $pending_ol_items = array(); } $result[] = $block; } } // Flush any remaining ordered list if ( null !== $pending_ol && ! empty( $pending_ol_items ) ) { $result[] = self::rebuild_ordered_list( $pending_ol_items ); } return $result; } /** * Rebuild an ordered list from accumulated items. * * @since 0.1.0 * @param array $items List item blocks. * @return array Ordered list block. */ private static function rebuild_ordered_list( $items ) { $html = '
      '; $inner_content = array(); foreach ( $items as $item ) { $li_html = isset( $item['innerHTML'] ) ? $item['innerHTML'] : '
    1. '; $html .= $li_html; $inner_content[] = $li_html; } $html .= '
    '; return array( 'blockName' => 'core/list', 'attrs' => array( 'ordered' => true, ), '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, ); } }