diff --git a/.eslintignore b/.eslintignore index b3ac4e5866..2f55011606 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,7 @@ __pycache__ packages/playground/wordpress-builds/src/wordpress packages/playground/wordpress-builds/public packages/playground/sync/src/test/wp-* +packages/playground/data-liberation/tests/fixtures packages/php-wasm/node/src/test/__test* *.timestamp-1678999213403.mjs .local diff --git a/.prettierignore b/.prettierignore index 9162807152..de4d6784be 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ /packages/playground/wordpress-builds/build/build-assets /packages/playground/wordpress-builds/src/wordpress /packages/playground/wordpress-builds/public/ +/packages/playground/data-liberation/tests/fixtures /packages/php-wasm/node/src/test/__test* __pycache__ *.timestamp-1678999213403.mjs diff --git a/packages/playground/blueprints/src/lib/steps/activate-plugin.ts b/packages/playground/blueprints/src/lib/steps/activate-plugin.ts index 0d08958340..706b0a8211 100644 --- a/packages/playground/blueprints/src/lib/steps/activate-plugin.ts +++ b/packages/playground/blueprints/src/lib/steps/activate-plugin.ts @@ -1,7 +1,5 @@ -import { phpVar } from '@php-wasm/util'; import { StepHandler } from '.'; import { logger } from '@php-wasm/logger'; - /** * @inheritDoc activatePlugin * @example @@ -39,18 +37,18 @@ export const activatePlugin: StepHandler = async ( progress?.tracker.setCaption(`Activating ${pluginName || pluginPath}`); const docroot = await playground.documentRoot; - const result = await playground.run({ + const activatePluginResult = await playground.run({ code: ` 'Administrator') )[0]->ID ); - $plugin_path = ${phpVar(pluginPath)}; + $plugin_path = getenv('PLUGIN_PATH'); $response = false; - if (!is_dir($plugin_path)) { + if ( ! is_dir( $plugin_path)) { $response = activate_plugin($plugin_path); } @@ -65,22 +63,101 @@ export const activatePlugin: StepHandler = async ( } } - if ( null === $response ) { - die('Plugin activated successfully'); - } else if ( is_wp_error( $response ) ) { - throw new Exception( $response->get_error_message() ); + if ( is_wp_error($response) ) { + die( $response->get_error_message() ); + } else if ( false === $response ) { + die( "The activatePlugin step wasn't able to find the plugin $plugin_path." ); } - - throw new Exception( 'Unable to activate plugin' ); `, + env: { + PLUGIN_PATH: pluginPath, + DOCROOT: docroot, + }, }); - if (result.text !== 'Plugin activated successfully') { - logger.debug(result); - throw new Error( - `Plugin ${pluginPath} could not be activated – WordPress exited with no error. ` + - `Sometimes, when $_SERVER or site options are not configured correctly, ` + - `WordPress exits early with a 301 redirect. ` + - `Inspect the "debug" logs in the console for more details` + if (activatePluginResult.text) { + logger.warn( + `Plugin ${pluginPath} activation printed the following bytes: ${activatePluginResult.text}` ); } + + /** + * Instead of checking the plugin activation response, + * check if the plugin is active by looking at the active plugins list. + * + * We have to split the activation and the check into two PHP runs + * because some plugins might redirect during activation, + * which would prevent any output that happens after activation from being returned. + * + * Relying on the plugin activation response is not reliable because if the plugin activation + * produces any output, WordPress will assume it's an activation error and return a WP_Error. + * WordPress will still activate the plugin and load the required page, + * but it will also show the error as a notice in wp-admin. + * See WordPress source code for more details: + * https://github.com/WordPress/wordpress-develop/blob/6.7/src/wp-admin/includes/plugin.php#L733 + * + * Because some plugins can create an output, we need to use output buffering + * to ensure the 'true' response is not polluted by other outputs. + * If the plugin activation fails, we will return the buffered output as it might + * contain more information about the failure. + */ + const isActiveCheckResult = await playground.run({ + code: `block_markup = $block_markup; + $this->state = array( + 'indent' => array(), + 'listStyle' => array() + ); + } + + private $markdown; + + public function convert() { + $this->markdown = $this->blocks_to_markdown(parse_blocks($this->block_markup)); + } + + public function get_result() { + return $this->markdown; + } + + private function blocks_to_markdown($blocks) { + $output = ''; + foreach ($blocks as $block) { + array_push($this->parents, $block['blockName']); + $output .= $this->block_to_markdown($block); + array_pop($this->parents); + } + return $output; + } + + private function block_to_markdown($block) { + $block_name = $block['blockName']; + $attributes = $block['attrs'] ?? array(); + $inner_html = $block['innerHTML'] ?? ''; + $inner_blocks = $block['innerBlocks'] ?? array(); + + switch ($block_name) { + case 'core/paragraph': + return $this->html_to_markdown($inner_html) . "\n\n"; + + case 'core/quote': + $content = $this->blocks_to_markdown($inner_blocks); + $lines = explode("\n", $content); + return implode("\n", array_map(function($line) { + return "> $line"; + }, $lines)) . "\n\n"; + + case 'core/code': + $code = $this->html_to_markdown($inner_html); + $language = $attributes['language'] ?? ''; + $fence = str_repeat('`', max(3, $this->longest_sequence_of($code, '`') + 1)); + return "{$fence}{$language}\n{$code}\n{$fence}\n\n"; + + case 'core/image': + return "![" . ($attributes['alt'] ?? '') . "](" . ($attributes['url'] ?? '') . ")\n\n"; + + case 'core/heading': + $level = $attributes['level'] ?? 1; + $content = $this->html_to_markdown($inner_html); + return str_repeat('#', $level) . ' ' . $content . "\n\n"; + + case 'core/table': + // Accumulate all the table contents to compute the markdown + // column widths. + $processor = WP_Data_Liberation_HTML_Processor::create_fragment($inner_html); + $rows = []; + $header = []; + $in_header = false; + $current_row = []; + + while ($processor->next_token()) { + if ($processor->get_token_type() !== '#tag') { + continue; + } + + $tag = $processor->get_tag(); + $is_closer = $processor->is_tag_closer(); + + if ($tag === 'THEAD' && !$is_closer) { + $in_header = true; + } else if ($tag === 'THEAD' && $is_closer) { + $in_header = false; + } else if ($tag === 'TR' && $is_closer) { + if ($in_header) { + $header = $current_row; + } else { + $rows[] = $current_row; + } + $current_row = []; + } else if (($tag === 'TH' || $tag === 'TD') && !$is_closer) { + $cell_content = $processor->get_inner_html(); + $current_row[] = $this->html_to_markdown($cell_content); + $processor->skip_to_closer(); + } + } + + if (empty($header) && !empty($rows)) { + $header = array_shift($rows); + } + + if (empty($header)) { + return ''; + } + + $col_widths = array_map('strlen', $header); + foreach ($rows as $row) { + foreach ($row as $i => $cell) { + $col_widths[$i] = max($col_widths[$i], strlen($cell)); + } + } + + $padded_header = array_map(function($cell, $width) { + return str_pad($cell, $width); + }, $header, $col_widths); + $markdown = "| " . implode(" | ", $padded_header) . " |\n"; + + $separator_cells = array_map(function($width) { + return str_repeat("-", $width + 2); + }, $col_widths); + $markdown .= "|" . implode("|", $separator_cells) . "|\n"; + + foreach ($rows as $row) { + $padded_cells = array_map(function($cell, $width) { + return str_pad($cell, $width); + }, $row, $col_widths); + $markdown .= "| " . implode(" | ", $padded_cells) . " |\n"; + } + + return $markdown . "\n"; + + case 'core/list': + array_push($this->state['listStyle'], array( + 'style' => isset($attributes['ordered']) ? ($attributes['type'] ?? 'decimal') : '-', + 'count' => $attributes['start'] ?? 1 + )); + $list = $this->blocks_to_markdown($inner_blocks); + array_pop($this->state['listStyle']); + if($this->has_parent('core/list-item')){ + return $list; + } + return $list . "\n"; + + case 'core/list-item': + if (empty($this->state['listStyle'])) { + return ''; + } + + $item = end($this->state['listStyle']); + $bullet = $this->get_list_bullet($item); + $bullet_indent = str_repeat(' ', strlen($bullet) + 1); + + $content = $this->html_to_markdown($inner_html); + $content_parts = explode("\n", $content, 2); + $content_parts = array_map('trim', $content_parts); + $first_line = $content_parts[0]; + $rest_lines = $content_parts[1] ?? ''; + + $item['count']++; + + if (empty($inner_html)) { + $output = implode('', $this->state['indent']) . "$bullet $first_line\n"; + array_push($this->state['indent'], $bullet_indent); + if ($rest_lines) { + $output .= $this->indent($rest_lines, $bullet_indent); + } + array_pop($this->state['indent']); + return $output; + } + + $markdown = $this->indent("$bullet $first_line\n"); + + array_push($this->state['indent'], $bullet_indent); + if($rest_lines){ + $markdown .= $this->indent($rest_lines) . "\n"; + } + $inner_blocks_markdown = $this->blocks_to_markdown( + $inner_blocks + ); + if($inner_blocks_markdown){ + $markdown .= $inner_blocks_markdown . "\n"; + } + array_pop($this->state['indent']); + + $markdown = rtrim($markdown, "\n"); + if($this->has_parent('core/list-item')){ + $markdown .= "\n"; + } else { + $markdown .= "\n\n"; + } + + return $markdown; + + case 'core/separator': + return "\n---\n\n"; + + default: + return ''; + } + } + + private function html_to_markdown($html, $parents = []) { + $processor = WP_Data_Liberation_HTML_Processor::create_fragment($html); + $markdown = ''; + + while ($processor->next_token()) { + if ($processor->get_token_type() === '#text') { + $markdown .= $processor->get_modifiable_text(); + continue; + } else if ($processor->get_token_type() !== '#tag') { + continue; + } + + $last_href = null; + $tag_name = $processor->get_tag(); + $sign = $processor->is_tag_closer() ? '-' : ( + $processor->expects_closer() ? '+' : '' + ); + $event = $sign . $tag_name; + switch ($event) { + case '+B': + case '-B': + case '+STRONG': + case '-STRONG': + $markdown .= '**'; + break; + + case '+I': + case '-I': + case '+EM': + case '-EM': + $markdown .= '*'; + break; + + case '+DEL': + case '-DEL': + $markdown .= '~~'; + break; + + case '+CODE': + case '-CODE': + if(!$this->has_parent('core/code')){ + $markdown .= '`'; + } + break; + + case '+A': + $last_href = $processor->get_attribute('href') ?? ''; + $markdown .= '['; + break; + + case '-A': + $markdown .= "]($last_href)"; + break; + + case 'BR': + $markdown .= "\n"; + break; + } + } + + // The HTML processor gives us all the whitespace verbatim + // as it was encountered in the byte stream. + // Let's normalize it to a single space. + $markdown = trim($markdown, "\n "); + $markdown = preg_replace('/ +/', ' ', $markdown); + $markdown = preg_replace('/\n+/', "\n", $markdown); + return $markdown; + } + + private function has_parent($parent) { + return in_array($parent, $this->parents, true); + } + + private function get_list_bullet($item) { + if ($item['style'] === '-') { + return '-'; + } + return $item['count'] . '.'; + } + + private function indent($string) { + if (empty($this->state['indent'])) { + return $string; + } + + $indent = implode('', $this->state['indent']); + $lines = explode("\n", $string); + return implode("\n", array_map(function($line) use ($indent) { + return empty($line) ? $line : $indent . $line; + }, $lines)); + } + + private function longest_sequence_of($input, $substring) { + $longest = 0; + $current = 0; + $len = strlen($input); + + for ($i = 0; $i < $len; $i++) { + if ($input[$i] === $substring) { + $current++; + $longest = max($longest, $current); + } else { + $current = 0; + } + } + + return $longest; + } +} diff --git a/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php b/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php index f63fb20c52..94da3e484c 100644 --- a/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php +++ b/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php @@ -26,13 +26,11 @@ class WP_Markdown_To_Blocks implements WP_Block_Markup_Converter { const STATE_COMPLETE = 'STATE_COMPLETE'; private $state = self::STATE_READY; - private $root_block; private $block_stack = array(); - private $current_block = null; + private $table_stack = array(); private $frontmatter = array(); private $markdown; - private $parsed_blocks = array(); private $block_markup = ''; public function __construct( $markdown ) { @@ -44,7 +42,7 @@ public function convert() { return false; } $this->convert_markdown_to_blocks(); - $this->block_markup = WP_Import_Utils::convert_blocks_to_markup( $this->parsed_blocks ); + // $this->block_markup = WP_Import_Utils::convert_blocks_to_markup( $this->parsed_blocks ); return true; } @@ -64,10 +62,6 @@ public function get_block_markup() { } private function convert_markdown_to_blocks() { - $this->root_block = $this->create_block( 'post-content' ); - $this->block_stack[] = $this->root_block; - $this->current_block = $this->root_block; - $environment = new Environment( array() ); $environment->addExtension( new CommonMarkCoreExtension() ); $environment->addExtension( new GithubFlavoredMarkdownExtension() ); @@ -81,7 +75,14 @@ private function convert_markdown_to_blocks() { $document = $parser->parse( $this->markdown ); $this->frontmatter = array(); - foreach ( $document->data as $key => $value ) { + foreach ( $document->data->export() as $key => $value ) { + if ( 'attributes' === $key && empty( $value ) ) { + // The Frontmatter extension adds an 'attributes' key to the document data + // even when there is no actual "attributes" key in the frontmatter. + // + // Let's skip it when the value is empty. + continue; + } // Use an array as a value to comply with the WP_Block_Markup_Converter interface. $this->frontmatter[ $key ] = array( $value ); } @@ -105,86 +106,72 @@ private function convert_markdown_to_blocks() { 'heading', array( 'level' => $node->getLevel(), - 'content' => 'getLevel() . '>', ) ); + $this->append_content( 'getLevel() . '>' ); break; case ExtensionBlock\ListBlock::class: - $this->push_block( - 'list', - array( - 'ordered' => $node->getListData()->type === 'ordered', - 'content' => '