first commit

This commit is contained in:
User A0264400
2026-04-01 23:20:16 +03:00
commit a766acdc90
23071 changed files with 4933189 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
{
"name": "woocommerce/email-editor",
"description": "Email editor based on WordPress Gutenberg package.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"prefer-stable": true,
"minimum-stability": "dev",
"version": "2.3.0",
"autoload": {
"classmap": [
"src/",
"vendor-prefixed/"
]
},
"autoload-dev": {
"classmap": [
"tests/unit/"
]
},
"require": {
"php": ">=7.4"
},
"require-dev": {
"automattic/jetpack-changelogger": "3.3.0",
"phpunit/phpunit": "^9.6",
"woocommerce/woocommerce-sniffs": "1.0.0",
"yoast/phpunit-polyfills": "^4.0"
},
"config": {
"platform": {
"php": "7.4"
},
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"scripts": {
"post-install-cmd": [
"composer dump-autoload -o"
],
"post-update-cmd": [
"composer dump-autoload -o"
],
"env:destroy": "wp-env destroy",
"env:stop": "wp-env stop",
"env:start": "wp-env start",
"test:unit": "wp-env run tests-cli --env-cwd=wp-content/plugins/email-editor ./vendor/bin/phpunit",
"test:integration": "wp-env run tests-cli --env-cwd=wp-content/plugins/email-editor ./vendor/bin/phpunit --configuration phpunit-integration.xml.dist",
"phpcs": "phpcs -s -p",
"phpcbf": "phpcbf -p"
},
"extra": {
"changelogger": {
"formatter": {
"filename": "../../../tools/changelogger/class-php-package-formatter.php"
},
"types": {
"fix": "Fixes an existing bug",
"add": "Adds functionality",
"update": "Update existing functionality",
"dev": "Development related task",
"tweak": "A minor adjustment to the codebase",
"performance": "Address performance issues",
"enhancement": "Improve existing functionality"
},
"changelog": "changelog.md"
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
/**
* Plugin Name: Email Editor
* Plugin URI: https://woocommerce.com/
* Description: An empty email-editor definition file to setup wp-env test env.
* Version: 0.0.1
* Author: Automattic
* Author URI: https://woocommerce.com
* Requires at least: 6.7
* Requires PHP: 7.4
*/
$autoload_entry_point = __DIR__ . '/vendor/autoload.php';
if ( file_exists( $autoload_entry_point ) ) {
require_once $autoload_entry_point;
}
// When the package is distributed as part of WooCommerce core, it will provide autoloading of necessary dependencies.

View File

@@ -0,0 +1,365 @@
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
This program incorporates work covered by the following copyright and
permission notices:
html2text is Copyright (c) 2019 Jevon Wright
https://github.com/soundasleep/html2text
html2text is released under the MIT License
https://github.com/soundasleep/html2text/blob/83502b6f8f1aaef8e2e238897199d64f284b4af3/LICENSE.md
===================================
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@@ -0,0 +1,165 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Logger;
/**
* Default implementation of the email editor logger that writes to WordPress debug log.
*/
class Default_Email_Editor_Logger implements Email_Editor_Logger_Interface {
/**
* Log levels.
*/
public const EMERGENCY = 'emergency';
public const ALERT = 'alert';
public const CRITICAL = 'critical';
public const ERROR = 'error';
public const WARNING = 'warning';
public const NOTICE = 'notice';
public const INFO = 'info';
public const DEBUG = 'debug';
/**
* Path to the log file.
*
* @var string
*/
private $log_file;
/**
* Constructor.
*/
public function __construct() {
if ( defined( 'WP_DEBUG_LOG' ) ) {
if ( true === WP_DEBUG_LOG ) {
$this->log_file = WP_CONTENT_DIR . '/debug.log';
} elseif ( is_string( WP_DEBUG_LOG ) && ! empty( WP_DEBUG_LOG ) ) {
$this->log_file = WP_DEBUG_LOG;
} else {
$this->log_file = '';
}
} else {
$this->log_file = '';
}
}
/**
* System is unusable.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function emergency( string $message, array $context = array() ): void {
$this->log( self::EMERGENCY, $message, $context );
}
/**
* Action must be taken immediately.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function alert( string $message, array $context = array() ): void {
$this->log( self::ALERT, $message, $context );
}
/**
* Critical conditions.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function critical( string $message, array $context = array() ): void {
$this->log( self::CRITICAL, $message, $context );
}
/**
* Runtime errors that do not require immediate action but should typically
* be logged and monitored.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function error( string $message, array $context = array() ): void {
$this->log( self::ERROR, $message, $context );
}
/**
* Exceptional occurrences that are not errors.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function warning( string $message, array $context = array() ): void {
$this->log( self::WARNING, $message, $context );
}
/**
* Normal but significant events.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function notice( string $message, array $context = array() ): void {
$this->log( self::NOTICE, $message, $context );
}
/**
* Interesting events.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function info( string $message, array $context = array() ): void {
$this->log( self::INFO, $message, $context );
}
/**
* Detailed debug information.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function debug( string $message, array $context = array() ): void {
$this->log( self::DEBUG, $message, $context );
}
/**
* Logs with an arbitrary level.
*
* @param string $level The log level.
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function log( string $level, string $message, array $context = array() ): void {
if ( ! $this->log_file ) {
return;
}
$entry = sprintf(
'[%s] %s: %s %s',
gmdate( 'Y-m-d H:i:s' ),
strtoupper( $level ),
$message,
! empty( $context ) ? wp_json_encode( $context ) : ''
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- This is a logging class, error_log is the intended functionality.
error_log( $entry . PHP_EOL, 3, $this->log_file );
}
}

View File

@@ -0,0 +1,98 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Logger;
/**
* Interface for email editor loggers.
*/
interface Email_Editor_Logger_Interface {
/**
* System is unusable.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function emergency( string $message, array $context = array() ): void;
/**
* Action must be taken immediately.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function alert( string $message, array $context = array() ): void;
/**
* Critical conditions.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function critical( string $message, array $context = array() ): void;
/**
* Runtime errors that do not require immediate action but should typically
* be logged and monitored.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function error( string $message, array $context = array() ): void;
/**
* Exceptional occurrences that are not errors.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function warning( string $message, array $context = array() ): void;
/**
* Normal but significant events.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function notice( string $message, array $context = array() ): void;
/**
* Interesting events.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function info( string $message, array $context = array() ): void;
/**
* Detailed debug information.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function debug( string $message, array $context = array() ): void;
/**
* Logs with an arbitrary level.
*
* @param string $level The log level.
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function log( string $level, string $message, array $context = array() ): void;
}

View File

@@ -0,0 +1,142 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Logger;
/**
* Email Editor Logger class.
* A wrapper that sets the logger to use. If no logger is provided, it defaults to the Default_Email_Editor_Logger.
*/
class Email_Editor_Logger implements Email_Editor_Logger_Interface {
/**
* Logger instance to delegate to.
*
* @var Email_Editor_Logger_Interface
*/
private Email_Editor_Logger_Interface $logger;
/**
* Constructor.
*
* @param Email_Editor_Logger_Interface|null $logger Logger instance.
*/
public function __construct( ?Email_Editor_Logger_Interface $logger = null ) {
$this->logger = $logger ?? new Default_Email_Editor_Logger();
}
/**
* Set the logger.
*
* @param Email_Editor_Logger_Interface $logger Logger instance.
* @return void
*/
public function set_logger( Email_Editor_Logger_Interface $logger ): void {
$this->logger = $logger;
}
/**
* Adds emergency level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function emergency( string $message, array $context = array() ): void {
$this->logger->emergency( $message, $context );
}
/**
* Adds alert level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function alert( string $message, array $context = array() ): void {
$this->logger->alert( $message, $context );
}
/**
* Adds critical level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function critical( string $message, array $context = array() ): void {
$this->logger->critical( $message, $context );
}
/**
* Adds error level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function error( string $message, array $context = array() ): void {
$this->logger->error( $message, $context );
}
/**
* Adds warning level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function warning( string $message, array $context = array() ): void {
$this->logger->warning( $message, $context );
}
/**
* Adds notice level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function notice( string $message, array $context = array() ): void {
$this->logger->notice( $message, $context );
}
/**
* Adds info level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function info( string $message, array $context = array() ): void {
$this->logger->info( $message, $context );
}
/**
* Adds debug level log message.
*
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function debug( string $message, array $context = array() ): void {
$this->logger->debug( $message, $context );
}
/**
* Logs with an arbitrary level.
*
* @param string $level The log level.
* @param string $message The log message.
* @param array $context The log context.
* @return void
*/
public function log( string $level, string $message, array $context = array() ): void {
$this->logger->log( $level, $message, $context );
}
}

View File

@@ -0,0 +1,130 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Patterns;
/**
* Abstract class for block patterns.
*/
abstract class Abstract_Pattern {
/**
* Name of the pattern.
*
* @var string $name
*/
protected $name = '';
/**
* Namespace of the pattern.
*
* @var string $namespace
*/
protected $namespace = '';
/**
* List of block types.
*
* @var array $block_types
*/
protected $block_types = array();
/**
* List of template types.
*
* @var string[] $template_types
*/
protected $template_types = array();
/**
* List of supported post types.
*
* @var string[] $post_types
*/
protected $post_types = array();
/**
* Flag to enable/disable inserter.
*
* @var bool $inserter
*/
protected $inserter = true;
/**
* Source of the pattern.
*
* @var string $source
*/
protected $source = 'plugin';
/**
* List of categories.
*
* @var array $categories
*/
protected $categories = array();
/**
* Viewport width.
*
* @var int $viewport_width
*/
protected $viewport_width = 620;
/**
* Get name of the pattern.
*
* @return string
*/
public function get_name(): string {
return $this->name;
}
/**
* Get namespace of the pattern.
*
* @return string
*/
public function get_namespace(): string {
return $this->namespace;
}
/**
* Return properties of the pattern.
*
* @return array
*/
public function get_properties(): array {
return array(
'title' => $this->get_title(),
'content' => $this->get_content(),
'description' => $this->get_description(),
'categories' => $this->categories,
'inserter' => $this->inserter,
'blockTypes' => $this->block_types,
'templateTypes' => $this->template_types,
'postTypes' => $this->post_types,
'source' => $this->source,
'viewportWidth' => $this->viewport_width,
);
}
/**
* Get content.
*
* @return string
*/
abstract protected function get_content(): string;
/**
* Get title.
*
* @return string
*/
abstract protected function get_title(): string;
/**
* Get description.
*
* @return string
*/
protected function get_description(): string {
return '';
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Patterns;
/**
* Register block patterns.
*/
class Patterns {
/**
* Initialize block patterns.
*
* @return void
*/
public function initialize(): void {
$this->register_block_pattern_categories();
}
/**
* Register block pattern category.
*
* @return void
*/
private function register_block_pattern_categories(): void {
$categories = array(
array(
'name' => 'email-contents',
'label' => _x( 'Email Contents', 'Block pattern category', 'woocommerce' ),
'description' => __( 'A collection of email content layouts.', 'woocommerce' ),
),
);
foreach ( $categories as $category ) {
register_block_pattern_category(
$category['name'],
array(
'label' => $category['label'],
'description' => $category['description'] ?? '',
)
);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags;
use WP_HTML_Tag_Processor;
use WP_HTML_Text_Replacement;
/**
* Class based on WP_HTML_Tag_Processor which is extended to replace
* tokens with their values in the email content.
*
* This class was inspired by a concept from the WordPress core,
* which could help us to avoid refactoring in the future.
*/
class HTML_Tag_Processor extends WP_HTML_Tag_Processor {
/**
* List of deferred updates which will be replaced after calling flush_updates().
*
* @var WP_HTML_Text_Replacement[]
*/
private $deferred_updates = array();
/**
* Replaces the token with the new content.
*
* @param string $new_content The new content to replace the token.
*/
public function replace_token( string $new_content ): void {
$this->set_bookmark( 'here' );
$here = $this->bookmarks['here'];
$this->deferred_updates[] = new WP_HTML_Text_Replacement(
$here->start,
$here->length,
$new_content
);
}
/**
* Flushes the deferred updates to the lexical updates.
*/
public function flush_updates(): void {
foreach ( $this->deferred_updates as $key => $update ) {
$this->lexical_updates[] = $update;
unset( $this->deferred_updates[ $key ] );
}
}
}

View File

@@ -0,0 +1,203 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags;
/**
* The class represents a personalization tag that contains all necessary information
* for replacing the tag with its value and displaying it in the UI.
*/
class Personalization_Tag {
/**
* The name of the tag displayed in the UI.
*
* @var string
*/
private string $name;
/**
* The token which is used in HTML_Tag_Processor to replace the tag with its value.
*
* @var string
*/
private string $token;
/**
* The category of the personalization tag for categorization on the UI.
*
* @var string
*/
private string $category;
/**
* The callback function which returns the value of the personalization tag.
*
* @var callable
*/
private $callback;
/**
* The attributes which are used in the Personalization Tag UI.
*
* @var array
*/
private array $attributes;
/**
* The value that is inserted via the UI. When the value is null the token is generated based on $token attribute and $attributes.
*
* @var string
*/
private string $value_to_insert;
/**
* The list of supported post types.
*
* @var string[]
*/
private array $post_types;
/**
* Personalization_Tag constructor.
*
* Example usage:
* $tag = new Personalization_Tag(
* 'First Name',
* 'user:first_name',
* 'User',
* function( $context, $args ) {
* return $context['user_firstname'] ?? 'user';
* },
* array( default => 'user' ),
* 'user:first default="user"'
* );
*
* @param string $name The name of the tag displayed in the UI.
* @param string $token The token used in HTML_Tag_Processor to replace the tag with its value.
* @param string $category The category of the personalization tag for categorization on the UI.
* @param callable $callback The callback function which returns the value of the personalization tag.
* @param array $attributes The attributes which are used in the Personalization Tag UI.
* @param string|null $value_to_insert The value that is inserted via the UI. When the value is null the token is generated based on $token attribute and $attributes.
* @param string[] $post_types The list of supported post types.
*/
public function __construct(
string $name,
string $token,
string $category,
callable $callback,
array $attributes = array(),
?string $value_to_insert = null,
array $post_types = array()
) {
$this->name = $name;
// Because Gutenberg does not wrap the token with square brackets, we need to add them here.
$this->token = strpos( $token, '[' ) === 0 ? $token : "[$token]";
$this->category = $category;
$this->callback = $callback;
$this->attributes = $attributes;
// Composing token to insert based on the token and attributes if it is not set.
if ( ! $value_to_insert ) {
if ( $this->attributes ) {
$value_to_insert = substr( $this->token, 0, -1 ) . ' ' .
implode(
' ',
array_map(
function ( $key ) {
return $key . '="' . esc_attr( $this->attributes[ $key ] ) . '"';
},
array_keys( $this->attributes )
)
) . ']';
} else {
$value_to_insert = $this->token;
}
}
$this->value_to_insert = $value_to_insert;
$this->post_types = $post_types;
}
/**
* Prevents deserialization of this class to avoid callback replacement attacks.
*
* @param array $data The serialized data.
* @return void
* @throws \Exception Always throws an exception to prevent deserialization.
*/
public function __unserialize( array $data ): void {
throw new \Exception( 'Deserialization of Personalization_Tag is not allowed for security reasons.' );
}
/**
* Returns the name of the personalization tag.
*
* @return string
*/
public function get_name(): string {
return $this->name;
}
/**
* Returns the token of the personalization tag.
*
* @return string
*/
public function get_token(): string {
return $this->token;
}
/**
* Returns the category of the personalization tag.
*
* @return string
*/
public function get_category(): string {
return $this->category;
}
/**
* Returns the attributes of the personalization tag.
*
* @return array
*/
public function get_attributes(): array {
return $this->attributes;
}
/**
* Returns the token to insert via UI in the editor.
*
* @return string
*/
public function get_value_to_insert(): string {
return $this->value_to_insert;
}
/**
* Returns the list of supported post types.
*
* @return array|string[]
*/
public function get_post_types(): array {
return $this->post_types;
}
/**
* Returns the callback function of the personalization tag.
*
* @return callable
*/
public function get_callback(): callable {
return $this->callback;
}
/**
* Executes the callback function for the personalization tag.
*
* @param mixed $context The context for the personalization tag.
* @param array $args The additional arguments for the callback.
* @return string The value of the personalization tag.
*/
public function execute_callback( $context, $args = array() ): string {
return call_user_func( $this->callback, ...array_merge( array( $context ), array( $args ) ) );
}
}

View File

@@ -0,0 +1,140 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags;
use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
/**
* Registry for personalization tags.
*/
class Personalization_Tags_Registry {
/**
* Logger instance.
*
* @var Email_Editor_Logger
*/
private Email_Editor_Logger $logger;
/**
* List of registered personalization tags.
*
* @var Personalization_Tag[]
*/
private $tags = array();
/**
* Constructor.
*
* @param Email_Editor_Logger $logger Logger instance.
*/
public function __construct( Email_Editor_Logger $logger ) {
$this->logger = $logger;
}
/**
* Initialize the personalization tags registry.
* This method should be called only once.
*
* @return void
*/
public function initialize(): void {
$this->logger->info( 'Initializing personalization tags registry' );
apply_filters( 'woocommerce_email_editor_register_personalization_tags', $this );
$this->logger->info( 'Personalization tags registry initialized', array( 'tags_count' => count( $this->tags ) ) );
}
/**
* Register a new personalization instance in the registry.
*
* @param Personalization_Tag $tag The personalization tag to register.
* @return void
*/
public function register( Personalization_Tag $tag ): void {
if ( isset( $this->tags[ $tag->get_token() ] ) ) {
$this->logger->warning(
'Personalization tag already registered',
array(
'token' => $tag->get_token(),
'name' => $tag->get_name(),
'category' => $tag->get_category(),
)
);
return;
}
$this->tags[ $tag->get_token() ] = $tag;
$this->logger->debug(
'Personalization tag registered',
array(
'token' => $tag->get_token(),
'name' => $tag->get_name(),
'category' => $tag->get_category(),
)
);
}
/**
* Unregister a personalization tag by its token or tag instance.
*
* @param string|Personalization_Tag $token_or_tag The token string or Personalization_Tag instance to unregister.
* @return Personalization_Tag|null The unregistered tag or null if not found.
*/
public function unregister( $token_or_tag ): ?Personalization_Tag {
// Extract token from the argument.
if ( $token_or_tag instanceof Personalization_Tag ) {
$token = $token_or_tag->get_token();
} elseif ( is_string( $token_or_tag ) ) {
$token = $token_or_tag;
} else {
$this->logger->warning(
'Invalid argument type for unregister method',
array(
'type' => gettype( $token_or_tag ),
)
);
return null;
}
$tag = $this->tags[ $token ] ?? null;
if ( $tag ) {
unset( $this->tags[ $token ] );
$this->logger->debug(
'Personalization tag unregistered',
array(
'token' => $token,
'name' => $tag->get_name(),
'category' => $tag->get_category(),
)
);
}
return $tag;
}
/**
* Retrieve a personalization tag by its token.
* Example: get_by_token( 'user:first_name' ) will return the instance of Personalization_Tag with identical token.
*
* @param string $token The token of the personalization tag.
* @return Personalization_Tag|null The array data or null if not found.
*/
public function get_by_token( string $token ): ?Personalization_Tag {
return $this->tags[ $token ] ?? null;
}
/**
* Retrieve all registered personalization tags.
*
* @return array List of all registered personalization tags.
*/
public function get_all() {
return $this->tags;
}
}

View File

@@ -0,0 +1,122 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Layout;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
/**
* This class provides functionality to render inner blocks of a block that supports reduced flex layout.
*/
class Flex_Layout_Renderer {
/**
* Render inner blocks in flex layout.
*
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
public function render_inner_blocks_in_layout( array $parsed_block, Rendering_Context $rendering_context ): string {
$theme_styles = $rendering_context->get_theme_styles();
$flex_gap = $theme_styles['spacing']['blockGap'] ?? '0px';
$flex_gap_number = Styles_Helper::parse_value( $flex_gap );
$margin_top = $parsed_block['email_attrs']['margin-top'] ?? '0px';
$justify = $parsed_block['attrs']['layout']['justifyContent'] ?? 'left';
$styles = wp_style_engine_get_styles( $parsed_block['attrs']['style'] ?? array() )['css'] ?? '';
$styles .= 'margin-top: ' . $margin_top . ';';
$styles .= 'text-align: ' . $justify;
// MS Outlook doesn't support style attribute in divs so we conditionally wrap the buttons in a table and repeat styles.
$output_html = sprintf(
'<!--[if mso | IE]><table align="%2$s" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%"><tr><td style="%1$s" ><![endif]-->
<div style="%1$s"><table class="layout-flex-wrapper" style="display:inline-block"><tbody><tr>',
esc_attr( $styles ),
esc_attr( $justify )
);
$inner_blocks = $this->compute_widths_for_flex_layout( $parsed_block, $flex_gap_number );
foreach ( $inner_blocks as $key => $block ) {
$styles = array();
if ( $block['email_attrs']['layout_width'] ?? null ) {
$styles['width'] = $block['email_attrs']['layout_width'];
}
if ( $key > 0 ) {
$styles['padding-left'] = $flex_gap;
}
$output_html .= '<td class="layout-flex-item" style="' . esc_attr( \WP_Style_Engine::compile_css( $styles, '' ) ) . '">' . render_block( $block ) . '</td>';
}
$output_html .= '</tr></table></div>
<!--[if mso | IE]></td></tr></table><![endif]-->';
return $output_html;
}
/**
* Compute widths for blocks in flex layout.
*
* @param array $parsed_block Parsed block.
* @param float $flex_gap Flex gap.
* @return array
*/
private function compute_widths_for_flex_layout( array $parsed_block, float $flex_gap ): array {
// When there is no parent width we can't compute widths so auto width will be used.
if ( ! isset( $parsed_block['email_attrs']['width'] ) ) {
return $parsed_block['innerBlocks'] ?? array();
}
$blocks_count = count( $parsed_block['innerBlocks'] );
$total_used_width = 0; // Total width assuming items without set width would consume proportional width.
$parent_width = Styles_Helper::parse_value( $parsed_block['email_attrs']['width'] );
$inner_blocks = $parsed_block['innerBlocks'] ?? array();
foreach ( $inner_blocks as $key => $block ) {
$block_width_percent = ( $block['attrs']['width'] ?? 0 ) ? intval( $block['attrs']['width'] ) : 0;
$block_width = floor( $parent_width * ( $block_width_percent / 100 ) );
// If width is not set, we assume it's 25% of the parent width.
$total_used_width += $block_width ? $block_width : floor( $parent_width * ( 25 / 100 ) );
if ( ! $block_width ) {
$inner_blocks[ $key ]['email_attrs']['layout_width'] = null; // Will be rendered as auto.
continue;
}
$inner_blocks[ $key ]['email_attrs']['layout_width'] = $this->get_width_without_gap( $block_width, $flex_gap, $block_width_percent ) . 'px';
}
// When there is only one block, or percentage is set reasonably we don't need to adjust and just render as set by user.
if ( $blocks_count <= 1 || ( $total_used_width <= $parent_width ) ) {
return $inner_blocks;
}
foreach ( $inner_blocks as $key => $block ) {
$proportional_space_overflow = $parent_width / $total_used_width;
$block_width = $block['email_attrs']['layout_width'] ? Styles_Helper::parse_value( $block['email_attrs']['layout_width'] ) : 0;
$block_proportional_width = $block_width * $proportional_space_overflow;
$block_proportional_percentage = ( $block_proportional_width / $parent_width ) * 100;
$inner_blocks[ $key ]['email_attrs']['layout_width'] = $block_width ? $this->get_width_without_gap( $block_proportional_width, $flex_gap, $block_proportional_percentage ) . 'px' : null;
}
return $inner_blocks;
}
/**
* How much of width we will strip to keep some space for the gap
* This is computed based on CSS rule used in the editor:
* For block with width set to X percent
* width: calc(X% - (var(--wp--style--block-gap) * (100 - X)/100)));
*
* @param float $block_width Block width in pixels.
* @param float $flex_gap Flex gap in pixels.
* @param float $block_width_percent Block width in percent.
* @return int
*/
private function get_width_without_gap( float $block_width, float $flex_gap, float $block_width_percent ): int {
$width_gap_reduction = $flex_gap * ( ( 100 - $block_width_percent ) / 100 );
return intval( floor( $block_width - $width_gap_reduction ) );
}
}

View File

@@ -0,0 +1,275 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
/**
* Postprocessor that handles border-style declarations to ensure consistent rendering across email clients.
*
* This postprocessor addresses two main issues:
*
* 1. Normalize border-style declarations:
* When using a uniform border-style declaration with non-uniform border-widths,
* some email clients (like Outlook) will incorrectly display borders on all sides
* even when the width is 0. For example:
* `border-color: #000000; border-style: solid; border-width: 0 1px 0 0;`
* would render borders on all sides in Outlook. This postprocessor normalizes
* the border-style declarations to only set styles for sides where border-width > 0:
* `border-color: currentColor; border-width: 0 1px 0 0; border-right-style: solid;`
*
* 2. Add fallback border styles:
* The block editor provides a default solid style for borders that have a width
* but no style specified. This postprocessor adds the same `border-style: solid`
* fallback to ensure the email rendering matches what users see in the editor.
*
* The postprocessor handles all border cases including:
* - Shorthand border declarations (border: 1px solid black)
* - Individual side declarations (border-top, border-right, etc.)
* - Individual property declarations (border-width, border-style, etc.)
* - Mixed combinations of the above
*/
class Border_Style_Postprocessor implements Postprocessor {
/**
* Postprocess the HTML.
*
* @param string $html HTML to postprocess.
* @return string
*/
public function postprocess( string $html ): string {
$processor = new \WP_HTML_Tag_Processor( $html );
while ( $processor->next_tag() ) {
$style = $processor->get_attribute( 'style' );
if ( null !== $style && true !== $style ) {
$processed_style = $this->process_style( $style );
if ( $processed_style !== $style ) {
$processor->set_attribute( 'style', $processed_style );
}
}
}
return $processor->get_updated_html();
}
/**
* Processes a style string to ensure border-style is set for borders with width > 0 and removes extra border-style properties.
*
* @param string $style The style attribute value.
* @return string
*/
private function process_style( string $style ): string {
// Parse style into associative array.
$styles = array();
foreach ( explode( ';', $style ) as $declaration ) {
if ( strpos( $declaration, ':' ) !== false ) {
list( $prop, $value ) = array_map( 'trim', explode( ':', $declaration, 2 ) );
$styles[ strtolower( $prop ) ] = $value;
}
}
$should_update_style = false;
// Collect border-widths and styles.
$border_widths = array();
$border_styles = array();
foreach ( $styles as $prop => $value ) {
if ( 'border' === $prop ) {
$border_width = $this->extract_width_from_shorthand_value( $value );
if ( $border_width ) {
$border_widths['top'] = $border_width;
$border_widths['right'] = $border_width;
$border_widths['bottom'] = $border_width;
$border_widths['left'] = $border_width;
}
$border_style = $this->extract_style_from_shorthand_value( $value );
if ( $border_style ) {
$border_styles['top'] = $border_style;
$border_styles['right'] = $border_style;
$border_styles['bottom'] = $border_style;
$border_styles['left'] = $border_style;
}
}
if ( preg_match( '/^border-(top|right|bottom|left)$/', $prop, $matches ) ) {
$border_width = $this->extract_width_from_shorthand_value( $value );
if ( $border_width ) {
$border_widths[ $matches[1] ] = $border_width;
}
$border_style = $this->extract_style_from_shorthand_value( $value );
if ( $border_style ) {
$border_styles[ $matches[1] ] = $border_style;
}
}
if ( 'border-width' === $prop ) {
$border_widths = array_merge( $border_widths, $this->expand_shorthand_value( $value ) );
}
if ( preg_match( '/^border-(top|right|bottom|left)-width$/', $prop, $matches ) ) {
$border_widths[ $matches[1] ] = $value;
}
if ( 'border-style' === $prop ) {
$border_styles = array_merge( $border_styles, $this->expand_shorthand_value( $value ) );
// Remove the original border style declaration, as it will be added later.
unset( $styles[ $prop ] );
$should_update_style = true;
}
if ( preg_match( '/^border-(top|right|bottom|left)-style$/', $prop, $matches ) ) {
$border_styles[ $matches[1] ] = $value;
// Remove the original border style declaration, as it will be added later.
unset( $styles[ $prop ] );
$should_update_style = true;
}
}
if ( array_diff( array_keys( $border_widths ), array_keys( $border_styles ) ) ) {
$should_update_style = true;
}
if ( ! $should_update_style ) {
return $style;
}
$border_styles_declarations = array();
foreach ( $border_widths as $side => $value ) {
if ( $this->is_nonzero_width( $value ) ) {
$border_styles_declarations[ "border-$side-style" ] = isset( $border_styles[ $side ] ) ? $border_styles[ $side ] : 'solid';
}
}
// If border style declarations for all sides are present and have the same value, use shorthand syntax.
if ( 4 === count( $border_styles_declarations ) && 1 === count( array_unique( $border_styles_declarations ) ) ) {
$border_styles_declarations = array( 'border-style' => array_values( $border_styles_declarations )[0] );
}
$merged_styles = array_merge( $styles, $border_styles_declarations );
$updated_style = '';
foreach ( $merged_styles as $prop => $value ) {
if ( '' !== $value ) {
$updated_style .= $prop . ': ' . $value . '; ';
}
}
return trim( $updated_style );
}
/**
* Expands shorthand border width and style values into individual properties.
*
* @param string $value The shorthand border value.
* @return array<string, string> The expanded border values.
*/
private function expand_shorthand_value( string $value ): array {
$values = preg_split( '/\s+/', trim( $value ) );
if ( ! is_array( $values ) ) {
return array();
}
$count = count( $values );
if ( 4 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[1] ?? '',
'bottom' => $values[2] ?? '',
'left' => $values[3] ?? '',
);
}
if ( 3 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[1] ?? '',
'bottom' => $values[2] ?? '',
'left' => $values[1] ?? '',
);
}
if ( 2 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[1] ?? '',
'bottom' => $values[0] ?? '',
'left' => $values[1] ?? '',
);
}
if ( 1 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[0] ?? '',
'bottom' => $values[0] ?? '',
'left' => $values[0] ?? '',
);
}
return array();
}
/**
* Extracts the width from a shorthand value.
*
* @param string $value The shorthand value.
* @return string|null The extracted width or null if no width is found.
*/
private function extract_width_from_shorthand_value( string $value ): ?string {
$parts = preg_split( '/\s+/', trim( $value ) );
if ( ! is_array( $parts ) ) {
return null;
}
foreach ( $parts as $part ) {
if ( preg_match( '/^\d+([a-z%]+)?$/', $part ) ) {
return $part;
}
}
return null;
}
/**
* Extracts the style from a shorthand value.
*
* @param string $value The shorthand value.
* @return string|null The extracted style or null if no style is found.
*/
private function extract_style_from_shorthand_value( string $value ): ?string {
$parts = preg_split( '/\s+/', trim( $value ) );
if ( ! is_array( $parts ) ) {
return null;
}
foreach ( $parts as $part ) {
if ( in_array( $part, array( 'none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset' ), true ) ) {
return $part;
}
}
return null;
}
/**
* Checks if a border width is nonzero.
*
* @param string $width The width value.
* @return bool
*/
private function is_nonzero_width( string $width ): bool {
return preg_match( '/^0([a-z%]+)?$/', trim( $width ) ) ? false : true;
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
/**
* This postprocessor replaces <mark> tags with <span> tags because mark tags are not supported across all email clients
*/
class Highlighting_Postprocessor implements Postprocessor {
/**
* Postprocess the HTML.
*
* @param string $html HTML to postprocess.
* @return string
*/
public function postprocess( string $html ): string {
return str_replace(
array( '<mark', '</mark>' ),
array( '<span', '</span>' ),
$html
);
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
/**
* In some case the blocks HTML contains CSS variables.
* For example when spacing is set from a preset the inline styles contain var(--wp--preset--spacing--10), var(--wp--preset--spacing--20) etc.
* This postprocessor uses variables from theme.json and replaces the CSS variables with their values in final email HTML.
*/
class Variables_Postprocessor implements Postprocessor {
/**
* Instance of Theme_Controller.
*
* @var Theme_Controller Theme controller.
*/
private Theme_Controller $theme_controller;
/**
* Constructor.
*
* @param Theme_Controller $theme_controller Theme controller.
*/
public function __construct(
Theme_Controller $theme_controller
) {
$this->theme_controller = $theme_controller;
}
/**
* Postprocess the HTML.
*
* @param string $html HTML to postprocess.
* @return string
*/
public function postprocess( string $html ): string {
$variables = $this->theme_controller->get_variables_values_map();
$replacements = array();
foreach ( $variables as $name => $value ) {
$var_pattern = '/' . preg_quote( 'var(' . $name . ')', '/' ) . '/i';
$replacements[ $var_pattern ] = $value;
}
// We want to replace the CSS variables only in the style attributes to avoid replacing the actual content.
$processor = new \WP_HTML_Tag_Processor( $html );
while ( $processor->next_tag() ) {
$style = $processor->get_attribute( 'style' );
if ( null !== $style && true !== $style ) {
// Replace CSS variables with their values.
$processed_style = preg_replace( array_keys( $replacements ), array_values( $replacements ), $style );
if ( null !== $processed_style ) {
$processor->set_attribute( 'style', $processed_style );
}
}
}
return $processor->get_updated_html();
}
}

View File

@@ -0,0 +1,22 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
/**
* Interface for postprocessors.
*/
interface Postprocessor {
/**
* Postprocess the HTML.
*
* @param string $html HTML to postprocess.
* @return string
*/
public function postprocess( string $html ): string;
}

View File

@@ -0,0 +1,139 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
/**
* This class sets the width of the blocks based on the layout width or column count.
* The final width in pixels is stored in the email_attrs array because we would like to avoid changing the original attributes.
*/
class Blocks_Width_Preprocessor implements Preprocessor {
/**
* Method to preprocess the content before rendering
*
* @param array $parsed_blocks Parsed blocks of the email.
* @param array{contentSize: string} $layout Layout of the email.
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles Styles of the email.
* @return array
*/
public function preprocess( array $parsed_blocks, array $layout, array $styles ): array {
foreach ( $parsed_blocks as $key => $block ) {
// Layout width is recalculated for each block because full-width blocks don't exclude padding.
$layout_width = $this->parse_number_from_string_with_pixels( $layout['contentSize'] );
$alignment = $block['attrs']['align'] ?? null;
// Subtract padding from the block width if it's not full-width.
if ( 'full' !== $alignment ) {
$layout_width -= $this->parse_number_from_string_with_pixels( $styles['spacing']['padding']['left'] ?? '0px' );
$layout_width -= $this->parse_number_from_string_with_pixels( $styles['spacing']['padding']['right'] ?? '0px' );
}
$width_input = $block['attrs']['width'] ?? '100%';
// Currently we support only % and px units in case only the number is provided we assume it's %
// because editor saves percent values as a number.
$width_input = is_numeric( $width_input ) ? "$width_input%" : $width_input;
$width_input = is_string( $width_input ) ? $width_input : '100%';
$width = $this->convert_width_to_pixels( $width_input, $layout_width );
if ( 'core/columns' === $block['blockName'] ) {
// Calculate width of the columns based on the layout width and padding.
$columns_width = $layout_width;
$columns_width -= $this->parse_number_from_string_with_pixels( $block['attrs']['style']['spacing']['padding']['left'] ?? '0px' );
$columns_width -= $this->parse_number_from_string_with_pixels( $block['attrs']['style']['spacing']['padding']['right'] ?? '0px' );
$border_width = $block['attrs']['style']['border']['width'] ?? '0px';
$columns_width -= $this->parse_number_from_string_with_pixels( $block['attrs']['style']['border']['left']['width'] ?? $border_width );
$columns_width -= $this->parse_number_from_string_with_pixels( $block['attrs']['style']['border']['right']['width'] ?? $border_width );
$block['innerBlocks'] = $this->add_missing_column_widths( $block['innerBlocks'], $columns_width );
}
// Copy layout styles and update width and padding.
$modified_layout = $layout;
$modified_layout['contentSize'] = "{$width}px";
$modified_styles = $styles;
$modified_styles['spacing']['padding']['left'] = $block['attrs']['style']['spacing']['padding']['left'] ?? '0px';
$modified_styles['spacing']['padding']['right'] = $block['attrs']['style']['spacing']['padding']['right'] ?? '0px';
$block['email_attrs']['width'] = "{$width}px";
$block['innerBlocks'] = $this->preprocess( $block['innerBlocks'], $modified_layout, $modified_styles );
$parsed_blocks[ $key ] = $block;
}
return $parsed_blocks;
}
// TODO: We could add support for other units like em, rem, etc.
/**
* Convert width to pixels
*
* @param string $current_width Current width.
* @param float $layout_width Layout width.
* @return float
*/
private function convert_width_to_pixels( string $current_width, float $layout_width ): float {
$width = $layout_width;
if ( strpos( $current_width, '%' ) !== false ) {
$width = (float) str_replace( '%', '', $current_width );
$width = round( $width / 100 * $layout_width );
} elseif ( strpos( $current_width, 'px' ) !== false ) {
$width = $this->parse_number_from_string_with_pixels( $current_width );
}
return $width;
}
/**
* Parse number from string with pixels
*
* @param string $value Value with pixels.
* @return float
*/
private function parse_number_from_string_with_pixels( string $value ): float {
return (float) str_replace( 'px', '', $value );
}
/**
* Add missing column widths
*
* @param array $columns Columns.
* @param float $columns_width Columns width.
* @return array
*/
private function add_missing_column_widths( array $columns, float $columns_width ): array {
$columns_count_with_defined_width = 0;
$defined_column_width = 0;
$columns_count = count( $columns );
foreach ( $columns as $column ) {
if ( isset( $column['attrs']['width'] ) && ! empty( $column['attrs']['width'] ) ) {
++$columns_count_with_defined_width;
$defined_column_width += $this->convert_width_to_pixels( $column['attrs']['width'], $columns_width );
} else {
// When width is not set we need to add padding to the defined column width for better ratio accuracy.
$defined_column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['spacing']['padding']['left'] ?? '0px' );
$defined_column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['spacing']['padding']['right'] ?? '0px' );
$border_width = $column['attrs']['style']['border']['width'] ?? '0px';
$defined_column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['border']['left']['width'] ?? $border_width );
$defined_column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['border']['right']['width'] ?? $border_width );
}
}
if ( $columns_count - $columns_count_with_defined_width > 0 ) {
$default_columns_width = round( ( $columns_width - $defined_column_width ) / ( $columns_count - $columns_count_with_defined_width ), 2 );
foreach ( $columns as $key => $column ) {
if ( ! isset( $column['attrs']['width'] ) || empty( $column['attrs']['width'] ) ) {
// Add padding to the specific column width because it's not included in the default width.
$column_width = $default_columns_width;
$column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['spacing']['padding']['left'] ?? '0px' );
$column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['spacing']['padding']['right'] ?? '0px' );
$border_width = $column['attrs']['style']['border']['width'] ?? '0px';
$column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['border']['left']['width'] ?? $border_width );
$column_width += $this->parse_number_from_string_with_pixels( $column['attrs']['style']['border']['right']['width'] ?? $border_width );
$columns[ $key ]['attrs']['width'] = "{$column_width}px";
}
}
}
return $columns;
}
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
/**
* Class Cleanup_Preprocessor
*/
class Cleanup_Preprocessor implements Preprocessor {
/**
* Method to preprocess the content before rendering
*
* @param array $parsed_blocks Parsed blocks of the email.
* @param array{contentSize: string} $layout Layout of the email.
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles Styles of the email.
* @return array
*/
public function preprocess( array $parsed_blocks, array $layout, array $styles ): array {
foreach ( $parsed_blocks as $key => $block ) {
// https://core.trac.wordpress.org/ticket/45312
// \WP_Block_Parser::parse_blocks() sometimes add a block with name null that can cause unexpected spaces in rendered content
// This behavior was reported as an issue, but it was closed as won't fix.
if ( null === $block['blockName'] && '' === trim( $block['innerHTML'] ?? '' ) ) {
unset( $parsed_blocks[ $key ] );
}
}
return array_values( $parsed_blocks );
}
}

View File

@@ -0,0 +1,122 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
/**
* Class Quote_Preprocessor
*/
class Quote_Preprocessor implements Preprocessor {
/**
* Method to preprocess the content before rendering
*
* @param array $parsed_blocks Parsed blocks of the email.
* @param array{contentSize: string} $layout Layout of the email.
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles Styles of the email.
* @return array
*/
public function preprocess( array $parsed_blocks, array $layout, array $styles ): array {
return $this->process_blocks( $parsed_blocks, $styles );
}
/**
* Recursively process blocks to handle quote block alignment and typography
*
* @param array $blocks The blocks to process.
* @param array $styles The styles from the theme.
* @return array The processed blocks.
*/
private function process_blocks( array $blocks, array $styles ): array {
foreach ( $blocks as &$block ) {
if ( ! isset( $block['innerBlocks'] ) ) {
continue;
}
if ( 'core/quote' === $block['blockName'] ) {
$quote_align = $block['attrs']['textAlign'] ?? null;
$quote_typography = $block['attrs']['style']['typography'] ?? array();
// Apply quote's text alignment to its children.
$block['innerBlocks'] = $this->apply_alignment_to_children( $block['innerBlocks'], $quote_align );
// Apply quote's typography to its children.
$block['innerBlocks'] = $this->apply_typography_to_children( $block['innerBlocks'], $quote_typography, $styles );
}
$block['innerBlocks'] = $this->process_blocks( $block['innerBlocks'], $styles );
}
return $blocks;
}
/**
* Apply text alignment to child blocks that don't have their own text alignment set
*
* @param array $blocks The blocks to process.
* @param string|null $text_align The text alignment to apply.
* @return array The processed blocks.
*/
private function apply_alignment_to_children( array $blocks, ?string $text_align = null ): array {
if ( ! $text_align ) {
return $blocks;
}
foreach ( $blocks as &$block ) {
// Only apply alignment if the block doesn't already have one set.
if ( ! isset( $block['attrs']['textAlign'] ) && ! isset( $block['attrs']['align'] ) ) {
if ( ! isset( $block['attrs'] ) ) {
$block['attrs'] = array();
}
$block['attrs']['textAlign'] = $text_align;
}
if ( isset( $block['innerBlocks'] ) ) {
$block['innerBlocks'] = $this->apply_alignment_to_children( $block['innerBlocks'], $block['attrs']['textAlign'] ?? $block['attrs']['align'] );
}
}
return $blocks;
}
/**
* Apply typography styles to immediate paragraph children
*
* @param array $blocks The blocks to process.
* @param array $quote_typography The typography styles from the quote block.
* @param array $styles The styles from the theme.
* @return array The processed blocks.
*/
private function apply_typography_to_children( array $blocks, array $quote_typography, array $styles ): array {
$default_typography = $styles['blocks']['core/quote']['typography'] ?? array();
$merged_typography = array_merge( $default_typography, $quote_typography );
if ( empty( $merged_typography ) ) {
return $blocks;
}
foreach ( $blocks as &$block ) {
if ( 'core/paragraph' === $block['blockName'] ) {
if ( ! isset( $block['attrs'] ) ) {
$block['attrs'] = array();
}
if ( ! isset( $block['attrs']['style'] ) ) {
$block['attrs']['style'] = array();
}
if ( ! isset( $block['attrs']['style']['typography'] ) ) {
$block['attrs']['style']['typography'] = array();
}
// Merge typography styles, with block's own styles taking precedence.
$block['attrs']['style']['typography'] = array_merge(
$merged_typography,
$block['attrs']['style']['typography']
);
}
}
return $blocks;
}
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
/**
* This preprocessor is responsible for setting default spacing values for blocks.
* In the early development phase, we are setting only margin-top for blocks that are not first or last in the columns block.
*/
class Spacing_Preprocessor implements Preprocessor {
/**
* Preprocesses the parsed blocks.
*
* @param array $parsed_blocks Parsed blocks.
* @param array $layout Layout.
* @param array $styles Styles.
* @return array
*/
public function preprocess( array $parsed_blocks, array $layout, array $styles ): array {
$parsed_blocks = $this->add_block_gaps( $parsed_blocks, $styles['spacing']['blockGap'] ?? '', null );
return $parsed_blocks;
}
/**
* Adds margin-top to blocks that are not first or last in the columns block.
*
* @param array $parsed_blocks Parsed blocks.
* @param string $gap Gap.
* @param array|null $parent_block Parent block.
* @return array
*/
private function add_block_gaps( array $parsed_blocks, string $gap = '', $parent_block = null ): array {
foreach ( $parsed_blocks as $key => $block ) {
$parent_block_name = $parent_block['blockName'] ?? '';
// Ensure that email_attrs are set.
$block['email_attrs'] = $block['email_attrs'] ?? array();
/**
* Do not add a gap to:
* - first child
* - parent block is a buttons block (where buttons are side by side).
*/
if ( 0 !== $key && $gap && 'core/buttons' !== $parent_block_name ) {
$block['email_attrs']['margin-top'] = $gap;
}
$block['innerBlocks'] = $this->add_block_gaps( $block['innerBlocks'] ?? array(), $gap, $block );
$parsed_blocks[ $key ] = $block;
}
return $parsed_blocks;
}
}

View File

@@ -0,0 +1,141 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
use Automattic\WooCommerce\EmailEditor\Engine\Settings_Controller;
/**
* This preprocessor is responsible for setting default typography values for blocks.
*/
class Typography_Preprocessor implements Preprocessor {
/**
* List of styles that should be copied from parent to children.
*
* @var string[]
*/
private const TYPOGRAPHY_STYLES = array(
'color',
'font-size',
'text-decoration',
);
/**
* Injected settings controller
*
* @var Settings_Controller
*/
private $settings_controller;
/**
* Typography_Preprocessor constructor.
*
* @param Settings_Controller $settings_controller Settings controller.
*/
public function __construct(
Settings_Controller $settings_controller
) {
$this->settings_controller = $settings_controller;
}
/**
* Method to preprocess the content before rendering
*
* @param array $parsed_blocks Parsed blocks of the email.
* @param array{contentSize: string} $layout Layout of the email.
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles Styles of the email.
* @return array
*/
public function preprocess( array $parsed_blocks, array $layout, array $styles ): array {
foreach ( $parsed_blocks as $key => $block ) {
$block = $this->preprocess_parent( $block );
// Set defaults from theme - this needs to be done on top level blocks only.
$block = $this->set_defaults_from_theme( $block );
$block['innerBlocks'] = $this->copy_typography_from_parent( $block['innerBlocks'], $block );
$parsed_blocks[ $key ] = $block;
}
return $parsed_blocks;
}
/**
* Copy typography styles from parent to children
*
* @param array $children List of children blocks.
* @param array $parent_block Parent block.
* @return array
*/
private function copy_typography_from_parent( array $children, array $parent_block ): array {
foreach ( $children as $key => $child ) {
$child = $this->preprocess_parent( $child );
$child['email_attrs'] = array_merge( $this->filterStyles( $parent_block['email_attrs'] ), $child['email_attrs'] );
$child['innerBlocks'] = $this->copy_typography_from_parent( $child['innerBlocks'] ?? array(), $child );
$children[ $key ] = $child;
}
return $children;
}
/**
* Preprocess parent block
*
* @param array $block Block to preprocess.
* @return array
*/
private function preprocess_parent( array $block ): array {
// Build styles that should be copied to children.
$email_attrs = array();
if ( isset( $block['attrs']['style']['color']['text'] ) ) {
$email_attrs['color'] = $block['attrs']['style']['color']['text'];
}
if ( isset( $block['attrs']['textColor'] ) && is_string( $block['attrs']['textColor'] ) && ! isset( $email_attrs['color'] ) ) {
$email_attrs['color'] = $this->settings_controller->translate_slug_to_color( $block['attrs']['textColor'] );
}
// In case the fontSize is set via a slug (small, medium, large, etc.) we translate it to a number
// The font size slug is set in $block['attrs']['fontSize'] and value in $block['attrs']['style']['typography']['fontSize'].
if ( isset( $block['attrs']['fontSize'] ) && is_string( $block['attrs']['fontSize'] ) ) {
$block['attrs']['style']['typography']['fontSize'] = $this->settings_controller->translate_slug_to_font_size( $block['attrs']['fontSize'] );
}
// Pass font size to email_attrs.
if ( isset( $block['attrs']['style']['typography']['fontSize'] ) ) {
$email_attrs['font-size'] = $block['attrs']['style']['typography']['fontSize'];
}
if ( isset( $block['attrs']['style']['typography']['textDecoration'] ) ) {
$email_attrs['text-decoration'] = $block['attrs']['style']['typography']['textDecoration'];
}
$block['email_attrs'] = array_merge( $email_attrs, $block['email_attrs'] ?? array() );
return $block;
}
/**
* Filter styles to only include typography styles
*
* @param array $styles List of styles.
* @return array
*/
private function filterStyles( array $styles ): array {
return array_intersect_key( $styles, array_flip( self::TYPOGRAPHY_STYLES ) );
}
/**
* Set default values from theme
*
* @param array $block Block to set defaults for.
* @return array
*/
private function set_defaults_from_theme( array $block ): array {
$theme_data = $this->settings_controller->get_theme()->get_data();
if ( ! ( $block['email_attrs']['color'] ?? '' ) ) {
$block['email_attrs']['color'] = $theme_data['styles']['color']['text'] ?? null;
}
if ( ! ( $block['email_attrs']['font-size'] ?? '' ) ) {
$block['email_attrs']['font-size'] = $theme_data['styles']['typography']['fontSize'];
}
return $block;
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors;
/**
* Interface Preprocessor
*/
interface Preprocessor {
/**
* Method to preprocess the content before rendering
*
* @param array $parsed_blocks Parsed blocks of the email.
* @param array{contentSize: string, wideSize?: string, allowEditing?: bool, allowCustomContentAndWideSize?: bool} $layout Layout of the email.
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles Styles of the email.
* @return array
*/
public function preprocess( array $parsed_blocks, array $layout, array $styles ): array;
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
/**
* Interface Block_Renderer
*/
interface Block_Renderer {
/**
* Renders the block content
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
public function render( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string;
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
use WP_Block_Parser;
/**
* Class Blocks_Parser
*/
class Blocks_Parser extends WP_Block_Parser {
/**
* List of parsed blocks
*
* @var \WP_Block_Parser_Block[]
*/
public $output;
/**
* Parse the blocks from the document
*
* @param string $document Document to parse.
* @return array[]
*/
public function parse( $document ) {
parent::parse( $document );
return apply_filters( 'woocommerce_email_blocks_renderer_parsed_blocks', $this->output );
}
}

View File

@@ -0,0 +1,356 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Css_Inliner;
use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Fallback;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Post_Content;
use WP_Block_Template;
use WP_Block_Type_Registry;
use WP_Post;
/**
* Class Content_Renderer
*/
class Content_Renderer {
/**
* Process manager
*
* @var Process_Manager
*/
private Process_Manager $process_manager;
/**
* Theme controller
*
* @var Theme_Controller
*/
private Theme_Controller $theme_controller;
const CONTENT_STYLES_FILE = 'content.css';
/**
* WordPress Block Type Registry.
*
* @var WP_Block_Type_Registry
*/
private WP_Block_Type_Registry $block_type_registry;
/**
* CSS inliner
*
* @var Css_Inliner
*/
private Css_Inliner $css_inliner;
/**
* Property to store the backup of the current template content.
*
* @var string|null
*/
private $backup_template_content;
/**
* Property to store the backup of the current template ID.
*
* @var int|null
*/
private $backup_template_id;
/**
* Property to store the backup of the current post.
*
* @var WP_Post|null
*/
private $backup_post;
/**
* Property to store the backup of the current query.
*
* @var \WP_Query|null
*/
private $backup_query;
/**
* Fallback renderer that is used when render_email_callback is not set for the rendered blockType.
*
* @var Fallback
*/
private Fallback $fallback_renderer;
/**
* Logger instance.
*
* @var Email_Editor_Logger
*/
private Email_Editor_Logger $logger;
/**
* Backup of the original core/post-content render callback.
*
* @var callable|null
*/
private $backup_post_content_callback;
/**
* Content_Renderer constructor.
*
* @param Process_Manager $preprocess_manager Preprocess manager.
* @param Css_Inliner $css_inliner Css inliner.
* @param Theme_Controller $theme_controller Theme controller.
* @param Email_Editor_Logger $logger Logger instance.
*/
public function __construct(
Process_Manager $preprocess_manager,
Css_Inliner $css_inliner,
Theme_Controller $theme_controller,
Email_Editor_Logger $logger
) {
$this->process_manager = $preprocess_manager;
$this->theme_controller = $theme_controller;
$this->css_inliner = $css_inliner;
$this->logger = $logger;
$this->block_type_registry = WP_Block_Type_Registry::get_instance();
$this->fallback_renderer = new Fallback();
}
/**
* Initialize the content renderer
*
* @return void
*/
private function initialize() {
add_filter( 'render_block', array( $this, 'render_block' ), 10, 2 );
add_filter( 'block_parser_class', array( $this, 'block_parser' ) );
add_filter( 'woocommerce_email_blocks_renderer_parsed_blocks', array( $this, 'preprocess_parsed_blocks' ) );
// Swap core/post-content render callback for email rendering.
// This prevents issues with WordPress's static $seen_ids array when rendering
// multiple emails in a single request (e.g., MailPoet batch processing).
$post_content_type = $this->block_type_registry->get_registered( 'core/post-content' );
if ( $post_content_type ) {
// Save the original callback (may be null or WordPress's default).
$this->backup_post_content_callback = $post_content_type->render_callback;
// Replace with our stateless renderer.
$post_content_renderer = new Post_Content();
$post_content_type->render_callback = array( $post_content_renderer, 'render_stateless' );
}
}
/**
* Render the content
*
* @param WP_Post $post Post object.
* @param WP_Block_Template $template Block template.
* @return string
*/
public function render( WP_Post $post, WP_Block_Template $template ): string {
$this->set_template_globals( $post, $template );
$this->initialize();
$rendered_html = get_the_block_template_html();
$this->reset();
return $this->process_manager->postprocess( $this->inline_styles( $rendered_html, $post, $template ) );
}
/**
* Get block parser class
*
* @return string
*/
public function block_parser() {
return 'Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Blocks_Parser';
}
/**
* Preprocess parsed blocks
*
* @param array $parsed_blocks Parsed blocks.
* @return array
*/
public function preprocess_parsed_blocks( array $parsed_blocks ): array {
return $this->process_manager->preprocess( $parsed_blocks, $this->theme_controller->get_layout_settings(), $this->theme_controller->get_styles() );
}
/**
* Renders block
* Translates block's HTML to HTML suitable for email clients. The method is intended as a callback for 'render_block' filter.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @return string
*/
public function render_block( string $block_content, array $parsed_block ): string {
/**
* Filter the email-specific context data passed to block renderers.
*
* This allows email sending systems to provide context data such as user ID,
* email address, order information, etc., that can be used by blocks during rendering.
*
* Blocks that need cart product information can derive it from the user_id or recipient_email
* using CartCheckoutUtils::get_cart_product_ids_for_user().
*
* @since 1.9.0
*
* @param array $email_context {
* Email-specific context data.
*
* @type int $user_id The ID of the user receiving the email.
* @type string $recipient_email The recipient's email address.
* @type int $order_id The order ID (for order-related emails).
* @type string $email_type The type of email being rendered.
* }
*/
$email_context = apply_filters( 'woocommerce_email_editor_rendering_email_context', array() );
$context = new Rendering_Context( $this->theme_controller->get_theme(), $email_context );
$block_type = $this->block_type_registry->get_registered( $parsed_block['blockName'] );
try {
if ( $block_type && isset( $block_type->render_email_callback ) && is_callable( $block_type->render_email_callback ) ) {
return call_user_func( $block_type->render_email_callback, $block_content, $parsed_block, $context );
}
} catch ( \Exception $error ) {
$this->logger->error(
'Error thrown while rendering block.',
array(
'exception' => $error,
'block_name' => $parsed_block['blockName'],
'parsed_block' => $parsed_block,
'message' => $error->getMessage(),
)
);
// Returning the original content.
return $block_content;
}
return $this->fallback_renderer->render( $block_content, $parsed_block, $context );
}
/**
* Set template globals
*
* @param WP_Post $email_post Post object.
* @param WP_Block_Template $template Block template.
* @return void
*/
private function set_template_globals( WP_Post $email_post, WP_Block_Template $template ) {
global $_wp_current_template_content, $_wp_current_template_id, $wp_query, $post;
// Backup current values of globals.
// Because overriding the globals can affect rendering of the page itself, we need to backup the current values.
$this->backup_template_content = $_wp_current_template_content;
$this->backup_template_id = $_wp_current_template_id;
$this->backup_query = $wp_query;
$this->backup_post = $post;
$_wp_current_template_id = $template->id;
$_wp_current_template_content = $template->content;
$wp_query = new \WP_Query( array( 'p' => $email_post->ID ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- We need to set the query for correct rendering the blocks.
$post = $email_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- We need to set the post for correct rendering the blocks.
}
/**
* As we use default WordPress filters, we need to remove them after email rendering
* so that we don't interfere with possible post rendering that might happen later.
*/
private function reset(): void {
remove_filter( 'render_block', array( $this, 'render_block' ) );
remove_filter( 'block_parser_class', array( $this, 'block_parser' ) );
remove_filter( 'woocommerce_email_blocks_renderer_parsed_blocks', array( $this, 'preprocess_parsed_blocks' ) );
// Restore the original core/post-content render callback.
// Note: We always restore it, even if it was null originally.
$post_content_type = $this->block_type_registry->get_registered( 'core/post-content' );
if ( $post_content_type ) {
// @phpstan-ignore-next-line -- WordPress core allows null for render_callback despite type definition.
$post_content_type->render_callback = $this->backup_post_content_callback;
}
// Restore globals to their original values.
global $_wp_current_template_content, $_wp_current_template_id, $wp_query, $post;
$_wp_current_template_content = $this->backup_template_content;
$_wp_current_template_id = $this->backup_template_id;
$wp_query = $this->backup_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring of the query.
$post = $this->backup_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring of the post.
}
/**
* Method to inline styles into the HTML
*
* @param string $html HTML content.
* @param WP_Post $post Post object.
* @param WP_Block_Template|null $template Block template.
* @return string
*/
private function inline_styles( $html, WP_Post $post, $template = null ) {
$styles = (string) file_get_contents( __DIR__ . '/' . self::CONTENT_STYLES_FILE );
$styles .= (string) file_get_contents( __DIR__ . '/../../content-shared.css' );
// Apply default contentWidth to constrained blocks.
$layout = $this->theme_controller->get_layout_settings();
$styles .= sprintf(
'
.is-layout-constrained > *:not(.alignleft):not(.alignright):not(.alignfull) {
max-width: %1$s;
margin-left: auto !important;
margin-right: auto !important;
}
.is-layout-constrained > .alignwide {
max-width: %2$s;
margin-left: auto !important;
margin-right: auto !important;
}
',
$layout['contentSize'],
$layout['wideSize']
);
// Get styles from theme.
$styles .= $this->theme_controller->get_stylesheet_for_rendering( $post, $template );
$block_support_styles = $this->theme_controller->get_stylesheet_from_context( 'block-supports', array() );
// Get styles from block-supports stylesheet. This includes rules such as layout (contentWidth) that some blocks use.
// @see https://github.com/WordPress/WordPress/blob/3c5da9c74344aaf5bf8097f2e2c6a1a781600e03/wp-includes/script-loader.php#L3134
// @internal :where is not supported by emogrifier, so we need to replace it with *.
$block_support_styles = str_replace(
':where(:not(.alignleft):not(.alignright):not(.alignfull))',
'*:not(.alignleft):not(.alignright):not(.alignfull)',
$block_support_styles
);
/*
* Layout CSS assumes the top level block will have a single DIV wrapper with children. Since our blocks use tables,
* we need to adjust this to look for children in the TD element. This may requires more advanced replacement but
* this works in the current version of Gutenberg.
* Example rule we're targetting: .wp-container-core-group-is-layout-1.wp-container-core-group-is-layout-1 > *
*/
$block_support_styles = preg_replace(
'/group-is-layout-(\d+) >/',
'group-is-layout-$1 > tbody tr td >',
$block_support_styles
);
$styles .= $block_support_styles;
/*
* Debugging for content styles. Remember these get inlined.
* echo '<pre>';
* var_dump($styles);
* echo '</pre>';
*/
$styles = '<style>' . wp_strip_all_tags( (string) apply_filters( 'woocommerce_email_content_renderer_styles', $styles, $post ) ) . '</style>';
return $this->css_inliner->from_html( $styles . $html )->inline_css()->render();
}
}

View File

@@ -0,0 +1,117 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors\Highlighting_Postprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors\Postprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors\Variables_Postprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\Blocks_Width_Preprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\Cleanup_Preprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\Preprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\Spacing_Preprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\Typography_Preprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preprocessors\Quote_Preprocessor;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors\Border_Style_Postprocessor;
/**
* Class Process_Manager
*/
class Process_Manager {
/**
* List of preprocessors
*
* @var Preprocessor[]
*/
private $preprocessors = array();
/**
* List of postprocessors
*
* @var Postprocessor[]
*/
private $postprocessors = array();
/**
* Process_Manager constructor.
*
* @param Cleanup_Preprocessor $cleanup_preprocessor Cleanup preprocessor.
* @param Blocks_Width_Preprocessor $blocks_width_preprocessor Blocks width preprocessor.
* @param Typography_Preprocessor $typography_preprocessor Typography preprocessor.
* @param Spacing_Preprocessor $spacing_preprocessor Spacing preprocessor.
* @param Quote_Preprocessor $quote_preprocessor Quote preprocessor.
* @param Highlighting_Postprocessor $highlighting_postprocessor Highlighting postprocessor.
* @param Variables_Postprocessor $variables_postprocessor Variables postprocessor.
* @param Border_Style_Postprocessor $border_style_postprocessor Border style postprocessor.
*/
public function __construct(
Cleanup_Preprocessor $cleanup_preprocessor,
Blocks_Width_Preprocessor $blocks_width_preprocessor,
Typography_Preprocessor $typography_preprocessor,
Spacing_Preprocessor $spacing_preprocessor,
Quote_Preprocessor $quote_preprocessor,
Highlighting_Postprocessor $highlighting_postprocessor,
Variables_Postprocessor $variables_postprocessor,
Border_Style_Postprocessor $border_style_postprocessor
) {
$this->register_preprocessor( $cleanup_preprocessor );
$this->register_preprocessor( $blocks_width_preprocessor );
$this->register_preprocessor( $typography_preprocessor );
$this->register_preprocessor( $spacing_preprocessor );
$this->register_preprocessor( $quote_preprocessor );
$this->register_postprocessor( $highlighting_postprocessor );
$this->register_postprocessor( $border_style_postprocessor );
$this->register_postprocessor( $variables_postprocessor );
}
/**
* Method to preprocess blocks
*
* @param array $parsed_blocks Parsed blocks.
* @param array{contentSize: string, wideSize?: string, allowEditing?: bool, allowCustomContentAndWideSize?: bool} $layout Layout.
* @param array{spacing: array{padding: array{bottom: string, left: string, right: string, top: string}, blockGap: string}} $styles Styles.
* @return array
*/
public function preprocess( array $parsed_blocks, array $layout, array $styles ): array {
foreach ( $this->preprocessors as $preprocessor ) {
$parsed_blocks = $preprocessor->preprocess( $parsed_blocks, $layout, $styles );
}
return $parsed_blocks;
}
/**
* Method to postprocess the content
*
* @param string $html HTML content.
* @return string
*/
public function postprocess( string $html ): string {
foreach ( $this->postprocessors as $postprocessor ) {
$html = $postprocessor->postprocess( $html );
}
return $html;
}
/**
* Register preprocessor
*
* @param Preprocessor $preprocessor Preprocessor.
*/
public function register_preprocessor( Preprocessor $preprocessor ): void {
$this->preprocessors[] = $preprocessor;
}
/**
* Register postprocessor
*
* @param Postprocessor $postprocessor Postprocessor.
*/
public function register_postprocessor( Postprocessor $postprocessor ): void {
$this->postprocessors[] = $postprocessor;
}
}

View File

@@ -0,0 +1,165 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use WP_Theme_JSON;
/**
* Class Rendering_Context
*/
class Rendering_Context {
/**
* Instance of the WP Theme.
*
* @var WP_Theme_JSON
*/
private WP_Theme_JSON $theme_json;
/**
* Email-specific context data.
*
* This array contains email-specific information that can be used during rendering,
* such as:
* - 'user_id': The ID of the user receiving the email
* - 'recipient_email': The recipient's email address
* - Additional context can be added by extensions using the generic get() method
*
* @var array<string, mixed>
*/
private array $email_context;
/**
* Rendering_Context constructor.
*
* @param WP_Theme_JSON $theme_json Theme Json used in the email.
* @param array<string, mixed> $email_context Email-specific context data.
*/
public function __construct( WP_Theme_JSON $theme_json, array $email_context = array() ) {
$this->theme_json = $theme_json;
$this->email_context = $email_context;
}
/**
* Returns WP_Theme_JSON instance that should be used during the email rendering.
*
* @return WP_Theme_JSON
*/
public function get_theme_json(): WP_Theme_JSON {
return $this->theme_json;
}
/**
* Get the email theme styles.
*
* @return array{
* spacing: array{
* blockGap: string,
* padding: array{bottom: string, left: string, right: string, top: string}
* },
* color: array{
* background: string,
* text: string
* },
* typography: array{
* fontFamily: string
* }
* }
*/
public function get_theme_styles(): array {
$theme = $this->get_theme_json();
return $theme->get_data()['styles'] ?? array();
}
/**
* Get settings from the theme.
*
* @return array
*/
public function get_theme_settings() {
return $this->get_theme_json()->get_settings();
}
/**
* Returns the width of the layout without padding.
*
* @return string
*/
public function get_layout_width_without_padding(): string {
$styles = $this->get_theme_styles();
$layout_settings = $this->get_theme_settings()['layout'] ?? array();
$width = Styles_Helper::parse_value( $layout_settings['contentSize'] ?? '0px' );
$padding = $styles['spacing']['padding'] ?? array();
$width -= Styles_Helper::parse_value( $padding['left'] ?? '0px' );
$width -= Styles_Helper::parse_value( $padding['right'] ?? '0px' );
return "{$width}px";
}
/**
* Translate color slug to color.
*
* @param string $color_slug Color slug.
* @return string
*/
public function translate_slug_to_color( string $color_slug ): string {
$settings = $this->get_theme_settings();
$color_definitions = array_merge(
$settings['color']['palette']['theme'] ?? array(),
$settings['color']['palette']['default'] ?? array()
);
foreach ( $color_definitions as $color_definition ) {
if ( $color_definition['slug'] === $color_slug ) {
return strtolower( $color_definition['color'] );
}
}
return $color_slug;
}
/**
* Get the email-specific context data.
*
* @return array<string, mixed>
*/
public function get_email_context(): array {
return $this->email_context;
}
/**
* Get the user ID from the email context.
*
* @return int|null The user ID if available, null otherwise.
*/
public function get_user_id(): ?int {
return isset( $this->email_context['user_id'] ) && is_numeric( $this->email_context['user_id'] ) ? (int) $this->email_context['user_id'] : null;
}
/**
* Get the recipient email address from the email context.
*
* @return string|null The email address if available, null otherwise.
*/
public function get_recipient_email(): ?string {
return isset( $this->email_context['recipient_email'] ) && is_string( $this->email_context['recipient_email'] ) ? $this->email_context['recipient_email'] : null;
}
/**
* Get a specific value from the email context.
*
* This method allows extensions to access custom context data that may be
* specific to their implementation (e.g., order IDs, email types, etc.).
*
* @param string $key The context key.
* @param mixed $default_value Default value if key is not found.
* @return mixed The context value or default.
*/
public function get( string $key, $default_value = null ) {
return $this->email_context[ $key ] ?? $default_value;
}
}

View File

@@ -0,0 +1,55 @@
/**
CSS reset for email clients for elements used in email content
StyleLint is disabled because some rules contain properties that linter marks as unknown (e.g. mso- prefix), but they are valid for email rendering
*/
/* stylelint-disable property-no-unknown */
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
img {
border: 0;
height: auto;
-ms-interpolation-mode: bicubic;
line-height: 100%;
max-width: 100%;
outline: none;
text-decoration: none;
}
p {
display: block;
margin: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0;
margin-top: 0;
}
/* Ensure border style is set when a block has a border */
.has-border-color {
border-style: solid;
}
/* We want ensure the same design for all email clients */
ul,
ol {
/* When margin attribute is set to zero, Outlook doesn't render the list properly. As a possible workaround, we can reset only margin for top and bottom */
margin-bottom: 0;
margin-top: 0;
padding: 0 0 0 40px;
}
/* Outlook was adding weird spaces around lists in some versions. Resetting vertical margin for list items solved it */
li {
margin-bottom: 0;
margin-top: 0;
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* HTML to Text Exception class
*
* This file was extracted from the `soundasleep/html2text` package.
* Copyright (c) 2019 Jevon Wright
* MIT License
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;
/**
* Exception thrown when HTML to text conversion fails
*/
class Html2Text_Exception extends \Exception {
/**
* Additional information about the error
*
* @var string
*/
private string $more_info;
/**
* Constructor
*
* @param string $message Error message.
* @param string $more_info Additional error information.
*/
public function __construct( string $message = '', string $more_info = '' ) {
parent::__construct( $message );
$this->more_info = $more_info;
}
/**
* Returns additional error information
*
* @return string Additional error information.
*/
public function get_more_info(): string {
return $this->more_info;
}
}

View File

@@ -0,0 +1,633 @@
<?php
/**
* HTML to Text Converter class
*
* This file was extracted from the `soundasleep/html2text` package.
* Copyright (c) 2019 Jevon Wright
* MIT License
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;
/**
* Converts HTML into plain text format suitable for email display
*
* Features:
* - Maintains links with href copied over
* - Information in the <head> is lost
* - Handles various HTML elements appropriately for text conversion
*/
class Html2Text {
/**
* Default options for HTML to text conversion
*
* @return array<string, bool|string> Default options array.
*/
public static function default_options(): array {
return array(
'ignore_errors' => false,
'drop_links' => false,
'char_set' => 'auto',
);
}
/**
* Converts HTML into plain text format
*
* @param string $html The input HTML.
* @param boolean|array<string, bool|string> $options Conversion options.
* @return string The HTML converted to text.
* @throws Html2Text_Exception|\InvalidArgumentException If the HTML could not be loaded or invalid options are provided.
*/
public static function convert( string $html, $options = array() ): string {
if ( false === $options || true === $options ) {
// Using old style (< 1.0) of passing in options.
$options = array( 'ignore_errors' => $options );
}
$options = array_merge( static::default_options(), $options );
// Check all options are valid.
foreach ( array_keys( $options ) as $key ) {
if ( ! in_array( $key, array_keys( static::default_options() ), true ) ) {
// Log invalid option for debugging purposes without exposing in exception.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Security: Logging sensitive data separately from user-facing exception messages.
error_log( 'Html2Text: Invalid option provided: ' . htmlspecialchars( (string) $key, ENT_QUOTES, 'UTF-8' ) . '. Valid options are: ' . htmlspecialchars( implode( ',', array_keys( static::default_options() ) ), ENT_QUOTES, 'UTF-8' ) );
// Throw generic error message to avoid exposing user input.
throw new \InvalidArgumentException( 'Invalid option provided for html2text conversion.' );
}
}
$is_office_document = self::is_office_document( $html );
if ( $is_office_document ) {
// Remove office namespace.
$html = str_replace( array( '<o:p>', '</o:p>' ), '', $html );
}
$html = self::fix_newlines( $html );
// Use mb_convert_encoding for legacy versions of php.
if ( PHP_MAJOR_VERSION * 10 + PHP_MINOR_VERSION < 81 && mb_detect_encoding( $html, 'UTF-8', true ) ) {
$converted = mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' );
$html = false !== $converted ? $converted : $html;
}
// Ensure $html is always a string before passing to get_document.
if ( ! is_string( $html ) ) {
$html = (string) $html;
}
$doc = self::get_document( $html, $options );
$output = self::iterate_over_node( $doc, null, false, $is_office_document, $options );
// Process output for whitespace/newlines.
$output = self::process_whitespace_newlines( $output );
return $output;
}
/**
* Unify newlines
*
* Converts \r\n to \n, and \r to \n. This means that all newlines
* (Unix, Windows, Mac) all become \ns.
*
* @param string $text Text with any number of \r, \r\n and \n combinations.
* @return string The fixed text.
*/
public static function fix_newlines( string $text ): string {
// Replace \r\n to \n.
$text = str_replace( "\r\n", "\n", $text );
// Remove \rs.
$text = str_replace( "\r", "\n", $text );
return $text;
}
/**
* Get non-breaking space character codes
*
* @return array<string> Array of nbsp codes.
*/
public static function nbsp_codes(): array {
return array(
"\xc2\xa0",
"\u00a0",
);
}
/**
* Get zero-width non-joiner character codes
*
* @return array<string> Array of zwnj codes.
*/
public static function zwnj_codes(): array {
return array(
"\xe2\x80\x8c",
"\u200c",
);
}
/**
* Remove leading or trailing spaces and excess empty lines from provided multiline text
*
* @param string $text Multiline text with any number of leading or trailing spaces or excess lines.
* @return string The fixed text.
*/
public static function process_whitespace_newlines( string $text ): string {
// Remove excess spaces around tabs.
$result = preg_replace( '/ *\t */im', "\t", $text );
$text = null !== $result ? $result : $text;
// Remove leading whitespace.
$text = ltrim( $text );
// Remove leading spaces on each line.
$result = preg_replace( "/\n[ \t]*/im", "\n", $text );
$text = null !== $result ? $result : $text;
// Convert non-breaking spaces to regular spaces to prevent output issues,
// do it here so they do NOT get removed with other leading spaces, as they
// are sometimes used for indentation.
$text = self::render_text( $text );
// Remove trailing whitespace.
$text = rtrim( $text );
// Remove trailing spaces on each line.
$result = preg_replace( "/[ \t]*\n/im", "\n", $text );
$text = null !== $result ? $result : $text;
// Unarmor pre blocks.
$text = self::fix_newlines( $text );
// Remove unnecessary empty lines.
$result = preg_replace( "/\n\n\n*/im", "\n\n", $text );
return null !== $result ? $result : $text;
}
/**
* Can we guess that this HTML is generated by Microsoft Office?
*
* @param string $html The HTML content.
* @return bool True if this appears to be an Office document.
*/
public static function is_office_document( string $html ): bool {
return strpos( $html, 'urn:schemas-microsoft-com:office' ) !== false;
}
/**
* Check if text is whitespace
*
* @param string $text The text to check.
* @return bool True if the text is whitespace.
*/
public static function is_whitespace( string $text ): bool {
return 0 === strlen( trim( self::render_text( $text ), "\n\r\t " ) );
}
/**
* Parse HTML into a DOMDocument
*
* @param string $html The input HTML.
* @param array<string, bool|string> $options Parsing options.
* @return \DOMDocument The parsed document tree.
* @throws Html2Text_Exception If the HTML could not be loaded.
*/
private static function get_document( string $html, array $options ): \DOMDocument {
$doc = new \DOMDocument();
$html = trim( $html );
if ( ! $html ) {
// DOMDocument doesn't support empty value and throws an error.
// Return empty document instead.
return $doc;
}
if ( '<' !== $html[0] ) {
// If HTML does not begin with a tag, we put a body tag around it.
// If we do not do this, PHP will insert a paragraph tag around
// the first block of text for some reason which can mess up
// the newlines. See pre.html test for an example.
$html = '<body>' . $html . '</body>';
}
$header = '';
// Use char sets for modern versions of php.
if ( PHP_MAJOR_VERSION * 10 + PHP_MINOR_VERSION >= 81 ) {
// Use specified char_set, or auto detect if not set.
$char_set = ! empty( $options['char_set'] ) && is_string( $options['char_set'] ) ? $options['char_set'] : 'auto';
if ( 'auto' === $char_set ) {
$detected = mb_detect_encoding( $html );
$char_set = false !== $detected ? $detected : 'UTF-8';
} elseif ( strpos( $char_set, ',' ) !== false ) {
$encoding_list = explode( ',', $char_set );
$encoding_list = array_map( 'trim', $encoding_list );
$encoding_list = array_filter(
$encoding_list,
function ( $encoding ) {
return ! empty( $encoding );
}
);
if ( ! empty( $encoding_list ) ) {
// Ensure we have a proper list with consecutive integer keys.
$encoding_list = array_values( $encoding_list );
mb_detect_order( $encoding_list );
$detected = mb_detect_encoding( $html );
$char_set = false !== $detected ? $detected : 'UTF-8';
}
}
// Turn off error detection for Windows-1252 legacy html.
if ( strpos( $char_set, '1252' ) !== false ) {
$options['ignore_errors'] = true;
}
$header = '<?xml version="1.0" encoding="' . $char_set . '">';
}
if ( ! empty( $options['ignore_errors'] ) ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$doc->strictErrorChecking = false;
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$doc->recover = true;
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$doc->xmlStandalone = true;
$old_internal_errors = libxml_use_internal_errors( true );
$load_result = $doc->loadHTML( $header . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_PARSEHUGE );
libxml_use_internal_errors( $old_internal_errors );
} else {
$load_result = $doc->loadHTML( $header . $html );
}
if ( ! $load_result ) {
// Log truncated HTML content for debugging purposes (limit to 500 chars to prevent log bloat).
$html_preview = strlen( $html ) > 500 ? substr( $html, 0, 500 ) . '...[truncated]' : $html;
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Security: Logging sensitive data separately from user-facing exception messages.
error_log( 'Html2Text: Failed to load HTML content: ' . htmlspecialchars( $html_preview, ENT_QUOTES, 'UTF-8' ) );
// Throw a generic error message to avoid exposing sensitive data.
throw new Html2Text_Exception( 'Could not load HTML - the content may be malformed.' );
}
return $doc;
}
/**
* Replace any special characters with simple text versions
*
* This prevents output issues:
* - Convert non-breaking spaces to regular spaces; and
* - Convert zero-width non-joiners to '' (nothing).
*
* This is to match our goal of rendering documents as they would be rendered
* by a browser.
*
* @param string $text The text to process.
* @return string The processed text.
*/
private static function render_text( string $text ): string {
$text = str_replace( self::nbsp_codes(), ' ', $text );
$text = str_replace( self::zwnj_codes(), '', $text );
return $text;
}
/**
* Get the next child name
*
* @param \DOMNode|null $node The node to check.
* @return string|null The next child name.
*/
private static function next_child_name( ?\DOMNode $node ): ?string {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( null === $node || null === $node->nextSibling ) {
return null;
}
// Get the next child.
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$next_node = $node->nextSibling;
while ( null !== $next_node ) {
if ( $next_node instanceof \DOMText ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( ! self::is_whitespace( $next_node->wholeText ) ) {
break;
}
}
if ( $next_node instanceof \DOMElement ) {
break;
}
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$next_node = $next_node->nextSibling;
}
$next_name = null;
if ( $next_node instanceof \DOMElement || $next_node instanceof \DOMText ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$next_name = strtolower( $next_node->nodeName );
}
return $next_name;
}
/**
* Iterate over a DOM node and convert to text
*
* @param \DOMNode $node The DOM node.
* @param string|null $prev_name Previous node name.
* @param bool $in_pre Whether we're in a pre block.
* @param bool $is_office_document Whether this is an Office document.
* @param array<string, bool|string> $options Conversion options.
* @return string The converted text.
*/
private static function iterate_over_node( \DOMNode $node, ?string $prev_name, bool $in_pre, bool $is_office_document, array $options ): string {
if ( $node instanceof \DOMText ) {
// Replace whitespace characters with a space (equivalent to \s).
if ( $in_pre ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$text = "\n" . trim( self::render_text( $node->wholeText ), "\n\r\t " ) . "\n";
// Remove trailing whitespace only.
$result = preg_replace( "/[ \t]*\n/im", "\n", $text );
$text = null !== $result ? $result : $text;
// Armor newlines with \r.
return str_replace( "\n", "\r", $text );
}
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$text = self::render_text( $node->wholeText );
$result = preg_replace( "/[\\t\\n\\f\\r ]+/im", ' ', $text );
$text = null !== $result ? $result : $text;
if ( ! self::is_whitespace( $text ) && ( 'p' === $prev_name || 'div' === $prev_name ) ) {
return "\n" . $text;
}
return $text;
}
if ( $node instanceof \DOMDocumentType || $node instanceof \DOMProcessingInstruction ) {
// Ignore.
return '';
}
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$name = strtolower( $node->nodeName );
$next_name = self::next_child_name( $node );
// Start whitespace.
switch ( $name ) {
case 'hr':
$prefix = '';
if ( null !== $prev_name ) {
$prefix = "\n";
}
return $prefix . "---------------------------------------------------------------\n";
case 'style':
case 'head':
case 'title':
case 'meta':
case 'script':
// Ignore these tags.
return '';
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
case 'ol':
case 'ul':
case 'pre':
// Add two newlines.
$output = "\n\n";
break;
case 'td':
case 'th':
// Add tab char to separate table fields.
$output = "\t";
break;
case 'p':
// Microsoft exchange emails often include HTML which, when passed through
// html2text, results in lots of double line returns everywhere.
//
// To fix this, for any p element with a className of `MsoNormal` (the standard
// classname in any Microsoft export or outlook for a paragraph that behaves
// like a line return) we skip the first line returns and set the name to br.
if ( $is_office_document && $node instanceof \DOMElement && 'MsoNormal' === $node->getAttribute( 'class' ) ) {
$output = '';
$name = 'br';
break;
}
// Add two lines.
$output = "\n\n";
break;
case 'tr':
// Add one line.
$output = "\n";
break;
case 'div':
$output = '';
if ( null !== $prev_name ) {
// Add one line.
$output .= "\n";
}
break;
case 'li':
$output = '- ';
break;
default:
// Print out contents of unknown tags.
$output = '';
break;
}
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( $node->childNodes->length > 0 ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$n = $node->childNodes->item( 0 );
$previous_sibling_names = array();
$previous_sibling_name = null;
$parts = array();
$trailing_whitespace = 0;
while ( null !== $n ) {
$text = self::iterate_over_node( $n, $previous_sibling_name, $in_pre || 'pre' === $name, $is_office_document, $options );
// Pass current node name to next child, as previousSibling does not appear to get populated.
if ( $n instanceof \DOMDocumentType
|| $n instanceof \DOMProcessingInstruction
|| ( $n instanceof \DOMText && self::is_whitespace( $text ) ) ) {
// Keep current previousSiblingName, these are invisible.
++$trailing_whitespace;
} else {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$previous_sibling_name = strtolower( $n->nodeName );
$previous_sibling_names[] = $previous_sibling_name;
$trailing_whitespace = 0;
}
$node->removeChild( $n );
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$n = $node->childNodes->item( 0 );
$parts[] = $text;
}
// Remove trailing whitespace, important for the br check below.
while ( $trailing_whitespace-- > 0 ) {
array_pop( $parts );
}
// Suppress last br tag inside a node list if follows text.
$last_name = array_pop( $previous_sibling_names );
if ( 'br' === $last_name ) {
$last_name = array_pop( $previous_sibling_names );
if ( '#text' === $last_name ) {
array_pop( $parts );
}
}
$output .= implode( '', $parts );
}
// End whitespace.
switch ( $name ) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
case 'pre':
case 'p':
// Add two lines.
$output .= "\n\n";
break;
case 'br':
// Add one line.
$output .= "\n";
break;
case 'div':
break;
case 'a':
// Links are returned in [text](link) format.
$href = $node instanceof \DOMElement ? $node->getAttribute( 'href' ) : '';
$output = trim( $output );
// Remove double [[ ]] s from linking images.
if ( '[' === substr( $output, 0, 1 ) && ']' === substr( $output, -1 ) ) {
$output = substr( $output, 1, strlen( $output ) - 2 );
// For linking images, the title of the <a> overrides the title of the <img>.
if ( $node instanceof \DOMElement && $node->getAttribute( 'title' ) ) {
$output = $node->getAttribute( 'title' );
}
}
// If there is no link text, but a title attr.
if ( ! $output && $node instanceof \DOMElement && $node->getAttribute( 'title' ) ) {
$output = $node->getAttribute( 'title' );
}
if ( ! $href ) {
// It doesn't link anywhere.
if ( $node instanceof \DOMElement && $node->getAttribute( 'name' ) ) {
if ( $options['drop_links'] ) {
$output = "$output";
} else {
$output = "[$output]";
}
}
} elseif ( $href === $output || "mailto:$output" === $href || "http://$output" === $href || "https://$output" === $href ) {
// Link to the same address: just use link.
$output = "$output";
} elseif ( $output ) {
// Replace it.
if ( $options['drop_links'] ) {
$output = "$output";
} else {
$output = "[$output]($href)";
}
} else {
// Empty string.
$output = "$href";
}
// Does the next node require additional whitespace?
switch ( $next_name ) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
$output .= "\n";
break;
}
break;
case 'img':
if ( $node instanceof \DOMElement && $node->getAttribute( 'title' ) ) {
$output = '[' . $node->getAttribute( 'title' ) . ']';
} elseif ( $node instanceof \DOMElement && $node->getAttribute( 'alt' ) ) {
$output = '[' . $node->getAttribute( 'alt' ) . ']';
} else {
$output = '';
}
break;
case 'li':
$output .= "\n";
break;
case 'blockquote':
// Process quoted text for whitespace/newlines.
$output = self::process_whitespace_newlines( $output );
// Add leading newline.
$output = "\n" . $output;
// Prepend '> ' at the beginning of all lines.
$result = preg_replace( "/\n/im", "\n> ", $output );
$output = null !== $result ? $result : $output;
// Replace leading '> >' with '>>'.
$result = preg_replace( "/\n> >/im", "\n>>", $output );
$output = null !== $result ? $result : $output;
// Add another leading newline and trailing newlines.
$output = "\n" . $output . "\n\n";
break;
default:
// Do nothing.
}
return $output;
}
}

View File

@@ -0,0 +1,247 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Content_Renderer;
use Automattic\WooCommerce\EmailEditor\Engine\Templates\Templates;
use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use WP_Style_Engine;
/**
* Class Renderer
*/
class Renderer {
/**
* Theme controller
*
* @var Theme_Controller
*/
private Theme_Controller $theme_controller;
/**
* Content renderer
*
* @var Content_Renderer
*/
private Content_Renderer $content_renderer;
/**
* Templates
*
* @var Templates
*/
private Templates $templates;
/**
* Css inliner
*
* @var Css_Inliner
*/
private Css_Inliner $css_inliner;
/**
* Personalization tags registry
*
* @var Personalization_Tags_Registry
*/
private Personalization_Tags_Registry $personalization_tags_registry;
/**
* Map of placeholders to full HTML comment tags for restoration.
*
* @var array
*/
private array $personalization_tag_placeholders = array();
const TEMPLATE_FILE = 'template-canvas.php';
const TEMPLATE_STYLES_FILE = 'template-canvas.css';
/**
* Renderer constructor.
*
* @param Content_Renderer $content_renderer Content renderer.
* @param Templates $templates Templates.
* @param Css_Inliner $css_inliner CSS Inliner.
* @param Theme_Controller $theme_controller Theme controller.
* @param Personalization_Tags_Registry $personalization_tags_registry Personalization tags registry.
*/
public function __construct(
Content_Renderer $content_renderer,
Templates $templates,
Css_Inliner $css_inliner,
Theme_Controller $theme_controller,
Personalization_Tags_Registry $personalization_tags_registry
) {
$this->content_renderer = $content_renderer;
$this->templates = $templates;
$this->theme_controller = $theme_controller;
$this->css_inliner = $css_inliner;
$this->personalization_tags_registry = $personalization_tags_registry;
}
/**
* Renders the email template
*
* @param \WP_Post $post Post object.
* @param string $subject Email subject.
* @param string $pre_header An email preheader or preview text is the short snippet of text that follows the subject line in an inbox. See https://kb.mailpoet.com/article/418-preview-text.
* @param string $language Email language.
* @param string $meta_robots Optional string. Can be left empty for sending, but you can provide a value (e.g. noindex, nofollow) when you want to display email html in a browser.
* @param string $template_slug Optional block template slug used for cases when email doesn't have associated template.
* @return array
*/
public function render( \WP_Post $post, string $subject, string $pre_header, string $language, string $meta_robots = '', string $template_slug = '' ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( ! $template_slug ) {
$template_slug = get_page_template_slug( $post ) ? get_page_template_slug( $post ) : 'email-general';
}
/** @var \WP_Block_Template $template */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$template = $this->templates->get_block_template( $template_slug );
$email_styles = $this->theme_controller->get_styles();
$template_html = $this->content_renderer->render( $post, $template );
$layout = $this->theme_controller->get_layout_settings();
ob_start();
include self::TEMPLATE_FILE;
$rendered_template = (string) ob_get_clean();
$template_styles =
WP_Style_Engine::compile_css(
array(
'background-color' => $email_styles['color']['background'] ?? 'inherit',
'color' => $email_styles['color']['text'] ?? 'inherit',
'padding-top' => $email_styles['spacing']['padding']['top'] ?? '0px',
'padding-bottom' => $email_styles['spacing']['padding']['bottom'] ?? '0px',
'padding-left' => $email_styles['spacing']['padding']['left'] ?? '0px',
'padding-right' => $email_styles['spacing']['padding']['right'] ?? '0px',
'font-family' => $email_styles['typography']['fontFamily'] ?? 'inherit',
'line-height' => $email_styles['typography']['lineHeight'] ?? '1.5',
'font-size' => $email_styles['typography']['fontSize'] ?? 'inherit',
),
'body, .email_layout_wrapper'
);
$template_styles .= '.email_layout_wrapper { box-sizing: border-box;}';
$template_styles .= file_get_contents( __DIR__ . '/' . self::TEMPLATE_STYLES_FILE );
$template_styles = '<style>' . wp_strip_all_tags( (string) apply_filters( 'woocommerce_email_renderer_styles', $template_styles, $post ) ) . '</style>';
$rendered_template = $this->inline_css_styles( $template_styles . $rendered_template );
// This is a workaround to support link :hover in some clients. Ideally we would remove the ability to set :hover
// however this is not possible using the color panel from Gutenberg.
if ( isset( $email_styles['elements']['link'][':hover']['color']['text'] ) ) {
$rendered_template = str_replace( '<!-- Forced Styles -->', '<style>a:hover { color: ' . esc_attr( $email_styles['elements']['link'][':hover']['color']['text'] ) . ' !important; }</style>', $rendered_template );
}
return array(
'html' => $rendered_template,
'text' => $this->render_text_version( $rendered_template ),
);
}
/**
* Inlines CSS styles into the HTML
*
* @param string $template HTML template.
* @return string
*/
private function inline_css_styles( $template ) {
return $this->css_inliner->from_html( $template )->inline_css()->render();
}
/**
* Renders the text version of the email template.
*
* @param string $template HTML template.
* @return string
*/
private function render_text_version( $template ) {
$template = ( mb_detect_encoding( $template, 'UTF-8', true ) ) ? $template : mb_convert_encoding( $template, 'UTF-8', mb_list_encodings() );
// Ensure template is a string before processing.
if ( ! is_string( $template ) ) {
return '';
}
// Preserve personalization tags by temporarily replacing them with unique placeholders.
$template = $this->preserve_personalization_tags( $template );
$result = Html2Text::convert( (string) $template, array( 'ignore_errors' => true ) );
if ( ! $result ) {
return '';
}
// Restore personalization tags from placeholders.
$result = $this->restore_personalization_tags( $result );
return $result;
}
/**
* Preserves personalization tags by replacing them with unique placeholders (not inside comments).
*
* @param string $template HTML template.
* @return string
*/
private function preserve_personalization_tags( string $template ): string {
$all_registered_tags = $this->personalization_tags_registry->get_all();
$this->personalization_tag_placeholders = array();
$counter = 0;
$base_tokens = array(); // All the tokens used in the email, e.g. [woocommerce/customer-username].
$token_prefixes = array(); // All the used prefixes, e.g. woocommerce, mailpoet, etc.
foreach ( $all_registered_tags as $tag ) {
$token = $tag->get_token(); // E.g. [woocommerce/customer-username].
$base_tokens[ $token ] = true;
// Remove brackets for regex matching, escape for regex.
$token_prefixes[] = preg_quote( substr( $token, 1, -1 ), '/' );
}
if ( empty( $token_prefixes ) ) {
return $template;
}
// Match all of the code comments that look like a personalization tags.
$pattern = '/<!--\[(' . implode( '|', $token_prefixes ) . ')(?:\s+[^\]]*)?\]-->/';
$template = preg_replace_callback(
$pattern,
function ( $matches ) use ( &$counter, $base_tokens ) {
// $matches[1] is the token without brackets, add brackets for lookup.
$base_token = '[' . $matches[1] . ']';
if ( isset( $base_tokens[ $base_token ] ) ) {
$placeholder = 'PERSONALIZATION_TAG_PLACEHOLDER_' . $counter;
$this->personalization_tag_placeholders[ $placeholder ] = $matches[0];
++$counter;
return $placeholder;
}
return $matches[0];
},
$template
);
return $template ?? '';
}
/**
* Restores personalization tags from placeholders
*
* @param string $text Text content.
* @return string
*/
private function restore_personalization_tags( string $text ): string {
if ( empty( $this->personalization_tag_placeholders ) ) {
return $text;
}
foreach ( $this->personalization_tag_placeholders as $placeholder => $html_comment ) {
$text = str_replace( $placeholder, $html_comment, $text );
}
return $text;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;
interface Css_Inliner {
/**
* Builds a new instance from the given HTML.
*
* @param string $unprocessed_html raw HTML, must be UTF-encoded, must not be empty.
*
* @return static
*/
public function from_html( string $unprocessed_html ): self;
/**
* Inlines the given CSS into the existing HTML.
*
* @param string $css the CSS to inline, must be UTF-8-encoded.
*
* @return $this
*/
public function inline_css( string $css = '' ): self;
/**
* Renders the normalized and processed HTML.
*
* @return string
*/
public function render(): string;
}

View File

@@ -0,0 +1,91 @@
/* Base CSS rules to be applied to all emails */
/* Created based on original MailPoet template for rendering emails */
/* StyleLint is disabled because some rules contain properties that linter marks as unknown (e.g. mso- prefix), but they are valid for email rendering */
/* stylelint-disable property-no-unknown */
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
-ms-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
word-spacing: normal;
}
a {
text-decoration: none;
}
.email_layout_wrapper {
margin: 0 auto;
width: 100%;
}
.email_content_wrapper {
direction: ltr;
font-size: inherit;
text-align: left;
}
.email_footer {
direction: ltr;
text-align: center;
}
/* https://www.emailonacid.com/blog/article/email-development/tips-for-coding-email-preheaders */
.email_preheader,
.email_preheader * {
color: #fff;
display: none;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
mso-hide: all;
opacity: 0;
overflow: hidden;
-webkit-text-size-adjust: none;
visibility: hidden;
}
@media screen and (max-width: 660px) {
.email-block-column-content {
max-width: 100% !important;
}
.block {
display: block;
width: 100% !important;
}
/* Ensure proper width of columns on mobile when we set 100% and a border is set */
.email-block-column {
box-sizing: border-box;
}
/* We set width to some tables e.g. for wrappers of horizontally aligned images and we force width 100% on mobile */
.email-table-with-width {
width: 100% !important;
}
/* Flex Layout */
.layout-flex-wrapper,
.layout-flex-wrapper tbody,
.layout-flex-wrapper tr {
display: block !important;
width: 100% !important;
}
.layout-flex-item {
display: block !important;
padding-bottom: 8px !important; /* Half of the flex gap between blocks */
padding-left: 0 !important;
width: 100% !important;
}
.layout-flex-item table,
.layout-flex-item td {
box-sizing: border-box !important;
display: block !important;
width: 100% !important;
}
/* Flex Layout End */
}
/* stylelint-enable property-no-unknown */

View File

@@ -0,0 +1,55 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
// phpcs:disable Generic.Files.InlineHTML.Found
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
/**
* Template file to render the current 'wp_template', specifcally for emails.
*
* Variables passed to this template:
*
* @var string $subject The email subject
* @var string $pre_header The email pre-header text
* @var string $template_html The email template HTML content
* @var string $meta_robots Meta robots tag content
* @var array{contentSize: string} $layout Layout configuration
*/
?><!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<title><?php echo esc_html( $subject ); ?></title>
<meta charset="<?php bloginfo( 'charset' ); ?>" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="format-detection" content="telephone=no" />
<?php echo $meta_robots; ?>
<!-- Forced Styles -->
</head>
<body>
<!--[if mso | IE]><table align="center" role="presentation" border="0" cellpadding="0" cellspacing="0" width="<?php echo esc_attr( $layout['contentSize'] ); ?>" style="width:<?php echo esc_attr( $layout['contentSize'] ); ?>"><tr><td><![endif]-->
<div class="email_layout_wrapper" style="max-width: <?php echo esc_attr( $layout['contentSize'] ); ?>">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td class="email_preheader" height="1">
<?php echo esc_html( wp_strip_all_tags( $pre_header ) ); ?>
</td>
</tr>
<tr>
<td class="email_content_wrapper">
<?php echo $template_html; ?>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</body>
</html>

View File

@@ -0,0 +1,142 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Templates;
/**
* The class represents a template
*/
class Template {
/**
* Plugin uri used in the template name.
*
* @var string $plugin_uri
*/
private string $plugin_uri;
/**
* The template slug used in the template name.
*
* @var string $slug
*/
private string $slug;
/**
* The template name used for block template registration.
*
* @var string $name
*/
private string $name;
/**
* The template title.
*
* @var string $title
*/
private string $title;
/**
* The template description.
*
* @var string $description
*/
private string $description;
/**
* The template content.
*
* @var string $content
*/
private string $content;
/**
* The list of supoorted post types.
*
* @var string[]
*/
private array $post_types;
/**
* Constructor of the class.
*
* @param string $plugin_uri The plugin uri.
* @param string $slug The template slug.
* @param string $title The template title.
* @param string $description The template description.
* @param string $content The template content.
* @param string[] $post_types The list of post types supported by the template.
*/
public function __construct(
string $plugin_uri,
string $slug,
string $title,
string $description,
string $content,
array $post_types = array()
) {
$this->plugin_uri = $plugin_uri;
$this->slug = $slug;
$this->name = "{$plugin_uri}//{$slug}"; // The template name is composed from the namespace and the slug.
$this->title = $title;
$this->description = $description;
$this->content = $content;
$this->post_types = $post_types;
}
/**
* Get the plugin uri.
*
* @return string
*/
public function get_pluginuri(): string {
return $this->plugin_uri;
}
/**
* Get the template slug.
*
* @return string
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Get the template name composed from the plugin_uri and the slug.
*
* @return string
*/
public function get_name(): string {
return $this->name;
}
/**
* Get the template title.
*
* @return string
*/
public function get_title(): string {
return $this->title;
}
/**
* Get the template description.
*
* @return string
*/
public function get_description(): string {
return $this->description;
}
/**
* Get the template content.
*
* @return string
*/
public function get_content(): string {
return $this->content;
}
/**
* Get the list of supported post types.
*
* @return string[]
*/
public function get_post_types(): array {
return $this->post_types;
}
}

View File

@@ -0,0 +1,90 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Templates;
/**
* Registry for email templates.
*/
class Templates_Registry {
/**
* List of registered templates.
*
* @var Template[]
*/
private $templates = array();
/**
* Initialize the template registry.
* This method should be called only once.
*
* @return void
*/
public function initialize(): void {
apply_filters( 'woocommerce_email_editor_register_templates', $this );
}
/**
* Register a template instance in the registry.
*
* @param Template $template The template to register.
* @return void
*/
public function register( Template $template ): void {
if ( ! \WP_Block_Templates_Registry::get_instance()->is_registered( $template->get_name() ) ) {
// skip registration if the template was already registered.
$result = register_block_template(
$template->get_name(),
array(
'title' => $template->get_title(),
'description' => $template->get_description(),
'content' => $template->get_content(),
'post_types' => $template->get_post_types(),
)
);
$this->templates[ $template->get_name() ] = $template;
}
}
/**
* Retrieve a template by its name.
* Example: get_by_name( 'woocommerce//email-general' ) will return the instance of Template with identical name.
*
* @param string $name The name of the template.
* @return Template|null The template object or null if not found.
*/
public function get_by_name( string $name ): ?Template {
return $this->templates[ $name ] ?? null;
}
/**
* Retrieve a template by its slug.
* Example: get_by_slug( 'email-general' ) will return the instance of Template with identical slug.
*
* @param string $slug The slug of the template.
* @return Template|null The template object or null if not found.
*/
public function get_by_slug( string $slug ): ?Template {
foreach ( $this->templates as $template ) {
if ( $template->get_slug() === $slug ) {
return $template;
}
}
return null;
}
/**
* Retrieve all registered templates.
*
* @return array List of all registered templates.
*/
public function get_all() {
return $this->templates;
}
}

View File

@@ -0,0 +1,176 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Templates;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
use WP_Block_Template;
/**
* Templates class.
*/
class Templates {
/**
* The plugin slug.
*
* @var string $plugin_slug
*/
private string $template_prefix = 'woocommerce';
/**
* The post type.
*
* @var string[] $post_type
*/
private array $post_types = array();
/**
* The template directory.
*
* @var string $template_directory
*/
private string $template_directory = __DIR__ . DIRECTORY_SEPARATOR;
/**
* The templates registry.
*
* @var Templates_Registry $templates_registry
*/
private Templates_Registry $templates_registry;
/**
* Constructor of the class.
*
* @param Templates_Registry $templates_registry The templates registry.
*/
public function __construct( Templates_Registry $templates_registry ) {
$this->templates_registry = $templates_registry;
}
/**
* Initializes the class.
*
* @param string[] $post_types The list of post types registered for usage with email editor.
*/
public function initialize( array $post_types ): void {
$this->post_types = $post_types;
add_filter( 'theme_templates', array( $this, 'add_theme_templates' ), 10, 4 ); // Workaround needed when saving post template association.
add_filter( 'woocommerce_email_editor_register_templates', array( $this, 'register_templates' ) );
$this->templates_registry->initialize();
$this->register_post_types_to_api();
}
/**
* Get a block template by ID.
*
* @param string $template_slug The template slug.
* @return WP_Block_Template|null
*/
public function get_block_template( $template_slug ) {
// Template id is always prefixed by active theme and get_stylesheet returns the active theme slug.
$template_id = get_stylesheet() . '//' . $template_slug;
return get_block_template( $template_id );
}
/**
* Register the templates via register_block_template
*
* @param Templates_Registry $templates_registry The templates registry.
*/
public function register_templates( Templates_Registry $templates_registry ): Templates_Registry {
// Register basic blank template.
$general_email_slug = 'email-general';
$template_filename = $general_email_slug . '.html';
$general_email = new Template(
$this->template_prefix,
$general_email_slug,
__( 'General Email', 'woocommerce' ),
__( 'A general template for emails.', 'woocommerce' ),
(string) file_get_contents( $this->template_directory . $template_filename ),
$this->post_types
);
$templates_registry->register( $general_email );
return $templates_registry;
}
/**
* Register post_types property to the templates rest api response.
*
* There is a PR that adds the property into the core https://github.com/WordPress/wordpress-develop/pull/7530
* Until it is merged, we need to add it manually.
*/
public function register_post_types_to_api(): void {
$controller = new \WP_REST_Templates_Controller( 'wp_template' );
$schema = $controller->get_item_schema();
// Future compatibility check if the post_types property is already registered.
if ( isset( $schema['properties']['post_types'] ) ) {
return;
}
register_rest_field(
'wp_template',
'post_types',
array(
'get_callback' => array( $this, 'get_post_types' ),
'update_callback' => null,
'schema' => Builder::string()->to_array(),
)
);
}
/**
* This is a callback function for adding post_types property to templates rest api response.
*
* @param array $response_object The rest API response object.
* @return array
*/
public function get_post_types( $response_object ): array {
$template = $this->templates_registry->get_by_slug( $response_object['slug'] ?? '' );
if ( $template ) {
return $template->get_post_types();
}
return $response_object['post_types'] ?? array();
}
/**
* This is need to enable saving post template association.
* When a theme doesn't support block_templates feature the association is not saved, because templates registered via register_block_template are not added to the list of available templates.
* https://github.com/WordPress/wordpress-develop/blob/cdc2f255acce57372b849d6278c4156e1056c749/src/wp-includes/class-wp-theme.php#L1355
*
* This function ensures that the email templates are in the list which is used for checking if the template can be saved in the association.
* See https://github.com/WordPress/wordpress-develop/blob/cdc2f255acce57372b849d6278c4156e1056c749/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php#L1595-L1599
*
* @param array $templates The templates.
* @param string $theme The theme.
* @param \WP_Post $post The post.
* @param string $post_type The post type.
* @return array
*/
public function add_theme_templates( $templates, $theme, $post, $post_type ) {
if ( $post_type && ! in_array( $post_type, $this->post_types, true ) ) {
return $templates;
}
$block_templates = get_block_templates();
$email_templates_slugs = array_map(
function ( Template $template ) {
return $template->get_slug();
},
$this->templates_registry->get_all()
);
foreach ( $block_templates as $block_template ) {
if ( ! in_array( $block_template->slug, $email_templates_slugs, true ) ) {
continue;
}
if ( isset( $templates[ $block_template->slug ] ) ) {
continue;
}
$templates[ $block_template->slug ] = $block_template->title; // Requires only the template title, not the full template object.
}
return $templates;
}
}

View File

@@ -0,0 +1,5 @@
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:post-content {"lock":{"move":true,"remove":true},"layout":{"type":"default"}} /-->
</div>
<!-- /wp:group -->

View File

@@ -0,0 +1,13 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
* Template canvas file to render the emails custom post type.
*
* @package Automattic\WooCommerce\EmailEditor
*/
// get the rendered post HTML content.
$template_html = apply_filters( 'woocommerce_email_editor_preview_post_template_html', get_post() );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $template_html;

View File

@@ -0,0 +1,273 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types=1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
/**
* Class responsible for managing email editor assets.
*/
class Assets_Manager {
/**
* Settings controller instance.
*
* @var Settings_Controller
*/
private Settings_Controller $settings_controller;
/**
* Theme controller instance.
*
* @var Theme_Controller
*/
private Theme_Controller $theme_controller;
/**
* User theme instance.
*
* @var User_Theme
*/
private User_Theme $user_theme;
/**
* Email editor assets path.
*
* @var string
*/
private string $assets_path = '';
/**
* Email editor assets URL.
*
* @var string
*/
private string $assets_url = '';
/**
* Logger instance.
*
* @var Email_Editor_Logger
*/
private Email_Editor_Logger $logger;
/**
* Assets Manager constructor with all dependencies.
*
* @param Settings_Controller $settings_controller Settings controller instance.
* @param Theme_Controller $theme_controller Theme controller instance.
* @param User_Theme $user_theme User theme instance.
* @param Email_Editor_Logger $logger Email editor logger instance.
*/
public function __construct(
Settings_Controller $settings_controller,
Theme_Controller $theme_controller,
User_Theme $user_theme,
Email_Editor_Logger $logger
) {
$this->settings_controller = $settings_controller;
$this->theme_controller = $theme_controller;
$this->user_theme = $user_theme;
$this->logger = $logger;
}
/**
* Sets the path for the email editor assets.
*
* @param string $assets_path The path to the email editor assets directory.
* @return void
*/
public function set_assets_path( string $assets_path ): void {
$this->assets_path = $assets_path;
}
/**
* Sets the URL for the email editor assets.
*
* @param string $assets_url The URL to the email editor assets directory.
* @return void
*/
public function set_assets_url( string $assets_url ): void {
$this->assets_url = $assets_url;
}
/**
* Initialize the assets manager.
*/
public function initialize(): void {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_styles' ) );
}
/**
* Enqueue admin styles that are needed by the email editor.
*/
public function enqueue_admin_styles(): void {
// Calling action that loads registered blockTypes.
do_action( 'enqueue_block_editor_assets' );
// Load CSS from Post Editor.
wp_enqueue_style( 'wp-edit-post' );
// Load CSS for the format library - used for example in popover.
wp_enqueue_style( 'wp-format-library' );
// Enqueue CSS containing --wp--preset variables.
wp_enqueue_global_styles_css_custom_properties();
// Enqueue media library scripts.
wp_enqueue_media();
}
/**
* Render the email editor's required HTML and admin header.
*
* @param string $element_id Optional. The ID of the main container element. Default is 'woocommerce-email-editor'.
*/
public function render_email_editor_html( string $element_id = 'woocommerce-email-editor' ): void {
// @phpstan-ignore-next-line -- PHPStan tried to check if the file exists.
require_once ABSPATH . 'wp-admin/admin-header.php';
echo '<div id="' . esc_attr( $element_id ) . '" class="block-editor block-editor__container hide-if-no-js"></div>';
}
/**
* Load editor assets.
*
* @param \WP_Post|\WP_Block_Template $edited_item The edited post or template.
* @param string $script_name The name of the registered script.
*/
public function load_editor_assets( $edited_item, string $script_name ): void {
$post_type = $edited_item instanceof \WP_Post ? $edited_item->post_type : 'wp_template';
$post_id = $edited_item instanceof \WP_Post ? $edited_item->ID : $edited_item->id;
$email_editor_assets_path = rtrim( $this->assets_path, '/' ) . '/';
$email_editor_assets_url = rtrim( $this->assets_url, '/' ) . '/';
// Email editor rich text JS - Because the Personalization Tags depend on Gutenberg 19.8.0 and higher
// the following code replaces used Rich Text for the version containing the necessary changes.
$rich_text_assets_file = $email_editor_assets_path . 'assets/rich-text.asset.php';
if ( ! file_exists( $rich_text_assets_file ) ) {
$this->logger->error( 'Rich Text assets file does not exist.', array( 'path' => $rich_text_assets_file ) );
} else {
$rich_text_assets = require $rich_text_assets_file;
wp_deregister_script( 'wp-rich-text' );
wp_enqueue_script(
'wp-rich-text',
$email_editor_assets_url . 'assets/rich-text.js',
$rich_text_assets['dependencies'],
$rich_text_assets['version'],
true
);
}
// End of replacing Rich Text package.
$assets_file = $email_editor_assets_path . 'style.asset.php';
if ( ! file_exists( $assets_file ) ) {
$this->logger->error( 'Email editor assets file does not exist.', array( 'path' => $assets_file ) );
} else {
$assets_file = require $assets_file;
wp_enqueue_style(
'wc-admin-email-editor-integration',
$email_editor_assets_url . 'style.css',
array(),
$assets_file['version']
);
}
// The get_block_categories() function expects a WP_Post or WP_Block_Editor_Context object.
// Therefore, we need to create an instance of WP_Block_Editor_Context when $edited_item is an instance of WP_Block_Template.
if ( $edited_item instanceof \WP_Block_Template ) {
$context = new \WP_Block_Editor_Context(
array(
'post' => $edited_item,
)
);
} else {
$context = $edited_item;
}
// The email editor needs to load block categories to avoid warning and missing category names.
// See: https://github.com/WordPress/WordPress/blob/753817d462955eb4e40a89034b7b7c375a1e43f3/wp-admin/edit-form-blocks.php#L116-L120.
wp_add_inline_script(
'wp-blocks',
sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( get_block_categories( $context ), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ),
'after'
);
// Preload server-registered block schemas to avoid warning about missing block titles.
// See: https://github.com/WordPress/WordPress/blob/753817d462955eb4e40a89034b7b7c375a1e43f3/wp-admin/edit-form-blocks.php#L144C1-L148C3.
wp_add_inline_script(
'wp-blocks',
sprintf( 'wp.blocks.unstable__bootstrapServerSideBlockDefinitions( %s );', wp_json_encode( get_block_editor_server_block_settings(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) )
);
$localization_data = array(
'current_post_type' => $post_type,
'current_post_id' => $post_id,
'current_wp_user_email' => wp_get_current_user()->user_email,
'editor_settings' => $this->settings_controller->get_settings(),
'editor_theme' => $this->theme_controller->get_base_theme()->get_raw_data(),
'user_theme_post_id' => $this->user_theme->get_user_theme_post()->ID,
'urls' => array(
'listings' => admin_url( 'admin.php?page=wc-settings&tab=email' ),
'send' => admin_url( 'admin.php?page=wc-settings&tab=email' ),
'back' => admin_url( 'admin.php?page=wc-settings&tab=email' ),
'createCoupon' => admin_url( 'post-new.php?post_type=shop_coupon' ),
),
);
wp_localize_script(
$script_name,
'WooCommerceEmailEditor',
apply_filters( 'woocommerce_email_editor_script_localization_data', $localization_data )
);
$this->preload_rest_api_data( $post_id, $post_type );
}
/**
* Preload REST API data for the email editor.
*
* @param int|string $post_id The post ID.
* @param string $post_type The post type.
*/
private function preload_rest_api_data( $post_id, string $post_type ): void {
$email_post_type = $post_type;
$user_theme_post_id = $this->user_theme->get_user_theme_post()->ID;
$template_slug = get_post_meta( (int) $post_id, '_wp_page_template', true );
$routes = array(
"/wp/v2/{$email_post_type}/" . intval( $post_id ) . '?context=edit',
"/wp/v2/types/{$email_post_type}?context=edit",
'/wp/v2/global-styles/' . intval( $user_theme_post_id ) . '?context=view', // Global email styles.
'/wp/v2/block-patterns/patterns',
'/wp/v2/templates?context=view',
'/wp/v2/block-patterns/categories',
'/wp/v2/settings',
'/wp/v2/types?context=view',
'/wp/v2/taxonomies?context=view',
);
if ( is_string( $template_slug ) ) {
$routes[] = '/wp/v2/templates/lookup?slug=' . $template_slug;
} else {
$routes[] = "/wp/v2/{$email_post_type}?context=edit&per_page=30&status=publish,sent";
}
// Preload the data for the specified routes.
$preload_data = array_reduce(
$routes,
'rest_preload_api_request',
array()
);
// Add inline script to set up preloading middleware.
wp_add_inline_script(
'wp-blocks',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
wp_json_encode( $preload_data, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
)
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
/**
* This class is responsible checking the dependencies of the email editor.
*/
class Dependency_Check {
/**
* Minimum WordPress version required for the email editor.
*/
public const MIN_WP_VERSION = '6.7';
/**
* Checks if all dependencies are met.
*/
public function are_dependencies_met(): bool {
if ( ! $this->is_wp_version_compatible() ) {
return false;
}
return true;
}
/**
* Checks if the WordPress version is supported.
*/
private function is_wp_version_compatible(): bool {
return version_compare( get_bloginfo( 'version' ), self::MIN_WP_VERSION, '>=' );
}
}

View File

@@ -0,0 +1,160 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
/**
* Class for email API controller.
*/
class Email_Api_Controller {
/**
* Personalization tags registry to get all personalization tags.
*
* @var Personalization_Tags_Registry
*/
private Personalization_Tags_Registry $personalization_tags_registry;
/**
* Email_Api_Controller constructor with all dependencies.
*
* @param Personalization_Tags_Registry $personalization_tags_registry Personalization tags registry.
*/
public function __construct( Personalization_Tags_Registry $personalization_tags_registry ) {
$this->personalization_tags_registry = $personalization_tags_registry;
}
/**
* Returns email specific data.
*
* @return array - Email specific data such styles.
*/
public function get_email_data(): array {
// Here comes code getting Email specific data that will be passed on 'email_data' attribute.
return array();
}
/**
* Update Email specific data we store.
*
* @param array $data - Email specific data.
* @param WP_Post $email_post - Email post object.
*/
public function save_email_data( array $data, WP_Post $email_post ): void {
// Here comes code saving of Email specific data that will be passed on 'email_data' attribute.
}
/**
* Sends preview email.
*
* @param WP_REST_Request $request Route request parameters.
* @return WP_REST_Response
* @phpstan-param WP_REST_Request<array{_locale: string, email: string, postId: int}> $request
*/
public function send_preview_email_data( WP_REST_Request $request ): WP_REST_Response {
/**
* $data - Post Data
* format
* [_locale] => user
* [email] => Provided email address
* [postId] => POST_ID
*
* @var array{_locale: string, email: string, postId: int} $data
*/
$data = $request->get_params();
try {
$result = apply_filters( 'woocommerce_email_editor_send_preview_email', $data );
return new WP_REST_Response(
array(
'success' => (bool) $result,
'result' => $result,
),
$result ? 200 : 400
);
} catch ( \Exception $exception ) {
return new WP_REST_Response( array( 'error' => $exception->getMessage() ), 400 );
}
}
/**
* Returns all registered personalization tags.
* We need to keep this endpoint for backward compatibility for older JS clients.
* We might consider removing it in the future (perhaps in late 2026).
*
* @deprecated Use get_personalization_tags_collection instead.
* @return WP_REST_Response
*/
public function get_personalization_tags(): WP_REST_Response {
$tags = $this->personalization_tags_registry->get_all();
return new WP_REST_Response(
array(
'success' => true,
'result' => array_values(
array_map(
function ( Personalization_Tag $tag ) {
return array(
'name' => $tag->get_name(),
'token' => $tag->get_token(),
'category' => $tag->get_category(),
'attributes' => $tag->get_attributes(),
'valueToInsert' => $tag->get_value_to_insert(),
'postTypes' => $tag->get_post_types(),
);
},
$tags
),
),
),
200
);
}
/**
* Returns all registered personalization tags as a collection.
* This endpoint follows WordPress REST API conventions by returning
* the array directly instead of wrapping it in a response object.
*
* @return WP_REST_Response
*/
public function get_personalization_tags_collection(): WP_REST_Response {
$tags = $this->personalization_tags_registry->get_all();
return new WP_REST_Response(
array_values(
array_map(
function ( Personalization_Tag $tag ) {
return array(
'name' => $tag->get_name(),
'token' => $tag->get_token(),
'category' => $tag->get_category(),
'attributes' => $tag->get_attributes(),
'valueToInsert' => $tag->get_value_to_insert(),
'postTypes' => $tag->get_post_types(),
);
},
$tags
)
),
200
);
}
/**
* Returns the schema for email data.
*
* @return array
*/
public function get_email_data_schema(): array {
return Builder::object()->to_array();
}
}

View File

@@ -0,0 +1,389 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use Automattic\WooCommerce\EmailEditor\Engine\Patterns\Patterns;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\EmailEditor\Engine\Templates\Templates;
use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
use WP_Post;
use WP_Theme_JSON;
/**
* Email editor class.
*
* @phpstan-type EmailPostType array{name: string, args: array, meta: array{key: string, args: array}[]}
* See register_post_type for details about EmailPostType args.
*/
class Email_Editor {
public const WOOCOMMERCE_EMAIL_META_THEME_TYPE = 'woocommerce_email_theme';
/**
* Property for the email API controller.
*
* @var Email_Api_Controller Email API controller.
*/
private Email_Api_Controller $email_api_controller;
/**
* Property for the templates.
*
* @var Templates Templates.
*/
private Templates $templates;
/**
* Property for the patterns.
*
* @var Patterns Patterns.
*/
private Patterns $patterns;
/**
* Property for the send preview email controller.
*
* @var Send_Preview_Email Send Preview controller.
*/
private Send_Preview_Email $send_preview_email;
/**
* Property for Personalization_Tags_Controller that allows initializing personalization tags.
*
* @var Personalization_Tags_Registry Personalization tags registry.
*/
private Personalization_Tags_Registry $personalization_tags_registry;
/**
* Property for the logger.
*
* @var Email_Editor_Logger Logger instance.
*/
private Email_Editor_Logger $logger;
/**
* Property for Assets Manager that should be initialized.
*
* @var Assets_Manager Assets manager instance.
*/
private Assets_Manager $assets_manager;
/**
* Constructor.
*
* @param Email_Api_Controller $email_api_controller Email API controller.
* @param Templates $templates Templates.
* @param Patterns $patterns Patterns.
* @param Send_Preview_Email $send_preview_email Preview email controller.
* @param Personalization_Tags_Registry $personalization_tags_controller Personalization tags registry that allows initializing personalization tags.
* @param Email_Editor_Logger $logger Logger instance.
* @param Assets_Manager $assets_manager Assets manager instance.
*/
public function __construct(
Email_Api_Controller $email_api_controller,
Templates $templates,
Patterns $patterns,
Send_Preview_Email $send_preview_email,
Personalization_Tags_Registry $personalization_tags_controller,
Email_Editor_Logger $logger,
Assets_Manager $assets_manager
) {
$this->email_api_controller = $email_api_controller;
$this->templates = $templates;
$this->patterns = $patterns;
$this->send_preview_email = $send_preview_email;
$this->personalization_tags_registry = $personalization_tags_controller;
$this->logger = $logger;
$this->assets_manager = $assets_manager;
}
/**
* Initialize the email editor.
*
* @return void
*/
public function initialize(): void {
$this->logger->info( 'Initializing email editor' );
do_action( 'woocommerce_email_editor_initialized' );
add_filter( 'woocommerce_email_editor_rendering_theme_styles', array( $this, 'extend_email_theme_styles' ), 10, 2 );
$this->register_block_patterns();
$this->register_email_post_types();
$this->register_block_templates();
$this->register_email_post_sent_status();
$this->register_personalization_tags();
$is_editor_page = apply_filters( 'woocommerce_is_email_editor_page', false );
if ( $is_editor_page ) {
$this->extend_email_post_api();
// Initialize the assets manager.
$this->assets_manager->initialize();
}
add_action( 'rest_api_init', array( $this, 'register_email_editor_api_routes' ) );
add_filter( 'woocommerce_email_editor_send_preview_email', array( $this->send_preview_email, 'send_preview_email' ), 11, 1 ); // allow for other filter methods to take precedent.
add_filter( 'single_template', array( $this, 'load_email_preview_template' ) );
add_filter( 'preview_post_link', array( $this, 'update_preview_post_link' ), 10, 2 );
$this->logger->info( 'Email editor initialized successfully' );
}
/**
* Register block templates.
*
* @return void
*/
private function register_block_templates(): void {
// Since we cannot currently disable blocks in the editor for specific templates, disable templates when viewing site editor. @see https://github.com/WordPress/gutenberg/issues/41062.
$request_uri = '';
if ( isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] ) ) {
$request_uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) );
}
if ( strstr( $request_uri, 'site-editor.php' ) === false ) {
$post_types = array_column( $this->get_post_types(), 'name' );
$this->templates->initialize( $post_types );
}
}
/**
* Register block patterns.
*
* @return void
*/
private function register_block_patterns(): void {
$this->patterns->initialize();
}
/**
* Register all custom post types that should be edited via the email editor
* The post types are added via woocommerce_email_editor_post_types filter.
*
* @return void
*/
private function register_email_post_types(): void {
foreach ( $this->get_post_types() as $post_type ) {
register_post_type(
$post_type['name'],
array_merge( $this->get_default_email_post_args(), $post_type['args'] )
);
}
}
/**
* Register all personalization tags registered via
* the woocommerce_email_editor_register_personalization_tags filter.
*
* @return void
*/
private function register_personalization_tags(): void {
$this->personalization_tags_registry->initialize();
}
/**
* Returns the email post types.
*
* @return array
* @phpstan-return EmailPostType[]
*/
private function get_post_types(): array {
$post_types = array();
return apply_filters( 'woocommerce_email_editor_post_types', $post_types );
}
/**
* Returns the default arguments for email post types.
*
* @return array
*/
private function get_default_email_post_args(): array {
return array(
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'supports' => array(
'editor' => array(
'default-mode' => 'template-locked',
),
'title',
'custom-fields',
), // 'custom-fields' is required for loading meta fields via API.
'has_archive' => true,
'show_in_rest' => true, // Important to enable Gutenberg editor.
'default_rendering_mode' => 'template-locked',
'publicly_queryable' => true, // required by the preview in new tab feature.
);
}
/**
* Register the 'sent' post status for emails.
*
* @return void
*/
private function register_email_post_sent_status(): void {
$default_args = array(
'public' => false,
'exclude_from_search' => true,
'internal' => true, // for now, we hide it, if we use the status in the listings we may flip this and following values.
'show_in_admin_all_list' => false,
'show_in_admin_status_list' => false,
'private' => true, // required by the preview in new tab feature for sent post (newsletter). Posts are only visible to site admins and editors.
);
$args = apply_filters( 'woocommerce_email_editor_post_sent_status_args', $default_args );
register_post_status(
'sent',
$args
);
}
/**
* Extends the email post types with email specific data.
*
* @return void
*/
public function extend_email_post_api() {
$email_post_types = array_column( $this->get_post_types(), 'name' );
register_rest_field(
$email_post_types,
'email_data',
array(
'get_callback' => array( $this->email_api_controller, 'get_email_data' ),
'update_callback' => array( $this->email_api_controller, 'save_email_data' ),
'schema' => $this->email_api_controller->get_email_data_schema(),
)
);
}
/**
* Registers the API route endpoint for the email editor
*
* @return void
*/
public function register_email_editor_api_routes() {
register_rest_route(
'woocommerce-email-editor/v1',
'/send_preview_email',
array(
'methods' => 'POST',
'callback' => array( $this->email_api_controller, 'send_preview_email_data' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
register_rest_route(
'woocommerce-email-editor/v1',
'/get_personalization_tags',
array(
'methods' => 'GET',
'callback' => array( $this->email_api_controller, 'get_personalization_tags' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
register_rest_route(
'woocommerce-email-editor/v1',
'/personalization_tags',
array(
'methods' => 'GET',
'callback' => array( $this->email_api_controller, 'get_personalization_tags_collection' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
}
/**
* Extends the email theme styles with the email specific styles.
*
* @param WP_Theme_JSON $theme Email theme styles.
* @param WP_Post $post Email post object.
* @return WP_Theme_JSON
*/
public function extend_email_theme_styles( WP_Theme_JSON $theme, WP_Post $post ): WP_Theme_JSON {
$email_theme = get_post_meta( $post->ID, self::WOOCOMMERCE_EMAIL_META_THEME_TYPE, true );
if ( $email_theme && is_array( $email_theme ) ) {
$theme->merge( new WP_Theme_JSON( $email_theme ) );
}
return $theme;
}
/**
* Get the current post object
*
* @return array|mixed|WP_Post|null
*/
public function get_current_post() {
if ( isset( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$post_id = 0;
if ( is_string( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- data valid
$post_id = intval( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- data valid
}
$current_post = get_post( $post_id );
} else {
$current_post = $GLOBALS['post'];
}
return $current_post;
}
/**
* Check if the current post type is an email post type.
*
* @param string $current_post_type The current post type.
* @return bool
*/
private function current_post_is_email_post_type( $current_post_type ): bool {
if ( ! $current_post_type ) {
return false;
}
$email_post_types = array_column( $this->get_post_types(), 'name' );
return in_array( $current_post_type, $email_post_types, true );
}
/**
* Use a custom page template for the email editor frontend rendering.
*
* @param string $template post template.
* @return string
*/
public function load_email_preview_template( $template ) {
$post = $this->get_current_post();
if ( ! $post instanceof \WP_Post ) {
return $template;
}
if ( ! $this->current_post_is_email_post_type( $post->post_type ) ) {
return $template;
}
add_filter(
'woocommerce_email_editor_preview_post_template_html',
function () use ( $post ) {
// Generate HTML content for email editor post.
return $this->send_preview_email->render_html( $post );
}
);
return __DIR__ . '/Templates/single-email-post-template.php';
}
/**
* Update the preview post link to remove the preview nonce.
*
* @param string $preview_link The preview post link.
* @param WP_Post $post The post object.
* @return string
*/
public function update_preview_post_link( $preview_link, $post ) {
if ( ! $post instanceof \WP_Post ) {
return $preview_link;
}
if ( ! $this->current_post_is_email_post_type( $post->post_type ) ) {
return $preview_link;
}
// Remove preview_nonce from the link.
return remove_query_arg( 'preview_nonce', $preview_link );
}
}

View File

@@ -0,0 +1,114 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
/**
* Class for email styles schema.
*/
class Email_Styles_Schema {
/**
* Returns the schema for email styles.
*
* @return array
*/
public function get_schema(): array {
$typography_props = Builder::object(
array(
'fontFamily' => Builder::string()->nullable(),
'fontSize' => Builder::string()->nullable(),
'fontStyle' => Builder::string()->nullable(),
'fontWeight' => Builder::string()->nullable(),
'letterSpacing' => Builder::string()->nullable(),
'lineHeight' => Builder::string()->nullable(),
'textTransform' => Builder::string()->nullable(),
'textDecoration' => Builder::string()->nullable(),
)
)->nullable();
return Builder::object(
array(
'version' => Builder::integer(),
'styles' => Builder::object(
array(
'spacing' => Builder::object(
array(
'padding' => Builder::object(
array(
'top' => Builder::string(),
'right' => Builder::string(),
'bottom' => Builder::string(),
'left' => Builder::string(),
)
)->nullable(),
'blockGap' => Builder::string()->nullable(),
)
)->nullable(),
'color' => Builder::object(
array(
'background' => Builder::string()->nullable(),
'text' => Builder::string()->nullable(),
)
)->nullable(),
'typography' => $typography_props,
'elements' => Builder::object(
array(
'heading' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'button' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'link' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'h1' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'h2' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'h3' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'h4' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'h5' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
'h6' => Builder::object(
array(
'typography' => $typography_props,
)
)->nullable(),
)
)->nullable(),
)
)->nullable(),
)
)->to_array();
}
}

View File

@@ -0,0 +1,202 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\HTML_Tag_Processor;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
/**
* Class for replacing personalization tags with their values in the email content.
*/
class Personalizer {
/**
* Personalization tags registry.
*
* @var Personalization_Tags_Registry
*/
private Personalization_Tags_Registry $tags_registry;
/**
* Context for personalization tags.
*
* The `context` is an associative array containing recipient-specific or
* campaign-specific data. This data is used to resolve personalization tags
* and provide input for tag callbacks during email content processing.
*
* Example context:
* array(
* 'recipient_email' => 'john@example.com', // Recipient's email
* 'custom_field' => 'Special Value', // Custom campaign-specific data
* )
*
* @var array<string, mixed>
*/
private array $context;
/**
* Class constructor with required dependencies.
*
* @param Personalization_Tags_Registry $tags_registry Personalization tags registry.
*/
public function __construct( Personalization_Tags_Registry $tags_registry ) {
$this->tags_registry = $tags_registry;
$this->context = array();
}
/**
* Set the context for personalization.
*
* The `context` provides data required for resolving personalization tags
* during content processing. This method allows the context to be set or updated.
*
* Example usage:
* $personalizer->set_context(array(
* 'recipient_email' => 'john@example.com',
* ));
*
* @param array<string, mixed> $context Associative array containing personalization data.
* @return void
*/
public function set_context( array $context ) {
$this->context = $context;
}
/**
* Get the current context.
*
* The `context` is an associative array containing recipient-specific or
* campaign-specific data. This data is used to resolve personalization tags
* and provide input for tag callbacks during email content processing.
*
* @return array<string, mixed> The current context.
*/
public function get_context(): array {
return $this->context;
}
/**
* Personalize the content by replacing the personalization tags with their values.
*
* @param string $content The content to personalize.
* @return string The personalized content.
*/
public function personalize_content( string $content ): string {
$content_processor = new HTML_Tag_Processor( $content );
while ( $content_processor->next_token() ) {
if ( $content_processor->get_token_type() === '#comment' ) {
$modifiable_text = $content_processor->get_modifiable_text();
$token = $this->parse_token( $modifiable_text );
$tag = $this->tags_registry->get_by_token( $token['token'] );
if ( ! $tag ) {
continue;
}
$value = $tag->execute_callback( $this->context, $token['arguments'] );
$content_processor->replace_token( $value );
} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'TITLE' ) {
// The title tag contains the subject of the email which should be personalized. HTML_Tag_Processor does parse the header tags.
$modifiable_text = $content_processor->get_modifiable_text();
$title = $this->personalize_content( $modifiable_text );
$content_processor->set_modifiable_text( $title );
} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'A' && $content_processor->get_attribute( 'data-link-href' ) ) {
// The anchor tag contains the data-link-href attribute which should be personalized.
$href = (string) $content_processor->get_attribute( 'data-link-href' );
$token = $this->parse_token( $href );
$tag = $this->tags_registry->get_by_token( $token['token'] );
if ( ! $tag ) {
continue;
}
$value = $tag->execute_callback( $this->context, $token['arguments'] );
$value = $this->replace_link_href( $href, $tag->get_token(), $value );
if ( $value ) {
$content_processor->set_attribute( 'href', $value );
$content_processor->remove_attribute( 'data-link-href' );
$content_processor->remove_attribute( 'contenteditable' );
}
} elseif ( $content_processor->get_token_type() === '#tag' && $content_processor->get_tag() === 'A' ) {
$href = $content_processor->get_attribute( 'href' );
if ( ! is_string( $href ) ) {
continue;
}
if ( ! $href || ! preg_match( '/\[[a-z-\/]+\]/', urldecode( $href ), $matches ) ) {
continue;
}
$token = $this->parse_token( $matches[0] );
$tag = $this->tags_registry->get_by_token( $token['token'] );
if ( ! $tag ) {
continue;
}
$value = $tag->execute_callback( $this->context, $token['arguments'] );
if ( $value ) {
$content_processor->set_attribute( 'href', $value );
}
}
}
$content_processor->flush_updates();
return $content_processor->get_updated_html();
}
/**
* Parse a personalization tag to the token and attributes.
*
* @param string $token The token to parse.
* @return array{token: string, arguments: array<string, string>} The parsed token.
*/
private function parse_token( string $token ): array {
$result = array(
'token' => '',
'arguments' => array(),
);
// Step 1: Separate the tag and attributes.
if ( preg_match( '/^\[([a-zA-Z0-9\-\/]+)\s*(.*?)\]$/', trim( $token ), $matches ) ) {
$result['token'] = "[{$matches[1]}]"; // The tag part (e.g., "[mailpoet/subscriber-firstname]").
$attributes_string = $matches[2]; // The attributes part (e.g., 'default="subscriber"').
// Step 2: Extract attributes from the attribute string.
if ( preg_match_all( '/(\w+)=["\']([^"\']*)["\']/', $attributes_string, $attribute_matches, PREG_SET_ORDER ) ) {
foreach ( $attribute_matches as $attribute ) {
$result['arguments'][ $attribute[1] ] = $attribute[2];
}
}
}
return $result;
}
/**
* Replace the href attribute of the anchor tag with the personalized value.
* The replacement uses regular expression to match the shortcode and its attributes.
*
* @param string $content The content to replace the link href.
* @param string $token Personalization tag token.
* @param string $replacement The callback output to replace the link href.
* @return string
*/
private function replace_link_href( string $content, string $token, string $replacement ) {
// Escape the shortcode name for safe regex usage and strip the brackets.
$escaped_shortcode = preg_quote( substr( $token, 1, strlen( $token ) - 2 ), '/' );
// Create a regex pattern dynamically.
$pattern = '/\[' . $escaped_shortcode . '(?:\s+[^\]]+)?\]/';
return trim( (string) preg_replace( $pattern, $replacement, $content ) );
}
}

View File

@@ -0,0 +1,204 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Engine;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Renderer;
/**
* Class Send_Preview_Email
*
* This class is responsible for handling the functionality to send preview emails.
* It is part of the email editor integrations utilities.
*
* @package Automattic\WooCommerce\EmailEditor\Integrations\Utils
*/
class Send_Preview_Email {
/**
* Instance of the Renderer class used for rendering the editor emails.
*
* @var Renderer $renderer
*/
private Renderer $renderer;
/**
* Instance of the Personalizer class used for rendering personalization tags.
*
* @var Personalizer $personalizer
*/
private Personalizer $personalizer;
/**
* Send_Preview_Email constructor.
*
* @param Renderer $renderer renderer instance.
* @param Personalizer $personalizer personalizer instance.
*/
public function __construct(
Renderer $renderer,
Personalizer $personalizer
) {
$this->renderer = $renderer;
$this->personalizer = $personalizer;
}
/**
* Sends a preview email.
*
* @param array $data The data required to send the preview email.
* @return bool Returns true if the preview email was sent successfully, false otherwise.
* @throws \Exception If the data is invalid.
*/
public function send_preview_email( $data ): bool {
if ( is_bool( $data ) ) {
// preview mail already sent. Do not process again.
return $data;
}
$this->validate_data( $data );
$email = $data['email'];
$post_id = $data['postId'];
$post = $this->fetch_post( $post_id );
$subject = $post->post_title;
$email_html_content = $this->render_html( $post );
return $this->send_email( $email, $subject, $email_html_content );
}
/**
* Renders the HTML content of the post
*
* @param \WP_Post $post The WordPress post object.
* @return string
*/
public function render_html( $post ): string {
$subject = $post->post_title;
$language = get_bloginfo( 'language' );
// Add filter to set preview context for block renderers.
add_filter( 'woocommerce_email_editor_rendering_email_context', array( $this, 'add_preview_context' ) );
$rendered_data = $this->renderer->render(
$post,
$subject,
__( 'Preview', 'woocommerce' ),
$language
);
// Remove filter after rendering.
remove_filter( 'woocommerce_email_editor_rendering_email_context', array( $this, 'add_preview_context' ) );
$rendered_data = apply_filters( 'woocommerce_email_editor_send_preview_email_rendered_data', $rendered_data, $post );
return $this->set_personalize_content( $rendered_data['html'] );
}
/**
* Add preview context to email rendering.
*
* This filter callback adds the is_user_preview flag and current user information
* to the rendering context, allowing block renderers to show appropriate preview content.
*
* @param array $email_context Email context data.
* @return array Modified email context with preview flag.
*/
public function add_preview_context( $email_context ): array {
$email_context['is_user_preview'] = true;
return $email_context;
}
/**
* Personalize the content.
*
* @param string $content HTML content.
* @return string
*/
public function set_personalize_content( string $content ): string {
$current_user = wp_get_current_user();
$subscriber = ! empty( $current_user->ID ) ? $current_user : null;
$personalizer_context = array(
'recipient_email' => $subscriber ? $subscriber->user_email : null,
'is_user_preview' => true,
);
$personalizer_context = apply_filters( 'woocommerce_email_editor_send_preview_email_personalizer_context', $personalizer_context );
$this->personalizer->set_context( $personalizer_context );
return $this->personalizer->personalize_content( $content );
}
/**
* Sends an email preview.
*
* @param string $to The recipient email address.
* @param string $subject The subject of the email.
* @param string $body The body content of the email.
* @return bool Returns true if the email was sent successfully, false otherwise.
*/
public function send_email( string $to, string $subject, string $body ): bool {
add_filter( 'wp_mail_content_type', array( $this, 'set_mail_content_type' ) );
$result = wp_mail( $to, $subject, $body );
// Reset content-type to avoid conflicts.
remove_filter( 'wp_mail_content_type', array( $this, 'set_mail_content_type' ) );
return $result;
}
/**
* Sets the mail content type. Used by $this->send_email.
*
* @param string $content_type The content type to be set for the mail.
* @return string The content type that was set.
*/
public function set_mail_content_type( string $content_type ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return 'text/html';
}
/**
* Validates the provided data array.
*
* @param array $data The data array to be validated.
*
* @return void
* @throws \InvalidArgumentException If the data is invalid.
*/
private function validate_data( array $data ) {
if ( empty( $data['email'] ) || empty( $data['postId'] ) ) {
throw new \InvalidArgumentException( esc_html__( 'Missing required data', 'woocommerce' ) );
}
if ( ! is_email( $data['email'] ) ) {
throw new \InvalidArgumentException( esc_html__( 'Invalid email', 'woocommerce' ) );
}
}
/**
* Fetches a post_id post object based on the provided post ID.
*
* @param int $post_id The ID of the post to fetch.
* @return \WP_Post The WordPress post object.
* @throws \Exception If the post is invalid.
*/
private function fetch_post( $post_id ): \WP_Post {
$post = get_post( intval( $post_id ) );
if ( ! $post instanceof \WP_Post ) {
throw new \Exception( esc_html__( 'Invalid post', 'woocommerce' ) );
}
return $post;
}
}

View File

@@ -0,0 +1,248 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
/**
* Class managing the settings for the email editor.
*/
class Settings_Controller {
const DEFAULT_SETTINGS = array(
'enableCustomUnits' => array( 'px', '%' ),
);
/**
* Theme controller.
*
* @var Theme_Controller
*/
private Theme_Controller $theme_controller;
/**
* Allowed iframe style handles.
*
* @var string[]
*/
private array $allowed_iframe_style_handles = array();
/**
* Assets for iframe editor (component styles, scripts, etc.)
*
* @var array
*/
private array $iframe_assets = array();
/**
* Class constructor.
*
* @param Theme_Controller $theme_controller Theme controller.
*/
public function __construct(
Theme_Controller $theme_controller
) {
$this->theme_controller = $theme_controller;
}
/**
* Get the settings for the email editor.
*
* @return array
*/
public function get_settings(): array {
$this->init_iframe_assets();
$core_default_settings = \get_default_block_editor_settings();
$theme_settings = $this->theme_controller->get_settings();
$settings = array_merge( $core_default_settings, self::DEFAULT_SETTINGS );
// Assets for iframe editor (component styles, scripts, etc.).
$settings['__unstableResolvedAssets'] = $this->iframe_assets;
$settings['allowedIframeStyleHandles'] = $this->allowed_iframe_style_handles;
$editor_content_styles = file_get_contents( __DIR__ . '/content-editor.css' );
$shares_content_styles = file_get_contents( __DIR__ . '/content-shared.css' );
$settings['styles'] = array(
array( 'css' => $editor_content_styles ),
array( 'css' => $shares_content_styles ),
);
$settings['autosaveInterval'] = 60;
// Disable code editing in the email editor. We manipulate HTML in renderer so it doesn't make sense to have it enabled.
$settings['codeEditingEnabled'] = false;
$settings['__experimentalFeatures'] = $theme_settings;
// Controls which alignment options are available for blocks.
$settings['supportsLayout'] = true; // Allow using default layouts.
$settings['__unstableIsBlockBasedTheme'] = true; // For default setting this to true disables wide and full alignments.
return $settings;
}
/**
* Returns the layout settings for the email editor.
*
* @return array{contentSize: string, wideSize: string}
*/
public function get_layout(): array {
$layout_settings = $this->theme_controller->get_layout_settings();
return array(
'contentSize' => $layout_settings['contentSize'],
'wideSize' => $layout_settings['wideSize'],
);
}
/**
* Get the email styles.
*
* @return array{
* spacing: array{
* blockGap: string,
* padding: array{bottom: string, left: string, right: string, top: string}
* },
* color: array{
* background: string,
* text: string
* },
* typography: array{
* fontFamily: string
* }
* }
*/
public function get_email_styles(): array {
$theme = $this->get_theme();
return $theme->get_data()['styles'];
}
/**
* Returns the width of the layout without padding.
*
* @return string
*/
public function get_layout_width_without_padding(): string {
$styles = $this->get_email_styles();
$layout = $this->get_layout();
$width = $this->parse_number_from_string_with_pixels( $layout['contentSize'] );
$width -= $this->parse_number_from_string_with_pixels( $styles['spacing']['padding']['left'] );
$width -= $this->parse_number_from_string_with_pixels( $styles['spacing']['padding']['right'] );
return "{$width}px";
}
/**
* Parse styles string to array.
*
* @param string $styles Styles string.
* @return array
*/
public function parse_styles_to_array( string $styles ): array {
$styles = explode( ';', $styles );
$parsed_styles = array();
foreach ( $styles as $style ) {
$style = explode( ':', $style );
if ( count( $style ) === 2 ) {
$parsed_styles[ trim( $style[0] ) ] = trim( $style[1] );
}
}
return $parsed_styles;
}
/**
* Returns float number parsed from string with pixels.
*
* @param string $value Value with pixels.
* @return float
*/
public function parse_number_from_string_with_pixels( string $value ): float {
return (float) str_replace( 'px', '', $value );
}
/**
* Returns the theme.
*
* @return \WP_Theme_JSON
*/
public function get_theme(): \WP_Theme_JSON {
return $this->theme_controller->get_theme();
}
/**
* Translate slug to font size.
*
* @param string $font_size Font size slug.
* @return string
*/
public function translate_slug_to_font_size( string $font_size ): string {
return $this->theme_controller->translate_slug_to_font_size( $font_size );
}
/**
* Translate slug to color.
*
* @param string $color_slug Color slug.
* @return string
*/
public function translate_slug_to_color( string $color_slug ): string {
return $this->theme_controller->translate_slug_to_color( $color_slug );
}
/**
* Get the allowed iframe style handles.
*
* @return array
*/
private function get_allowed_iframe_style_handles() {
// Core style handles.
$allowed_iframe_style_handles = array(
'wp-components-css',
'wp-reset-editor-styles-css',
'wp-block-library-css',
'wp-block-editor-content-css',
'wp-edit-blocks-css',
);
foreach ( \WP_Block_Type_Registry::get_instance()->get_all_registered() as $block ) {
if ( ! isset( $block->supports['email'] ) || ! $block->supports['email'] ) {
continue;
}
foreach ( $block->style_handles as $handle ) {
$allowed_iframe_style_handles[] = $handle . '-css';
}
foreach ( $block->editor_style_handles as $handle ) {
$allowed_iframe_style_handles[] = $handle . '-css';
}
}
return apply_filters( 'woocommerce_email_editor_allowed_iframe_style_handles', $allowed_iframe_style_handles );
}
/**
* Method to initialize iframe assets.
*
* @return void
*/
private function init_iframe_assets(): void {
if ( ! empty( $this->iframe_assets ) ) {
return;
}
$this->iframe_assets = _wp_get_iframed_editor_assets();
$this->allowed_iframe_style_handles = $this->get_allowed_iframe_style_handles();
$cleaned_styles = array();
foreach ( explode( "\n", (string) $this->iframe_assets['styles'] ) as $asset ) {
foreach ( $this->allowed_iframe_style_handles as $handle ) {
if ( strpos( $asset, $handle ) !== false ) {
$cleaned_styles[] = $asset;
break;
}
}
}
$this->iframe_assets['styles'] = implode( "\n", $cleaned_styles );
}
}

View File

@@ -0,0 +1,457 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use WP_Theme_JSON;
use WP_Theme_JSON_Resolver;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
/**
* Site Style Sync Controller
*
* Manages the live synchronization of site styles to email templates.
* Converts site theme styles to email-compatible formats while maintaining
* visual consistency between the site and emails.
*/
class Site_Style_Sync_Controller {
/**
* Current site theme data
*
* @var WP_Theme_JSON|null
*/
private ?WP_Theme_JSON $site_theme = null;
/**
* Email-safe fonts
*
* @var array
*/
private $email_safe_fonts = array();
/**
* Constructor
*/
public function __construct() {
add_action( 'init', array( $this, 'initialize' ), 20 );
}
/**
* Initialize the sync controller
*
* Hook into theme changes to trigger automatic sync
*
* @return void
*/
public function initialize(): void {
add_action( 'switch_theme', array( $this, 'invalidate_site_theme_cache' ) );
add_action( 'customize_save_after', array( $this, 'invalidate_site_theme_cache' ) );
}
/**
* Sync site styles to email theme format
*
* @return array Email-compatible theme data.
*/
public function sync_site_styles(): array {
$site_theme = $this->get_site_theme();
$site_data = $site_theme->get_data();
$synced_data = array(
'version' => 3,
'settings' => $this->sync_settings_data( $site_data['settings'] ?? array() ),
'styles' => $this->sync_styles_data( $site_data['styles'] ?? array() ),
);
/**
* Filter the synced site style data before applying to email theme
*
* @param array $synced_data The converted email-compatible theme data.
* @param array $site_data The original site theme data.
*/
$synced_data = apply_filters( 'woocommerce_email_editor_synced_site_styles', $synced_data, $site_data );
return $synced_data;
}
/**
* Getter for site theme.
*
* @return ?WP_Theme_JSON Synced site theme.
*/
public function get_theme(): ?WP_Theme_JSON {
if ( ! $this->is_sync_enabled() ) {
return null;
}
$synced_data = $this->sync_site_styles();
if ( empty( $synced_data ) || ! isset( $synced_data['version'] ) ) {
return null;
}
return new WP_Theme_JSON( $synced_data, 'theme' );
}
/**
* Check if site style sync is enabled
*
* @return bool
*/
public function is_sync_enabled(): bool {
/**
* Filter to enable/disable site style sync functionality
*
* @param bool $enabled Whether site style sync is enabled.
*/
return apply_filters( 'woocommerce_email_editor_site_style_sync_enabled', true );
}
/**
* Invalidate cached site theme data
*
* @return void
*/
public function invalidate_site_theme_cache(): void {
if ( ! $this->is_sync_enabled() ) {
return;
}
$this->site_theme = null;
}
/**
* Get site theme data
*
* @return WP_Theme_JSON
*/
private function get_site_theme(): WP_Theme_JSON {
if ( null === $this->site_theme ) {
// Get only the theme and user customizations (e.g. from site editor).
$this->site_theme = new WP_Theme_JSON();
$this->site_theme->merge( WP_Theme_JSON_Resolver::get_theme_data() );
$this->site_theme->merge( WP_Theme_JSON_Resolver::get_user_data() );
if ( isset( $this->site_theme->get_raw_data()['styles'] ) ) {
$this->site_theme = WP_Theme_JSON::resolve_variables( $this->site_theme );
}
}
return $this->site_theme;
}
/**
* Sync settings data from site theme to email-compatible format
*
* @param array $site_settings Site theme settings.
* @return array Email-compatible settings.
*/
private function sync_settings_data( array $site_settings ): array {
$email_settings = array();
// Sync color palette.
if ( isset( $site_settings['color']['palette'] ) ) {
$email_settings['color']['palette'] = $site_settings['color']['palette'];
}
return $email_settings;
}
/**
* Sync styles data from site theme to email-compatible format
*
* @param array $site_styles Site theme styles.
* @return array Email-compatible styles.
*/
private function sync_styles_data( array $site_styles ): array {
$email_styles = array();
// Sync color styles.
if ( ! empty( $site_styles['color'] ) ) {
$email_styles['color'] = $this->convert_color_styles( $site_styles['color'] );
}
// Sync typography styles.
if ( ! empty( $site_styles['typography'] ) ) {
$email_styles['typography'] = $this->convert_typography_styles( $site_styles['typography'] );
}
// Sync spacing styles.
if ( ! empty( $site_styles['spacing'] ) ) {
$email_styles['spacing'] = $this->convert_spacing_styles( $site_styles['spacing'] );
}
// Sync element styles.
if ( ! empty( $site_styles['elements'] ) ) {
$email_styles['elements'] = $this->convert_element_styles( $site_styles['elements'] );
}
return $email_styles;
}
/**
* Get email-safe fonts
*
* @return array Email-safe fonts.
*/
public function get_email_safe_fonts(): array {
if ( empty( $this->email_safe_fonts ) ) {
/**
* Pull email-safe fonts from theme.json (src/Engine/theme.json).
*
* @var array{settings?: array{typography?: array{fontFamilies?: array<array{name: string, slug: string, fontFamily: string}>}}} $theme_data
*/
$theme_data = (array) json_decode( (string) file_get_contents( __DIR__ . '/theme.json' ), true );
$font_families = $theme_data['settings']['typography']['fontFamilies'] ?? array();
if ( ! empty( $font_families ) ) {
foreach ( $font_families as $font_family ) {
$this->email_safe_fonts[ strtolower( $font_family['slug'] ) ] = $font_family['fontFamily'];
}
}
}
return $this->email_safe_fonts;
}
/**
* Convert site color styles to email format
*
* @param array $color_styles Site color styles.
* @return array Email-compatible color styles.
*/
private function convert_color_styles( array $color_styles ): array {
$email_colors = array();
$this->resolve_and_assign( $color_styles, 'background', $email_colors );
$this->resolve_and_assign( $color_styles, 'text', $email_colors );
return $email_colors;
}
/**
* Convert site typography styles to email format
*
* @param array $typography_styles Site typography styles.
* @return array Email-compatible typography styles.
*/
private function convert_typography_styles( array $typography_styles ): array {
$email_typography = array();
// Handle special cases with processors.
$this->resolve_and_assign( $typography_styles, 'fontFamily', $email_typography, array( $this, 'convert_to_email_safe_font' ) );
$this->resolve_and_assign( $typography_styles, 'fontSize', $email_typography, array( $this, 'convert_to_px_size' ) );
// Handle compatible properties without processing.
$compatible_props = array( 'fontWeight', 'fontStyle', 'lineHeight', 'letterSpacing', 'textTransform', 'textDecoration' );
foreach ( $compatible_props as $prop ) {
$this->resolve_and_assign( $typography_styles, $prop, $email_typography );
}
return $email_typography;
}
/**
* Convert site spacing styles to email format
*
* @param array $spacing_styles Site spacing styles.
* @return array Email-compatible spacing styles.
*/
private function convert_spacing_styles( array $spacing_styles ): array {
$email_spacing = array();
$this->resolve_and_assign( $spacing_styles, 'padding', $email_spacing, array( $this, 'convert_spacing_values' ) );
$this->resolve_and_assign( $spacing_styles, 'blockGap', $email_spacing, array( $this, 'convert_to_px_size' ) );
// Note: We intentionally skip margin as it's not supported in email renderer.
return $email_spacing;
}
/**
* Convert site element styles to email format
*
* @param array $element_styles Site element styles.
* @return array Email-compatible element styles.
*/
private function convert_element_styles( array $element_styles ): array {
$email_elements = array();
// Process supported elements.
$supported_elements = array( 'heading', 'button', 'link', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' );
foreach ( $supported_elements as $element ) {
if ( isset( $element_styles[ $element ] ) ) {
$email_elements[ $element ] = $this->convert_element_style( $element_styles[ $element ] );
}
}
return $email_elements;
}
/**
* Convert individual element style to email format
*
* @param array $element_style Site element style.
* @return array Email-compatible element style.
*/
private function convert_element_style( array $element_style ): array {
$email_element = array();
// Convert typography if present.
if ( isset( $element_style['typography'] ) ) {
$email_element['typography'] = $this->convert_typography_styles( $element_style['typography'] );
}
// Convert color if present.
if ( isset( $element_style['color'] ) ) {
$email_element['color'] = $this->convert_color_styles( $element_style['color'] );
}
// Convert spacing if present.
if ( isset( $element_style['spacing'] ) ) {
$email_element['spacing'] = $this->convert_spacing_styles( $element_style['spacing'] );
}
return $email_element;
}
/**
* Resolve and assign a single style property
*
* @param array $styles The source styles array.
* @param string $property The property key to resolve.
* @param array $target The target array to assign the value to.
* @param callable|null $processor Optional processor function for the resolved value.
* @return bool True if the property was resolved and assigned, false otherwise.
*/
private function resolve_and_assign( array $styles, string $property, array &$target, ?callable $processor = null ): bool {
if ( ! isset( $styles[ $property ] ) ) {
return false;
}
$resolved = $this->resolve_style_value( $styles[ $property ] );
if ( ! $resolved ) {
return false;
}
$target[ $property ] = $processor ? $processor( $resolved ) : $resolved;
return true;
}
/**
* Styles may contain references to other styles.
* This function resolves the reference to the actual value.
* https://make.wordpress.org/core/2022/10/11/reference-styles-values-in-theme-json/
* It is not allowed to reference another reference so we don't need to check recursively.
*
* @param mixed $style_value Style value that might contain a reference.
* @return mixed Resolved style value or null when the reference is not found.
*/
private function resolve_style_value( $style_value ) {
// Check if this is a reference array.
if ( is_array( $style_value ) && isset( $style_value['ref'] ) ) {
$ref = $style_value['ref'];
if ( ! is_string( $ref ) || empty( $ref ) ) {
return null;
}
$path = explode( '.', $ref );
return _wp_array_get( $this->get_site_theme()->get_data(), $path, null );
}
return $style_value;
}
/**
* Convert font family to email-safe alternative
*
* @param string $font_family Original font family.
* @return string Email-safe font family.
*/
private function convert_to_email_safe_font( string $font_family ): string {
// Get email-safe fonts.
$email_safe_fonts = $this->get_email_safe_fonts();
// Map common web fonts to email-safe alternatives.
$font_map = array(
'helvetica' => $email_safe_fonts['arial'], // Arial fallback.
'times' => $email_safe_fonts['georgia'], // Georgia fallback.
'courier' => $email_safe_fonts['courier-new'], // Courier New.
'trebuchet' => $email_safe_fonts['trebuchet-ms'],
);
$email_safe_fonts = array_merge( $email_safe_fonts, $font_map );
$get_font_family = function ( $font_name ) use ( $email_safe_fonts ) {
$font_name_lower = strtolower( $font_name );
// First check for match in the email-safe slug.
if ( isset( $email_safe_fonts[ $font_name_lower ] ) ) {
return $email_safe_fonts[ $font_name_lower ];
}
// If no match in the slug, check for match in the font family name.
foreach ( $email_safe_fonts as $safe_font_slug => $safe_font ) {
if ( stripos( $safe_font, $font_name_lower ) !== false ) {
return $safe_font;
}
}
return null;
};
// Check if it's already an email-safe font.
$font_family_array = explode( ',', $font_family );
$safe_font_family = $get_font_family( trim( $font_family_array[0] ) );
if ( $safe_font_family ) {
return $safe_font_family;
}
// Default to arial font if no match found.
return $email_safe_fonts['arial'];
}
/**
* Convert size value to px format.
*
* @param string $size Original size value.
* @return string Size in px format.
*/
private function convert_to_px_size( string $size ): string {
// Replace clamp() with its average value.
if ( stripos( $size, 'clamp(' ) !== false ) {
return Styles_Helper::clamp_to_static_px( $size, 'avg' ) ?? $size;
}
return Styles_Helper::convert_to_px( $size, false ) ?? $size; // Fallback to original value if conversion fails.
}
/**
* Convert spacing values to px format.
*
* @param string|array $spacing_values Original spacing values.
* @return string|array Spacing values in px format.
*/
private function convert_spacing_values( $spacing_values ) {
if ( ! is_string( $spacing_values ) && ! is_array( $spacing_values ) ) {
return $spacing_values;
}
if ( is_string( $spacing_values ) ) {
return $this->convert_to_px_size( $spacing_values );
}
$px_values = array();
foreach ( $spacing_values as $side => $value ) {
if ( is_string( $value ) ) {
$px_values[ $side ] = $this->convert_to_px_size( $value );
} else {
$px_values[ $side ] = $value;
}
}
return $px_values;
}
}

View File

@@ -0,0 +1,336 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use WP_Block_Template;
use WP_Post;
use WP_Theme_JSON;
use WP_Theme_JSON_Resolver;
/**
* E-mail editor works with own theme.json which defines settings for the editor and styles for the e-mail.
* This class is responsible for accessing data defined by the theme.json.
*/
class Theme_Controller {
/**
* Core theme loaded from the WordPress core.
*
* @var WP_Theme_JSON
*/
private WP_Theme_JSON $core_theme;
/**
* Base theme loaded from a file in the package directory.
*
* @var WP_Theme_JSON
*/
private WP_Theme_JSON $base_theme;
/**
* User theme contains user custom styles and settings
*
* @var User_Theme
*/
private User_Theme $user_theme;
/**
* Site style sync controller
*
* @var Site_Style_Sync_Controller
*/
private Site_Style_Sync_Controller $site_style_sync_controller;
/**
* Theme_Controller constructor.
*/
public function __construct() {
$this->core_theme = WP_Theme_JSON_Resolver::get_core_data();
$this->base_theme = new WP_Theme_JSON( (array) json_decode( (string) file_get_contents( __DIR__ . '/theme.json' ), true ), 'default' );
$this->user_theme = new User_Theme();
$this->site_style_sync_controller = new Site_Style_Sync_Controller();
}
/**
* Gets combined theme data from the core and base theme, merged with the user .
*
* @return WP_Theme_JSON
*/
public function get_theme(): WP_Theme_JSON {
$theme = $this->get_base_theme();
$theme->merge( $this->user_theme->get_theme() );
return $theme;
}
/**
* Gets combined theme data from the core and base theme and some handpicked settings from the site theme.
*
* @return WP_Theme_JSON
*/
public function get_base_theme(): WP_Theme_JSON {
$theme = new WP_Theme_JSON();
$theme->merge( $this->core_theme );
$theme->merge( $this->base_theme );
// Merge synced styles from current active theme.
if ( $this->site_style_sync_controller->is_sync_enabled() ) {
/** @var WP_Theme_JSON $site_theme */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
$site_theme = $this->site_style_sync_controller->get_theme();
$theme->merge( $site_theme );
}
return apply_filters( 'woocommerce_email_editor_theme_json', $theme );
}
/**
* Replace preset variables with their values.
*
* @param array $values Styles array.
* @param array $presets Presets array.
* @return array
*/
private function recursive_replace_presets( $values, $presets ) {
foreach ( $values as $key => $value ) {
if ( is_array( $value ) ) {
$values[ $key ] = $this->recursive_replace_presets( $value, $presets );
} elseif ( is_string( $value ) ) {
$values[ $key ] = preg_replace( array_keys( $presets ), array_values( $presets ), $value );
} else {
$values[ $key ] = $value;
}
}
return $values;
}
/**
* Replace preset variables with their values.
*
* @param array $styles Styles array.
* @return array
*/
private function recursive_extract_preset_variables( $styles ) {
foreach ( $styles as $key => $style_value ) {
if ( is_array( $style_value ) ) {
$styles[ $key ] = $this->recursive_extract_preset_variables( $style_value );
} elseif ( is_string( $style_value ) && strpos( $style_value, 'var:preset|' ) === 0 ) {
/** @var string $style_value */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
$styles[ $key ] = 'var(--wp--' . str_replace( '|', '--', str_replace( 'var:', '', $style_value ) ) . ')';
} else {
$styles[ $key ] = $style_value;
}
}
return $styles;
}
/**
* Get styles for the e-mail.
*
* @return array{
* spacing: array{
* blockGap: string,
* padding: array{bottom: string, left: string, right: string, top: string}
* },
* color: array{
* background: string
* },
* typography: array{
* fontFamily: string
* }
* }
*/
public function get_styles(): array {
$theme_styles = $this->get_theme()->get_data()['styles'];
// Extract preset variables.
$theme_styles = $this->recursive_extract_preset_variables( $theme_styles );
// Replace preset values.
$variables = $this->get_variables_values_map();
$presets = array();
foreach ( $variables as $name => $value ) {
$pattern = '/var\(' . preg_quote( $name, '/' ) . '\)/i';
$presets[ $pattern ] = $value;
}
/* @phpstan-ignore-next-line Return type defined above. */
return $this->recursive_replace_presets( $theme_styles, $presets );
}
/**
* Get settings from the theme.
*
* @return array
*/
public function get_settings(): array {
return $this->get_theme()->get_settings();
}
/**
* Get layout settings from the theme.
*
* @return array{contentSize: string, wideSize: string, allowEditing?: bool, allowCustomContentAndWideSize?: bool}
*/
public function get_layout_settings(): array {
return $this->get_theme()->get_settings()['layout'];
}
/**
* Get stylesheet from context.
*
* @param string $context Context.
* @param array $options Options.
* @return string
*/
public function get_stylesheet_from_context( $context, $options = array() ): string {
return function_exists( 'gutenberg_style_engine_get_stylesheet_from_context' ) ? gutenberg_style_engine_get_stylesheet_from_context( $context, $options ) : wp_style_engine_get_stylesheet_from_context( $context, $options );
}
/**
* Get stylesheet for rendering.
*
* @param WP_Post|null $post Post object.
* @param WP_Block_Template|null $template Template object.
* @return string
*/
public function get_stylesheet_for_rendering( ?WP_Post $post = null, $template = null ): string {
$email_theme_settings = $this->get_settings();
$css_presets = '';
// Font family classes.
foreach ( $email_theme_settings['typography']['fontFamilies']['default'] as $font_family ) {
$css_presets .= ".has-{$font_family['slug']}-font-family { font-family: {$font_family['fontFamily']}; } \n";
}
// Font size classes.
foreach ( $email_theme_settings['typography']['fontSizes']['default'] as $font_size ) {
$css_presets .= ".has-{$font_size['slug']}-font-size { font-size: {$font_size['size']}; } \n";
}
// Color palette classes.
$color_definitions = array_merge( $email_theme_settings['color']['palette']['theme'] ?? array(), $email_theme_settings['color']['palette']['default'] ?? array() );
foreach ( $color_definitions as $color ) {
$css_presets .= ".has-{$color['slug']}-color { color: {$color['color']}; } \n";
$css_presets .= ".has-{$color['slug']}-background-color { background-color: {$color['color']}; } \n";
$css_presets .= ".has-{$color['slug']}-border-color { border-color: {$color['color']}; } \n";
}
// Block specific styles.
$css_blocks = '';
$blocks = $this->get_theme()->get_styles_block_nodes();
foreach ( $blocks as $block_metadata ) {
$css_blocks .= $this->get_theme()->get_styles_for_block( $block_metadata );
}
// Remove `:root :where(...)` selectors since they are not supported in the CSS inliner.
$css_blocks = preg_replace( '/:root\s:where\((.*?)\)/', '$1', $css_blocks );
// Element specific styles.
$elements_styles = $this->get_theme()->get_raw_data()['styles']['elements'] ?? array();
// Because the section styles is not a part of the output the `get_styles_block_nodes` method, we need to get it separately.
if ( $template && $template->wp_id ) {
$template_theme = (array) get_post_meta( $template->wp_id, Email_Editor::WOOCOMMERCE_EMAIL_META_THEME_TYPE, true );
$template_styles = (array) ( $template_theme['styles'] ?? array() );
$template_elements = $template_styles['elements'] ?? array();
$elements_styles = array_replace_recursive( (array) $elements_styles, (array) $template_elements );
}
if ( $post ) {
$post_theme = (array) get_post_meta( $post->ID, 'woocommerce_email_theme', true );
$post_styles = (array) ( $post_theme['styles'] ?? array() );
$post_elements = $post_styles['elements'] ?? array();
$elements_styles = array_replace_recursive( (array) $elements_styles, (array) $post_elements );
}
$css_elements = '';
foreach ( $elements_styles as $key => $elements_style ) {
$selector = $key;
if ( 'button' === $key ) {
$selector = '.wp-block-button';
$css_elements .= wp_style_engine_get_styles( $elements_style, array( 'selector' => '.wp-block-button' ) )['css'] ?? '';
// Add color to link element.
$css_elements .= wp_style_engine_get_styles( array( 'color' => array( 'text' => $elements_style['color']['text'] ?? '' ) ), array( 'selector' => '.wp-block-button a' ) )['css'] ?? '';
continue;
}
switch ( $key ) {
case 'heading':
$selector = 'h1, h2, h3, h4, h5, h6';
break;
case 'link':
$selector = 'a:not(.button-link)';
break;
}
$css_elements .= wp_style_engine_get_styles( $elements_style, array( 'selector' => $selector ) )['css'] ?? '';
}
$result = $css_presets . $css_blocks . $css_elements;
// Because font-size can by defined by the clamp() function that is not supported in the e-mail clients, we need to replace it to the value.
// Regular expression to match clamp() function and capture its max value.
$pattern = '/clamp\([^,]+,\s*[^,]+,\s*([^)]+)\)/';
// Replace clamp() with its maximum value.
$result = (string) preg_replace( $pattern, '$1', $result );
return $result;
}
/**
* Translate font family slug to font family name.
*
* @param string $font_size Font size slug.
* @return string
*/
public function translate_slug_to_font_size( string $font_size ): string {
$settings = $this->get_settings();
foreach ( $settings['typography']['fontSizes']['default'] as $font_size_definition ) {
if ( $font_size_definition['slug'] === $font_size ) {
return $font_size_definition['size'];
}
}
return $font_size;
}
/**
* Translate color slug to color.
*
* @param string $color_slug Color slug.
* @return string
*/
public function translate_slug_to_color( string $color_slug ): string {
$settings = $this->get_settings();
$color_definitions = array_merge( $settings['color']['palette']['theme'] ?? array(), $settings['color']['palette']['default'] ?? array() );
foreach ( $color_definitions as $color_definition ) {
if ( $color_definition['slug'] === $color_slug ) {
return strtolower( $color_definition['color'] );
}
}
return $color_slug;
}
/**
* Returns the map of CSS variables and their values from the theme.
*
* @return array
*/
public function get_variables_values_map(): array {
$variables_css = $this->get_theme()->get_stylesheet( array( 'variables' ) );
$map = array();
// Regular expression to match CSS variable definitions.
$pattern = '/--(.*?):\s*(.*?);/';
if ( preg_match_all( $pattern, $variables_css, $matches, PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
// '--' . $match[1] is the variable name, $match[2] is the variable value.
$map[ '--' . $match[1] ] = $match[2];
}
}
return $map;
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine;
use WP_Post;
use WP_Theme_JSON;
/**
* This class is responsible for managing and accessing theme data aka email styles created by users.
*/
class User_Theme {
private const USER_THEME_POST_NAME = 'wp-global-styles-woocommerce-email';
private const INITIAL_THEME_DATA = array(
'version' => 3,
'isGlobalStylesUserThemeJSON' => true,
);
/**
* Core theme loaded from the WordPress core.
*
* @var WP_Post | null
*/
private ?WP_Post $user_theme_post = null;
/**
* Getter for user theme.
*
* @throws \Exception If the user theme post cannot be created.
* @return WP_Theme_JSON
*/
public function get_theme(): WP_Theme_JSON {
$post = $this->get_user_theme_post();
$theme_data = json_decode( $post->post_content, true );
if ( ! is_array( $theme_data ) ) {
$theme_data = self::INITIAL_THEME_DATA;
}
return new WP_Theme_JSON( $theme_data, 'custom' );
}
/**
* Getter for user theme post.
* If the post does not exist, it will be created.
*
* @throws \Exception If the user theme post cannot be created.
* @return WP_Post
*/
public function get_user_theme_post(): WP_Post {
$this->ensure_theme_post();
if ( ! $this->user_theme_post instanceof WP_Post ) {
throw new \Exception( 'Error creating user theme post' );
}
return $this->user_theme_post;
}
/**
* Ensures that the user theme post exists and is loaded.
*
* @throws \Exception If the user theme post cannot be created.
*/
private function ensure_theme_post(): void {
if ( $this->user_theme_post ) {
return;
}
$this->user_theme_post = get_page_by_path( self::USER_THEME_POST_NAME, OBJECT, 'wp_global_styles' );
if ( $this->user_theme_post instanceof WP_Post ) {
return;
}
$post_data = array(
'post_title' => __( 'Custom Email Styles', 'woocommerce' ),
'post_name' => self::USER_THEME_POST_NAME,
'post_content' => (string) wp_json_encode( self::INITIAL_THEME_DATA, JSON_FORCE_OBJECT ),
'post_status' => 'publish',
'post_type' => 'wp_global_styles',
);
/**
* The doc is needed since PHPStan thinks that wp_insert_post can't return WP_Error.
*
* @var int|\WP_Error $post_id
*/
$post_id = wp_insert_post( $post_data );
if ( is_wp_error( $post_id ) ) {
throw new \Exception( 'Error creating user theme post: ' . esc_html( $post_id->get_error_message() ) );
}
$this->user_theme_post = get_post( $post_id );
}
}

View File

@@ -0,0 +1,202 @@
/*
* Styles for the email editor.
*/
/*
* Flex layout used for buttons block for email editor.
*/
.is-layout-email-flex {
flex-wrap: nowrap;
}
:where(body .is-layout-flex) {
gap: var(--wp--style--block-gap, 16px);
}
.is-mobile-preview .is-layout-email-flex {
display: block;
}
.is-mobile-preview .is-layout-email-flex .block-editor-block-list__block {
padding: 5px 0;
width: 100%;
}
.is-mobile-preview .is-layout-email-flex .wp-block-button__link {
width: 100%;
}
/*
* Email Editor specific styles for vertical gap between blocks in column and group.
* This is needed because we disable layout for core/group, core/column and core/columns blocks, and .is-layout-flex is not applied.
*/
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block:first-child,
.wp-block-group > .wp-block:first-child {
margin-top: 0;
}
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block,
.wp-block-group > .wp-block {
margin-bottom: var(--wp--style--block-gap, 16px);
margin-top: var(--wp--style--block-gap, 16px);
}
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block:not([aria-hidden="true"]):last-of-type,
.wp-block-group > .wp-block:not([aria-hidden="true"]):last-of-type {
margin-bottom: 0;
}
/*
* Use box sizing border box for columns that have defined a width (they have flex-basis set).
*/
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column[style*='flex-basis'] {
box-sizing: border-box;
}
/*
* For the WYSIWYG experience we don't want to display any margins between blocks in the editor
*/
.wp-block {
clear: both;
}
/*
* Image block enhancements
*/
.wp-block-image figcaption {
/* Resetting the margin for images in the editor to avoid unexpected spacing */
margin: 0;
}
/*
* Table block enhancements
*/
.wp-block-table figcaption {
/* Center-align table captions like core WordPress */
text-align: center;
}
/* Ensure bold formatting shows up in table cells and captions in the editor */
.wp-block-table td strong,
.wp-block-table th strong,
.wp-block-table figcaption strong {
font-weight: bold;
}
.wp-block-image.alignleft,
.wp-block-image.alignright {
margin-inline: 0 0;
text-align: center;
}
.wp-block-image.aligncenter {
margin-left: auto;
margin-right: auto;
}
.wp-block-image.alignright {
margin-left: auto;
}
/*
* Set default padding-left to have consistent default look in editor and in email
* This also overrides the default values in browsers for padding-inline-start
*/
ul,
ol {
padding-left: 40px;
}
/*
* Override default button border radius which is set in core to 9999px
*/
.wp-block-button__link {
border-radius: 0;
}
/*
* Mobile preview fixes
*/
.is-mobile-preview figure > div {
max-width: 100% !important;
height: auto !important;
}
/*
* Reset default margin for blocks in template-mode
* This was causing the first block to have a margin-top set to block gap.
* We control the gab via different css
*/
.wp-site-blocks > * {
margin-block-start: 0;
}
/*
* Hide the post title.
* When user disables the template-lock mode we don't want to show the post title.
*/
.editor-visual-editor__post-title-wrapper {
display: none;
}
/*
* Temporary styles for Rich Text HTML comments from the PR: https://github.com/WordPress/gutenberg/pull/62128/files
*/
[data-rich-text-comment],
[data-rich-text-format-boundary] {
border-radius: 2px;
}
[data-rich-text-comment] {
background-color: var(
--wp-components-color-accent,
var(--wp-admin-theme-color, #3858e9)
);
span {
color: var(--wp-components-color-accent-inverted, #fff);
filter: none;
padding: 0 2px;
}
}
/**
* Override the default gap for social links block in the editor.
* This is needed because we do not want to have a gap between the social links and also for a WYSIWYG experience.
*/
.wp-block-social-links.is-layout-flex {
gap: 16px !important;
}
/**
* Override the default padding for social links block in the editor.
* This is needed because we do not want to have a padding for the social links block particularly for a WYSIWYG experience.
*/
.wp-block-social-links.has-background {
padding-left: 0;
}
/**
* Override the default background color for social links block in the editor.
* This is mostly for a WYSIWYG experience. These icons don't have a default background color.
*/
:where(.wp-block-social-links:not(.is-style-logos-only)) .wp-social-link-mail,
:where(.wp-block-social-links:not(.is-style-logos-only)) .wp-social-link-feed,
:where(.wp-block-social-links:not(.is-style-logos-only)) .wp-social-link-chain {
background-color:#000;
color:#fff;
}
/**
* Override the default overflow-x for the iframe in the editor.
* This is needed because we do want allow scrolling on small screens such as mobile devices.
*/
.block-editor-iframe__body {
overflow-x: auto;
}

View File

@@ -0,0 +1,27 @@
/*
* Styles for both the email editor and renderer.
*/
/* Automatic padding for blocks with background color */
.email-text-block.has-background,
p.has-background,
h1.has-background,
h2.has-background,
h3.has-background,
h4.has-background,
h5.has-background,
h6.has-background {
padding: 16px 24px;
}
/* Specific padding for list elements */
ul.has-background,
ol.has-background {
padding: 20px 40px;
}
/* Remove default browser underline from site title link */
.wp-block-site-title a:not(.wp-element-button) {
text-decoration: none !important;
color: inherit;
}

View File

@@ -0,0 +1,285 @@
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"color": {
"customGradient": false,
"defaultGradients": false,
"gradients": [],
"background": true,
"text": true,
"link": true,
"customDuotone": false,
"defaultDuotone": false
},
"layout": {
"contentSize": "660px",
"wideSize": "",
"allowEditing": false,
"allowCustomContentAndWideSize": false
},
"background": {
"backgroundImage": true
},
"spacing": {
"units": ["px"],
"blockGap": false,
"padding": true,
"margin": false,
"spacingSizes": [
{
"name": "1",
"size": "10px",
"slug": "10"
},
{
"name": "2",
"size": "20px",
"slug": "20"
},
{
"name": "3",
"size": "30px",
"slug": "30"
},
{
"name": "4",
"size": "40px",
"slug": "40"
},
{
"name": "5",
"size": "50px",
"slug": "50"
},
{
"name": "6",
"size": "60px",
"slug": "60"
}
]
},
"border": {
"radius": true,
"color": true,
"style": true,
"width": true
},
"typography": {
"dropCap": false,
"fontWeight": true,
"lineHeight": true,
"defaultFontSizes": true,
"fontFamilies": [
{
"name": "Arial",
"slug": "arial",
"fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif"
},
{
"name": "Comic Sans MS",
"slug": "comic-sans-ms",
"fontFamily": "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif"
},
{
"name": "Courier New",
"slug": "courier-new",
"fontFamily": "'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace"
},
{
"name": "Georgia",
"slug": "georgia",
"fontFamily": "Georgia, Times, 'Times New Roman', serif"
},
{
"name": "Lucida",
"slug": "lucida",
"fontFamily": "'Lucida Sans Unicode', 'Lucida Grande', sans-serif"
},
{
"name": "Tahoma",
"slug": "tahoma",
"fontFamily": "'Tahoma, Verdana, Segoe, sans-serif'"
},
{
"name": "Times New Roman",
"slug": "times-new-roman",
"fontFamily": "'Times New Roman', Times, Baskerville, Georgia, serif"
},
{
"name": "Trebuchet MS",
"slug": "trebuchet-ms",
"fontFamily": "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif"
},
{
"name": "Verdana",
"slug": "verdana",
"fontFamily": "'Verdana, Geneva, sans-serif'"
},
{
"name": "Arvo",
"slug": "arvo",
"fontFamily": "'arvo, courier, georgia, serif'"
},
{
"name": "Lato",
"slug": "lato",
"fontFamily": "lato, 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Lora",
"slug": "lora",
"fontFamily": "lora, georgia, 'times new roman', serif"
},
{
"name": "Merriweather",
"slug": "merriweather",
"fontFamily": "merriweather, georgia, 'times new roman', serif"
},
{
"name": "Merriweather Sans",
"slug": "merriweather-sans",
"fontFamily": "'merriweather sans', 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Noticia Text",
"slug": "noticia-text",
"fontFamily": "'noticia text', georgia, 'times new roman', serif"
},
{
"name": "Open Sans",
"slug": "open-sans",
"fontFamily": "'open sans', 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Playfair Display",
"slug": "playfair-display",
"fontFamily": "'playfair display', georgia, 'times new roman', serif"
},
{
"name": "Roboto",
"slug": "roboto",
"fontFamily": "roboto, 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Source Sans Pro",
"slug": "source-sans-pro",
"fontFamily": "'source sans pro', 'helvetica neue', helvetica, arial, sans-serif"
},
{
"name": "Oswald",
"slug": "oswald",
"fontFamily": "Oswald, 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif"
},
{
"name": "Raleway",
"slug": "raleway",
"fontFamily": "Raleway, 'Century Gothic', CenturyGothic, AppleGothic, sans-serif"
},
{
"name": "Permanent Marker",
"slug": "permanent-marker",
"fontFamily": "'Permanent Marker', Tahoma, Verdana, Segoe, sans-serif"
},
{
"name": "Pacifico",
"slug": "pacifico",
"fontFamily": "Pacifico, 'Arial Narrow', Arial, sans-serif"
}
],
"fontSizes": [
{
"name": "small",
"size": "13px",
"slug": "small"
},
{
"name": "medium",
"size": "16px",
"slug": "medium"
},
{
"name": "large",
"size": "24px",
"slug": "large"
},
{
"name": "extra-large",
"size": "32px",
"slug": "x-large"
},
{
"name": "extra-extra-large",
"size": "40px",
"slug": "xx-large"
}
]
},
"useRootPaddingAwareAlignments": true
},
"styles": {
"spacing": {
"blockGap": "16px",
"padding": {
"bottom": "20px",
"left": "20px",
"right": "20px",
"top": "20px"
}
},
"color": {
"background": "#ffffff",
"text": "#1e1e1e"
},
"typography": {
"fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif",
"fontSize": "16px",
"fontWeight": "400",
"fontStyle": "normal",
"letterSpacing": "0",
"lineHeight": "1.5",
"textDecoration": "none",
"textTransform": "none"
},
"elements": {
"heading": {
"typography": {
"fontFamily": "Arial, 'Helvetica Neue', Helvetica, sans-serif",
"fontWeight": "400",
"fontStyle": "normal",
"lineHeight": "1.2"
}
},
"h1": {
"typography": {
"fontSize": "40px"
}
},
"h2": {
"typography": {
"fontSize": "32px"
}
},
"h3": {
"typography": {
"fontSize": "24px"
}
},
"h4": {
"typography": {
"fontSize": "16px"
}
},
"h5": {
"typography": {
"fontSize": "14px"
}
},
"h6": {
"typography": {
"fontSize": "12px"
}
}
}
}
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Block_Renderer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use WP_Style_Engine;
/**
* Shared functionality for block renderers.
*/
abstract class Abstract_Block_Renderer implements Block_Renderer {
/**
* Wrapper for wp_style_engine_get_styles which ensures all values are returned.
*
* @param array $block_styles Array of block styles.
* @param bool $skip_convert_vars If true, --wp_preset--spacing--x type values will be left in the original var:preset:spacing:x format.
* @return array
*/
protected function get_styles_from_block( array $block_styles, $skip_convert_vars = false ) {
return Styles_Helper::get_styles_from_block( $block_styles, $skip_convert_vars );
}
/**
* Compile objects containing CSS properties to a string.
*
* @param array ...$styles Style arrays to compile.
* @return string
*/
protected function compile_css( ...$styles ): string {
return WP_Style_Engine::compile_css( array_merge( ...$styles ), '' );
}
/**
* Extract inner content from a wrapper element.
*
* Removes the outer wrapper element (e.g., div) and returns only the inner HTML content.
* This is useful when you need to strip the wrapper and use only the inner content.
*
* @param string $block_content Block content with wrapper element.
* @param string $tag_name Tag name of the wrapper element (default: 'div').
* @return string Inner content without the wrapper element, or original content if wrapper not found.
*/
protected function get_inner_content( string $block_content, string $tag_name = 'div' ): string {
$dom_helper = new Dom_Document_Helper( $block_content );
$element = $dom_helper->find_element( $tag_name );
return $element ? $dom_helper->get_element_inner_html( $element ) : $block_content;
}
/**
* Add a spacer around the block.
*
* @param string $content The block content.
* @param array $email_attrs The email attributes.
* @return string
*/
protected function add_spacer( $content, $email_attrs ): string {
$gap_style = WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin-top' ) ) ), '' ) ?? '';
$padding_style = WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'padding-left', 'padding-right' ) ) ), '' ) ?? '';
$table_attrs = array(
'align' => 'left',
'width' => '100%',
'style' => $gap_style,
);
$cell_attrs = array(
'style' => $padding_style,
);
$div_content = sprintf(
'<div class="email-block-layout" style="%1$s %2$s">%3$s</div>',
esc_attr( $gap_style ),
esc_attr( $padding_style ),
$content
);
return Table_Wrapper_Helper::render_outlook_table_wrapper( $div_content, $table_attrs, $cell_attrs );
}
/**
* Render the block.
*
* @param string $block_content The block content.
* @param array $parsed_block The parsed block.
* @param Rendering_Context $rendering_context The rendering context.
* @return string
*/
public function render( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
return $this->add_spacer(
$this->render_content( $block_content, $parsed_block, $rendering_context ),
$parsed_block['email_attrs'] ?? array()
);
}
/**
* Render the block content.
*
* @param string $block_content The block content.
* @param array $parsed_block The parsed block.
* @param Rendering_Context $rendering_context The rendering context.
* @return string
*/
abstract protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string;
}

View File

@@ -0,0 +1,193 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* Audio block renderer.
* This renderer handles core/audio blocks for email.
*/
class Audio extends Abstract_Block_Renderer {
/**
* Render the block.
*
* @param string $block_content The block content.
* @param array $parsed_block The parsed block.
* @param Rendering_Context $rendering_context The rendering context.
* @return string
*/
public function render( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// Validate input parameters and required dependencies.
if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
return '';
}
$attr = $parsed_block['attrs'];
// Check if we have a valid audio source - return empty string immediately if not.
// For attachments, check the 'id' attribute. For external URLs, check if src exists in HTML content.
$has_attachment_id = ! empty( $attr['id'] );
$has_src_in_html = preg_match( '#<audio[^>]*\ssrc=["\']([^"\']*)["\'][^>]*/?>#', $block_content );
// If we have neither an attachment ID nor a src in the HTML content, return empty.
if ( ! $has_attachment_id && ! $has_src_in_html ) {
return '';
}
// If we have a valid source, proceed with normal rendering.
$rendered_content = $this->render_content( $block_content, $parsed_block, $rendering_context );
// If render_content returns empty (e.g., invalid URL), return empty string.
if ( empty( $rendered_content ) ) {
return '';
}
return $this->add_spacer( $rendered_content, $parsed_block['email_attrs'] ?? array() );
}
/**
* Renders the audio block content as an audio player for email.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context (required by parent contract but unused in this implementation).
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$attr = $parsed_block['attrs'];
// Get URL and length.
if ( isset( $attr['id'] ) ) {
// Audio file from site's media library.
$url = \wp_get_attachment_url( $attr['id'] );
$meta = \get_post_meta( $attr['id'], '_wp_attachment_metadata', true );
$length = ( is_array( $meta ) && isset( $meta['length_formatted'] ) && is_string( $meta['length_formatted'] ) ) ? $meta['length_formatted'] : '';
} else {
// Audio file from external URL.
preg_match( '#<audio[^>]*\ssrc=["\']([^"\']*)["\'][^>]*/?>#', $block_content, $audio );
$url = isset( $audio[1] ) ? $audio[1] : $attr['src'] ?? '';
$length = null;
}
// Validate URL with proper ordering and comprehensive checks.
if ( empty( $url ) ) {
return '';
}
// Validate URL type and format.
if ( ! str_starts_with( $url, 'data:audio/' ) &&
! str_starts_with( $url, '/' ) &&
! str_starts_with( $url, 'https://' ) ) {
// Reject everything else (http://, ftp://, relative paths, etc.).
return '';
}
// For HTTPS URLs, validate with wp_http_validate_url.
if ( str_starts_with( $url, 'https://' ) && ! wp_http_validate_url( $url ) ) {
return '';
}
// Get spacing from email_attrs for better consistency with core blocks.
$email_attrs = $parsed_block['email_attrs'] ?? array();
$table_margin_style = '';
if ( ! empty( $email_attrs ) && class_exists( '\WP_Style_Engine' ) ) {
// Get margin for table styling.
$table_margin_style = \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin' ) ) ), '' );
}
$icon_image = $this->get_audio_icon_url();
$label = ! empty( $attr['label'] ) ? $attr['label'] : __( 'Listen to the audio', 'woocommerce' );
// Add duration to label if available.
if ( ! empty( $length ) ) {
$label .= ' (' . esc_html( (string) $length ) . ')';
}
$audio_url = esc_url( $url );
// Define pill-style colors and styling.
$background_color = '#f6f7f7';
$border_color = '#AAA';
$icon_size = '18px';
$font_size = '14px';
// Generate the icon content.
$icon_content = sprintf(
'<a href="%1$s" rel="noopener nofollow" target="_blank" style="padding: 0.25em; padding-left: 17px; display: inline-block; vertical-align: middle;"><img height="%2$s" src="%3$s" style="display:block;margin-right:0;vertical-align:middle;" width="%2$s" alt="%4$s"></a>',
$audio_url,
esc_attr( $icon_size ),
esc_url( $icon_image ),
// translators: %s is the audio player icon.
sprintf( __( '%s icon', 'woocommerce' ), __( 'Audio', 'woocommerce' ) )
);
$icon_content = Table_Wrapper_Helper::render_table_cell( $icon_content, array( 'style' => sprintf( 'vertical-align:middle;font-size:%s;', $font_size ) ) );
// Generate the label content.
$label_content = sprintf(
'<a href="%1$s" rel="noopener nofollow" target="_blank" style="text-decoration:none; padding: 0.25em; padding-right: 17px; display: inline-block;"><span style="margin-left:.5em;margin-right:.5em;font-weight:bold"> %2$s </span></a>',
$audio_url,
esc_html( $label )
);
$label_cell_style = sprintf(
'vertical-align:middle;font-size:%s;',
$font_size
);
$label_content = Table_Wrapper_Helper::render_table_cell( $label_content, array( 'style' => $label_cell_style ) );
// Combine icon and label tables.
$audio_content = $icon_content . $label_content;
// Create the main pill-style table.
$main_table_styles = sprintf(
'background-color: %s; border-radius: 9999px; float: none; border: 1px solid %s; border-collapse: separate;',
$background_color,
$border_color
);
$main_table_attrs = array(
'align' => 'left',
'style' => $main_table_styles,
);
$main_table = Table_Wrapper_Helper::render_table_wrapper( $audio_content, $main_table_attrs, array(), array(), false );
// Create the main wrapper table.
$table_style = 'width: 100%;';
if ( ! empty( $table_margin_style ) ) {
$table_style = $table_margin_style . '; ' . $table_style;
} else {
$table_style = 'margin: 16px 0; ' . $table_style;
}
$table_attrs = array(
'style' => $table_style,
);
$cell_attrs = array(
'style' => 'min-width: 100%; vertical-align: middle; word-break: break-word; text-align: left;',
);
$main_wrapper = Table_Wrapper_Helper::render_table_wrapper( $main_table, $table_attrs, $cell_attrs );
return Table_Wrapper_Helper::render_outlook_table_wrapper( $main_wrapper, array( 'align' => 'left' ) );
}
/**
* Gets the audio icon URL.
*
* @return string The audio icon URL.
*/
private function get_audio_icon_url(): string {
$file_name = '/icons/audio/audio-play.png';
return plugins_url( $file_name, __FILE__ );
}
}

View File

@@ -0,0 +1,130 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
/**
* Renders a button block.
*
* @see https://www.activecampaign.com/blog/email-buttons
* @see https://documentation.mjml.io/#mj-button
*/
class Button extends Abstract_Block_Renderer {
/**
* Get styles for the wrapper element.
*
* @param array $block_attributes Block attributes.
* @param Rendering_Context $rendering_context Rendering context.
* @return array
*/
private function get_wrapper_styles( array $block_attributes, Rendering_Context $rendering_context ) {
$block_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'border', 'background-color', 'color', 'typography', 'spacing' ) );
return Styles_Helper::extend_block_styles(
$block_styles,
array(
'word-break' => 'break-word',
'display' => 'block',
)
);
}
/**
* Get styles for the link element.
*
* @param array $block_attributes Block attributes.
* @param Rendering_Context $rendering_context Rendering context.
* @return array
*/
private function get_link_styles( array $block_attributes, Rendering_Context $rendering_context ) {
$block_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'color', 'typography' ) );
return Styles_Helper::extend_block_styles(
$block_styles,
array( 'display' => 'block' )
);
}
/**
* Renders the block.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
public function render( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
return $this->render_content( $block_content, $parsed_block, $rendering_context );
}
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
if ( empty( $parsed_block['innerHTML'] ) ) {
return '';
}
$dom_helper = new Dom_Document_Helper( $parsed_block['innerHTML'] );
$block_classname = $dom_helper->get_attribute_value_by_tag_name( 'div', 'class' ) ?? '';
$button_link = $dom_helper->find_element( 'a' );
if ( ! $button_link ) {
return '';
}
$button_text = $dom_helper->get_element_inner_html( $button_link ) ? $dom_helper->get_element_inner_html( $button_link ) : '';
$button_url = $button_link->getAttribute( 'href' ) ? $button_link->getAttribute( 'href' ) : '#';
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'width' => '',
'style' => array(),
'textAlign' => 'center',
'backgroundColor' => '',
'textColor' => '',
)
);
$wrapper_styles = $this->get_wrapper_styles( $block_attributes, $rendering_context );
$link_styles = $this->get_link_styles( $block_attributes, $rendering_context );
$table_attrs = array(
'style' => 'width:' . ( $block_attributes['width'] ? '100%' : 'auto' ) . ';',
);
$cell_attrs = array(
'class' => $wrapper_styles['classnames'] . ' ' . $block_classname,
'style' => $wrapper_styles['css'],
'align' => $block_attributes['textAlign'],
'valign' => 'middle',
'role' => 'presentation',
);
$button_content = sprintf(
'<a class="button-link %1$s" style="%2$s" href="%3$s" target="_blank">%4$s</a>',
esc_attr( $link_styles['classnames'] ),
esc_attr( $link_styles['css'] ),
esc_url( $button_url ),
$button_text
);
return Table_Wrapper_Helper::render_table_wrapper( $button_content, $table_attrs, $cell_attrs );
}
}

View File

@@ -0,0 +1,53 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Layout\Flex_Layout_Renderer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
/**
* Renders a buttons block.
*/
class Buttons extends Abstract_Block_Renderer {
/**
* Provides the Flex_Layout_Renderer instance.
*
* @var Flex_Layout_Renderer
*/
private $flex_layout_renderer;
/**
* Buttons constructor.
*
* @param Flex_Layout_Renderer $flex_layout_renderer Flex layout renderer.
*/
public function __construct(
Flex_Layout_Renderer $flex_layout_renderer
) {
$this->flex_layout_renderer = $flex_layout_renderer;
}
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// Ignore font size set on the buttons block.
// We rely on TypographyPreprocessor to set the font size on the buttons.
// Rendering font size on the wrapper causes unwanted whitespace below the buttons.
if ( isset( $parsed_block['attrs']['style']['typography']['fontSize'] ) ) {
unset( $parsed_block['attrs']['style']['typography']['fontSize'] );
}
return $this->flex_layout_renderer->render_inner_blocks_in_layout( $parsed_block, $rendering_context );
}
}

View File

@@ -0,0 +1,121 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* Renders a column block.
*/
class Column extends Abstract_Block_Renderer {
/**
* Override this method to disable spacing (block gap) for columns.
* Spacing is applied on wrapping columns block. Columns are rendered side by side so no spacer is needed.
*
* @param string $content Content.
* @param array $email_attrs Email attributes.
*/
protected function add_spacer( $content, $email_attrs ): string {
return $content;
}
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
return str_replace(
'{column_content}',
$this->get_inner_content( $block_content ),
$this->get_block_wrapper( $block_content, $parsed_block, $rendering_context )
);
}
/**
* Based on MJML <mj-column>
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
*/
private function get_block_wrapper( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$original_wrapper_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'div', 'class' ) ?? '';
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'verticalAlignment' => 'stretch',
'width' => $rendering_context->get_layout_width_without_padding(),
'style' => array(),
)
);
// The default column alignment is `stretch to fill` which means that we need to set the background color to the main cell
// to create a feeling of a stretched column. This also needs to apply to CSS classnames which can also apply styles.
$is_stretched = empty( $block_attributes['verticalAlignment'] ) || 'stretch' === $block_attributes['verticalAlignment'];
$padding_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'padding' ) );
$padding_styles = Styles_Helper::extend_block_styles( $padding_styles, array( 'text-align' => 'left' ) );
$cell_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'border', 'background', 'background-color', 'color' ) );
$cell_styles = Styles_Helper::extend_block_styles(
$cell_styles,
array_filter(
array(
'background-size' => ! empty( $cell_styles['background-image'] ) && empty( $cell_styles['background-size'] ) ? 'cover' : null,
)
)
);
$wrapper_classname = 'block wp-block-column email-block-column';
$content_classname = 'email-block-column-content';
$wrapper_styles = Styles_Helper::extend_block_styles(
Styles_Helper::$empty_block_styles,
array( 'vertical-align' => $is_stretched ? 'top' : $block_attributes['verticalAlignment'] ),
);
$content_styles = Styles_Helper::extend_block_styles( Styles_Helper::$empty_block_styles, array( 'vertical-align' => 'top' ) );
if ( $is_stretched ) {
$wrapper_classname .= ' ' . $original_wrapper_classname;
$wrapper_styles = Styles_Helper::extend_block_styles( $wrapper_styles, $cell_styles['declarations'] );
} else {
$content_classname .= ' ' . $original_wrapper_classname;
$content_styles = Styles_Helper::extend_block_styles( $content_styles, $cell_styles['declarations'] );
}
// Create the inner table using the helper.
$inner_table_attrs = array(
'class' => $content_classname,
'style' => $content_styles['css'],
'width' => '100%',
);
$inner_cell_attrs = array(
'align' => 'left',
'style' => $padding_styles['css'],
);
$inner_table = Table_Wrapper_Helper::render_table_wrapper( '{column_content}', $inner_table_attrs, $inner_cell_attrs );
// Create the outer td element (since this is meant to be used within a columns structure).
$wrapper_cell_attrs = array(
'class' => $wrapper_classname,
'style' => $wrapper_styles['css'],
'width' => Styles_Helper::parse_value( $block_attributes['width'] ),
);
return Table_Wrapper_Helper::render_table_cell( $inner_table, $wrapper_cell_attrs );
}
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
/**
* Renders a columns block.
*/
class Columns extends Abstract_Block_Renderer {
/**
* Override this method to disable spacing (block gap) for columns.
* Spacing is applied on wrapping columns block. Columns are rendered side by side so no spacer is needed.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
return str_replace(
'{columns_content}',
$this->get_inner_content( $block_content ),
$this->getBlockWrapper( $block_content, $parsed_block, $rendering_context )
);
}
/**
* Based on MJML <mj-section>
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
*/
private function getBlockWrapper( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$original_wrapper_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'div', 'class' ) ?? '';
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'align' => null,
'width' => $rendering_context->get_layout_width_without_padding(),
'style' => array(),
)
);
$columns_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'padding', 'border', 'background', 'background-color', 'color' ) );
$columns_styles = Styles_Helper::extend_block_styles(
$columns_styles,
array(
'width' => '100%',
'border-collapse' => 'separate',
'text-align' => 'left',
'background-size' => $columns_styles['declarations']['background-size'] ?? 'cover',
)
);
$columns_table_attrs = array(
'class' => 'email-block-columns ' . $original_wrapper_classname,
'style' => $columns_styles['css'],
'align' => 'center',
);
$columns_content = Table_Wrapper_Helper::render_table_wrapper( '{columns_content}', $columns_table_attrs, array(), array(), false );
// Margins are not supported well in outlook for tables, so wrap in another table.
$margins = $block_attributes['style']['spacing']['margin'] ?? array();
if ( ! empty( $margins ) ) {
$magin_to_padding_attributes = array( 'style' => array( 'spacing' => array( 'padding' => $margins ) ) );
$margin_wrapper_styles = Styles_Helper::get_block_styles( $magin_to_padding_attributes, $rendering_context, array( 'padding' ) );
$margin_wrapper_styles = Styles_Helper::extend_block_styles(
$margin_wrapper_styles,
array(
'width' => '100%',
'border-collapse' => 'separate',
'text-align' => 'left',
)
);
$wrapper_table_attrs = array(
'class' => 'email-block-columns-wrapper',
'style' => $margin_wrapper_styles['css'],
'align' => 'center',
);
return Table_Wrapper_Helper::render_table_wrapper( $columns_content, $wrapper_table_attrs );
}
return $columns_content;
}
}

View File

@@ -0,0 +1,252 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
/**
* Cover block renderer.
* This renderer handles core/cover blocks with proper email-friendly HTML layout.
*/
class Cover extends Abstract_Block_Renderer {
/**
* Renders the cover block content using a table-based layout for email compatibility.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$block_attrs = $parsed_block['attrs'] ?? array();
$inner_blocks = $parsed_block['innerBlocks'] ?? array();
// Render all inner blocks content.
$inner_content = '';
foreach ( $inner_blocks as $block ) {
$inner_content .= render_block( $block );
}
// If we don't have inner content, return empty.
if ( empty( $inner_content ) ) {
return '';
}
// Build the email-friendly layout.
$background_image = $this->extract_background_image( $block_attrs, $parsed_block['innerHTML'] ?? $block_content );
return $this->build_email_layout( $inner_content, $block_attrs, $block_content, $background_image, $rendering_context );
}
/**
* Build the email-friendly layout for cover blocks.
*
* @param string $inner_content Inner content.
* @param array $block_attrs Block attributes.
* @param string $block_content Original block content.
* @param string $background_image Background image URL.
* @param Rendering_Context $rendering_context Rendering context.
* @return string Rendered HTML.
*/
private function build_email_layout( string $inner_content, array $block_attrs, string $block_content, string $background_image, Rendering_Context $rendering_context ): string {
// Get original wrapper classes from block content.
$original_wrapper_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'div', 'class' ) ?? '';
// Get background color information.
$background_color = $this->get_background_color( $block_attrs, $rendering_context );
// Get block styles using the Styles_Helper.
$block_styles = Styles_Helper::get_block_styles( $block_attrs, $rendering_context, array( 'padding', 'border', 'background-color' ) );
$default_styles = array(
'width' => '100%',
'border-collapse' => 'collapse',
'text-align' => 'center',
);
// Add minimum height (use specified value or default).
$min_height = $this->get_minimum_height( $block_attrs );
$default_styles['min-height'] = ! empty( $min_height ) ? $min_height : '430px';
$block_styles = Styles_Helper::extend_block_styles(
$block_styles,
$default_styles
);
// Add background image to table styles if present.
if ( ! empty( $background_image ) ) {
$block_styles = Styles_Helper::extend_block_styles(
$block_styles,
array(
'background-image' => 'url("' . esc_url( $background_image ) . '")',
'background-size' => 'cover',
'background-position' => 'center',
'background-repeat' => 'no-repeat',
)
);
} elseif ( ! empty( $background_color ) ) {
// If no background image but there's a background color, use it.
$block_styles = Styles_Helper::extend_block_styles(
$block_styles,
array(
'background-color' => $background_color,
)
);
}
// Apply class and style attributes to the wrapper table.
$table_attrs = array(
'class' => 'email-block-cover ' . esc_attr( $original_wrapper_classname ),
'style' => $block_styles['css'],
'align' => 'center',
'width' => '100%',
);
// Build the cover content without background (background is now on the table).
$cover_content = $this->build_cover_content( $inner_content );
// Build individual table cell.
$cell_attrs = array(
'valign' => 'middle',
'align' => 'center',
);
$cell = Table_Wrapper_Helper::render_table_cell( $cover_content, $cell_attrs );
// Use render_cell = false to avoid wrapping in an extra <td>.
return Table_Wrapper_Helper::render_table_wrapper( $cell, $table_attrs, array(), array(), false );
}
/**
* Extract background image from block attributes or HTML content.
*
* @param array $block_attrs Block attributes.
* @param string $block_content Original block content.
* @return string Background image URL or empty string.
*/
private function extract_background_image( array $block_attrs, string $block_content ): string {
// First check block attributes for URL.
if ( ! empty( $block_attrs['url'] ) ) {
return esc_url( $block_attrs['url'] );
}
// Fallback: use HTML API to find background image src.
$html = new \WP_HTML_Tag_Processor( $block_content );
while ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
$class_attr = $html->get_attribute( 'class' );
// Check if this img tag has the wp-block-cover__image-background class.
if ( is_string( $class_attr ) && false !== strpos( $class_attr, 'wp-block-cover__image-background' ) ) {
$src = $html->get_attribute( 'src' );
if ( is_string( $src ) ) {
return esc_url( $src );
}
}
}
return '';
}
/**
* Get minimum height from block attributes.
*
* @param array $block_attrs Block attributes.
* @return string Minimum height value or empty string.
*/
private function get_minimum_height( array $block_attrs ): string {
// Check for minHeight attribute (legacy format).
if ( ! empty( $block_attrs['minHeight'] ) ) {
return Html_Processing_Helper::sanitize_dimension_value( $block_attrs['minHeight'] );
}
// Check for style.dimensions.minHeight (WordPress 6.2+ format).
if ( ! empty( $block_attrs['style']['dimensions']['minHeight'] ) ) {
return Html_Processing_Helper::sanitize_dimension_value( $block_attrs['style']['dimensions']['minHeight'] );
}
return '';
}
/**
* Get background color from block attributes.
*
* @param array $block_attrs Block attributes.
* @param Rendering_Context $rendering_context Rendering context.
* @return string Background color or empty string.
*/
private function get_background_color( array $block_attrs, Rendering_Context $rendering_context ): string {
// Check for custom overlay color first (used as background color when no image).
if ( ! empty( $block_attrs['customOverlayColor'] ) ) {
$color = $block_attrs['customOverlayColor'];
$sanitized_color = $this->validate_and_sanitize_color( $color );
if ( ! empty( $sanitized_color ) ) {
return $sanitized_color;
}
}
// Check for overlay color slug (used as background color when no image).
if ( ! empty( $block_attrs['overlayColor'] ) ) {
$translated_color = $rendering_context->translate_slug_to_color( $block_attrs['overlayColor'] );
$sanitized_color = $this->validate_and_sanitize_color( $translated_color );
if ( ! empty( $sanitized_color ) ) {
return $sanitized_color;
}
}
return '';
}
/**
* Validate and sanitize a color value, returning empty string for invalid colors.
*
* @param string $color The color value to validate and sanitize.
* @return string Sanitized color or empty string if invalid.
*/
private function validate_and_sanitize_color( string $color ): string {
$sanitized_color = Html_Processing_Helper::sanitize_color( $color );
// If sanitize_color returned the default fallback, check if the original was actually valid.
if ( '#000000' === $sanitized_color && '#000000' !== $color ) {
// The original color was invalid, so return empty string.
return '';
}
// The color is valid (either it was sanitized to something other than the default,
// or it was specifically #000000 which is a valid color).
return $sanitized_color;
}
/**
* Build the cover content with background image or color.
*
* @param string $inner_content Inner content.
* @return string Cover content HTML.
*/
private function build_cover_content( string $inner_content ): string {
$cover_style = 'position: relative; display: inline-block; width: 100%; max-width: 100%;';
// Wrap inner content with padding.
// Note: $inner_content is already rendered HTML from other blocks via render_block(),
// so it should be properly escaped by the individual block renderers.
$inner_wrapper_style = 'padding: 20px;';
$inner_wrapper_html = sprintf(
'<div class="wp-block-cover__inner-container" style="%s">%s</div>',
$inner_wrapper_style,
$inner_content
);
return sprintf(
'<div class="wp-block-cover" style="%s">%s</div>',
$cover_style,
$inner_wrapper_html
);
}
}

View File

@@ -0,0 +1,519 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Audio;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
/**
* Embed block renderer.
* This renderer handles core/embed blocks, detecting audio and video provider embeds and rendering them appropriately.
*
* Audio providers: Spotify, SoundCloud, Pocket Casts, Mixcloud, ReverbNation - rendered as audio players.
* Video providers: YouTube - rendered as video thumbnails with play buttons.
*/
class Embed extends Abstract_Block_Renderer {
/**
* Supported audio providers with their configuration.
*
* @var array
*/
private const AUDIO_PROVIDERS = array(
'pocket-casts' => array(
'domains' => array( 'pca.st' ),
'base_url' => 'https://pca.st/',
),
'spotify' => array(
'domains' => array( 'open.spotify.com' ),
'base_url' => 'https://open.spotify.com/',
),
'soundcloud' => array(
'domains' => array( 'soundcloud.com' ),
'base_url' => 'https://soundcloud.com/',
),
'mixcloud' => array(
'domains' => array( 'mixcloud.com' ),
'base_url' => 'https://www.mixcloud.com/',
),
'reverbnation' => array(
'domains' => array( 'reverbnation.com' ),
'base_url' => 'https://www.reverbnation.com/',
),
);
/**
* Supported video providers with their configuration.
*
* @var array
*/
private const VIDEO_PROVIDERS = array(
'youtube' => array(
'domains' => array( 'youtube.com', 'youtu.be' ),
'base_url' => 'https://www.youtube.com/',
),
);
/**
* Get all supported providers (audio and video).
*
* @return array All supported providers.
*/
private function get_all_supported_providers(): array {
return array_merge( array_keys( self::AUDIO_PROVIDERS ), array_keys( self::VIDEO_PROVIDERS ) );
}
/**
* Get all provider configurations (audio and video).
*
* @return array All provider configurations.
*/
private function get_all_provider_configs(): array {
return array_merge( self::AUDIO_PROVIDERS, self::VIDEO_PROVIDERS );
}
/**
* Detect provider from content by checking against known domains.
*
* @param string $content Content to check for provider domains.
* @return string Provider name or empty string if not found.
*/
private function detect_provider_from_domains( string $content ): string {
$all_providers = $this->get_all_provider_configs();
foreach ( $all_providers as $provider => $config ) {
foreach ( $config['domains'] as $domain ) {
if ( strpos( $content, $domain ) !== false ) {
return $provider;
}
}
}
return '';
}
/**
* Validate URL using both filter_var and wp_http_validate_url.
*
* @param string $url URL to validate.
* @return bool True if URL is valid.
*/
private function is_valid_url( string $url ): bool {
return ! empty( $url ) && filter_var( $url, FILTER_VALIDATE_URL ) && wp_http_validate_url( $url );
}
/**
* Create fallback attributes for link rendering.
*
* @param string $url URL for the fallback.
* @param string $label Label for the fallback.
* @return array Fallback attributes.
*/
private function create_fallback_attributes( string $url, string $label ): array {
return array(
'url' => $url,
'label' => $label,
);
}
/**
* Renders the embed block.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
public function render( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// Validate input parameters and required dependencies.
if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
return '';
}
$attr = $parsed_block['attrs'];
// Check if this is a supported audio or video provider embed and has a valid URL.
$provider = $this->get_supported_provider( $attr, $block_content );
if ( empty( $provider ) ) {
// For non-supported embeds, try to render as a simple link fallback.
return $this->render_link_fallback( $attr, $block_content, $parsed_block, $rendering_context );
}
$url = $this->extract_provider_url( $attr, $block_content );
if ( empty( $url ) ) {
// Provider was detected but URL extraction failed - provide graceful fallback.
return $this->render_link_fallback( $attr, $block_content, $parsed_block, $rendering_context );
}
// If we have a valid audio or video provider embed, proceed with normal rendering.
return $this->render_content( $block_content, $parsed_block, $rendering_context );
}
/**
* Renders the embed block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context (required by parent contract but unused in this implementation).
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$attr = $parsed_block['attrs'] ?? array();
// Get provider and URL (validation already done in render method).
$provider = $this->get_supported_provider( $attr, $block_content );
$url = $this->extract_provider_url( $attr, $block_content );
// Check if this is a video provider - render as video block.
if ( $this->is_video_provider( $provider ) ) {
return $this->render_video_embed( $url, $provider, $parsed_block, $rendering_context, $block_content );
}
// For audio providers, use the original audio rendering logic.
$label = $this->get_provider_label( $provider, $attr );
// Create a mock audio block structure to reuse the Audio renderer.
$mock_audio_block = array(
'blockName' => 'core/audio',
'attrs' => array(
'src' => $url,
'label' => $label,
),
'innerHTML' => '<figure class="wp-block-audio"><audio controls src="' . esc_attr( $url ) . '"></audio></figure>',
);
// Copy email attributes to the mock block.
if ( isset( $parsed_block['email_attrs'] ) ) {
$mock_audio_block['email_attrs'] = $parsed_block['email_attrs'];
}
// Use the Audio renderer to render the audio provider embed.
$audio_renderer = new Audio();
$audio_result = $audio_renderer->render( $mock_audio_block['innerHTML'], $mock_audio_block, $rendering_context );
// If audio rendering fails, fall back to a simple link.
if ( empty( $audio_result ) ) {
$fallback_attr = $this->create_fallback_attributes( $url, $label );
return $this->render_link_fallback( $fallback_attr, $block_content, $parsed_block, $rendering_context );
}
return $audio_result;
}
/**
* Get supported audio or video provider from block attributes or content.
*
* @param array $attr Block attributes.
* @param string $block_content Block content.
* @return string Provider name or empty string if not supported.
*/
private function get_supported_provider( array $attr, string $block_content ): string {
$all_supported_providers = $this->get_all_supported_providers();
// Check provider name slug.
if ( isset( $attr['providerNameSlug'] ) && in_array( $attr['providerNameSlug'], $all_supported_providers, true ) ) {
return $attr['providerNameSlug'];
}
// Check for supported domains in URL or content.
$url = $attr['url'] ?? '';
$content_to_check = ! empty( $url ) ? $url : $block_content;
// Use sophisticated domain detection logic.
return $this->detect_provider_from_domains( $content_to_check );
}
/**
* Extract URL from block content using DOM parsing.
*
* @param string $block_content Block content HTML.
* @return string Extracted URL or empty string.
*/
private function extract_url_from_content( string $block_content ): string {
$dom_helper = new Dom_Document_Helper( $block_content );
// Find the wp-block-embed__wrapper div.
$wrapper_element = $dom_helper->find_element( 'div' );
if ( $wrapper_element ) {
// Check if this div has the correct class.
$class_attr = $dom_helper->get_attribute_value( $wrapper_element, 'class' );
if ( strpos( $class_attr, 'wp-block-embed__wrapper' ) !== false ) {
// Get the text content (URL) from the div.
$url = trim( $wrapper_element->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// Decode HTML entities and validate URL.
$url = html_entity_decode( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
// Validate the extracted URL.
if ( $this->is_valid_url( $url ) ) {
return $url;
}
}
}
return '';
}
/**
* Extract provider URL from block attributes or content.
*
* @param array $attr Block attributes.
* @param string $block_content Block content.
* @return string Provider URL or empty string.
*/
private function extract_provider_url( array $attr, string $block_content ): string {
// First, try to get URL from attributes.
if ( ! empty( $attr['url'] ) ) {
$url = $attr['url'];
// Validate the URL from attributes.
if ( $this->is_valid_url( $url ) ) {
return $url;
}
return '';
}
// If not in attributes, extract from block content.
return $this->extract_url_from_content( $block_content );
}
/**
* Get appropriate label for the provider.
*
* @param string $provider Provider name.
* @param array $attr Block attributes.
* @return string Label for the provider.
*/
private function get_provider_label( string $provider, array $attr ): string {
// Use custom label if provided.
if ( ! empty( $attr['label'] ) ) {
return $attr['label'];
}
// Get translated label for the provider.
return $this->get_translated_provider_label( $provider );
}
/**
* Get translated label for a provider.
*
* @param string $provider Provider name.
* @return string Translated label for the provider.
*/
private function get_translated_provider_label( string $provider ): string {
switch ( $provider ) {
case 'spotify':
return __( 'Listen on Spotify', 'woocommerce' );
case 'soundcloud':
return __( 'Listen on SoundCloud', 'woocommerce' );
case 'pocket-casts':
return __( 'Listen on Pocket Casts', 'woocommerce' );
case 'mixcloud':
return __( 'Listen on Mixcloud', 'woocommerce' );
case 'reverbnation':
return __( 'Listen on ReverbNation', 'woocommerce' );
case 'youtube':
return __( 'Watch on YouTube', 'woocommerce' );
default:
return __( 'Listen to the audio', 'woocommerce' );
}
}
/**
* Render a simple link fallback for non-supported embeds.
*
* @param array $attr Block attributes.
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string Rendered link or empty string if no valid URL.
*/
private function render_link_fallback( array $attr, string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// Try to get URL from attributes first.
$url = $attr['url'] ?? '';
// If no URL in attributes, try to extract from block content.
if ( empty( $url ) ) {
// First try the standard wrapper div extraction.
$url = $this->extract_url_from_content( $block_content );
// If still no URL, try to find any HTTP/HTTPS URL in the entire content.
if ( empty( $url ) ) {
$dom_helper = new Dom_Document_Helper( $block_content );
$body_element = $dom_helper->find_element( 'body' );
if ( $body_element ) {
$text_content = $body_element->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
// Look for HTTP/HTTPS URLs in the text content.
if ( preg_match( '/(?<![a-zA-Z0-9.-])https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[a-zA-Z0-9\/?=&%-]*(?![a-zA-Z0-9.-])/', $text_content, $matches ) ) {
$url = $matches[0];
}
}
}
}
// If still no URL, try to use provider-specific base URL if we have a provider.
if ( empty( $url ) && isset( $attr['providerNameSlug'] ) ) {
$url = $this->get_provider_base_url( $attr['providerNameSlug'] );
}
// Validate URL with both filter_var and wp_http_validate_url.
if ( ! $this->is_valid_url( $url ) ) {
return '';
}
// Get link text - use custom label if provided, otherwise use provider label for base URLs or URL.
if ( ! empty( $attr['label'] ) ) {
$link_text = $attr['label'];
} else {
// Check if this is a provider base URL (like https://open.spotify.com/).
$provider = $attr['providerNameSlug'] ?? '';
$base_url = $this->get_provider_base_url( $provider );
if ( ! empty( $base_url ) && $url === $base_url ) {
// Use provider-specific label for base URLs.
$link_text = $this->get_provider_label( $provider, $attr );
} else {
// Use the URL itself for specific URLs.
$link_text = $url;
}
}
// Get color from email attributes or theme styles.
$email_styles = $rendering_context->get_theme_styles();
$link_color = $parsed_block['email_attrs']['color'] ?? $email_styles['color']['text'] ?? '#0073aa';
// Sanitize color value to ensure it's a valid hex color or CSS variable.
$link_color = Html_Processing_Helper::sanitize_color( $link_color );
// Create a simple link.
$link_html = sprintf(
'<a href="%s" target="_blank" rel="noopener nofollow" style="color: %s; text-decoration: underline;">%s</a>',
esc_url( $url ),
esc_attr( $link_color ),
esc_html( $link_text )
);
// Wrap with spacer if we have email attributes.
return $this->add_spacer(
$link_html,
$parsed_block['email_attrs'] ?? array()
);
}
/**
* Get base URL for a provider when specific URL extraction fails.
*
* @param string $provider Provider name.
* @return string Base URL for the provider or empty string.
*/
private function get_provider_base_url( string $provider ): string {
$all_providers = $this->get_all_provider_configs();
return $all_providers[ $provider ]['base_url'] ?? '';
}
/**
* Check if a provider is a video provider.
*
* @param string $provider Provider name.
* @return bool True if video provider.
*/
private function is_video_provider( string $provider ): bool {
return array_key_exists( $provider, self::VIDEO_PROVIDERS );
}
/**
* Render a video embed using the Video renderer.
*
* @param string $url URL of the video.
* @param string $provider Provider name.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @param string $block_content Original block content.
* @return string Rendered video embed or fallback.
*/
private function render_video_embed( string $url, string $provider, array $parsed_block, Rendering_Context $rendering_context, string $block_content ): string {
// Try to get video thumbnail URL.
$poster_url = $this->get_video_thumbnail_url( $url, $provider );
// If no poster available, fall back to a simple link.
if ( empty( $poster_url ) ) {
$fallback_attr = $this->create_fallback_attributes( $url, $url );
return $this->render_link_fallback( $fallback_attr, $block_content, $parsed_block, $rendering_context );
}
// Create a mock video block structure to reuse the Video renderer.
$mock_video_block = array(
'blockName' => 'core/video',
'attrs' => array(
'poster' => $poster_url,
),
'innerHTML' => '<figure class="wp-block-video wp-block-embed is-type-video is-provider-' . esc_attr( $provider ) . '"><div class="wp-block-embed__wrapper">' . esc_url( $url ) . '</div></figure>',
);
// Copy email attributes to the mock block.
if ( isset( $parsed_block['email_attrs'] ) ) {
$mock_video_block['email_attrs'] = $parsed_block['email_attrs'];
}
// Use the Video renderer to render the video provider embed.
$video_renderer = new Video();
$video_result = $video_renderer->render( $mock_video_block['innerHTML'], $mock_video_block, $rendering_context );
// If video rendering fails, fall back to a simple link.
if ( empty( $video_result ) ) {
$fallback_attr = $this->create_fallback_attributes( $url, $url );
return $this->render_link_fallback( $fallback_attr, $block_content, $parsed_block, $rendering_context );
}
return $video_result;
}
/**
* Get video thumbnail URL for supported providers.
*
* @param string $url Video URL.
* @param string $provider Provider name.
* @return string Thumbnail URL or empty string.
*/
private function get_video_thumbnail_url( string $url, string $provider ): string {
// Currently only YouTube supports thumbnail extraction.
if ( 'youtube' === $provider ) {
return $this->get_youtube_thumbnail( $url );
}
// For other providers, we don't have thumbnail extraction implemented.
// Return empty to trigger link fallback.
return '';
}
/**
* Extract YouTube video thumbnail URL.
*
* @param string $url YouTube video URL.
* @return string Thumbnail URL or empty string.
*/
private function get_youtube_thumbnail( string $url ): string {
// Extract video ID from various YouTube URL formats.
$video_id = '';
if ( preg_match( '/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/', $url, $matches ) ) {
$video_id = $matches[1];
}
if ( empty( $video_id ) ) {
return '';
}
// Return YouTube thumbnail URL.
// Using 0.jpg format as shown in the example.
return 'https://img.youtube.com/vi/' . $video_id . '/0.jpg';
}
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* Fallback block renderer.
* This renderer is used when no specific renderer is found for a block.
*
* AbstractBlockRenderer applies some adjustments to the block content, like adding spacers.
* By using fallback renderer for all blocks we apply there adjustments to all blocks that don't have any renderer.
*
* We need to find a better abstraction/architecture for this.
*/
class Fallback extends Abstract_Block_Renderer {
/**
* Renders the block content
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$block_attrs = $parsed_block['attrs'] ?? array();
$table_attrs = array(
'style' => 'border-collapse: separate;', // Needed because of border radius.
'width' => '100%',
);
$align = $block_attrs['textAlign'] ?? $block_attrs['align'] ?? 'left';
$cell_attrs = array(
'align' => $align,
);
return Table_Wrapper_Helper::render_table_wrapper( $block_content, $table_attrs, $cell_attrs );
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
/**
* Gallery block renderer.
* This renderer handles core/gallery blocks with proper email-friendly HTML layout.
*/
class Gallery extends Abstract_Block_Renderer {
/**
* Renders the gallery block content using a table-based layout.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// Extract images directly from the block content (more efficient than re-rendering).
$gallery_images = $this->extract_images_from_gallery_content( $block_content, $parsed_block );
// If we don't have any images, return empty.
if ( empty( $gallery_images ) ) {
return '';
}
// Build the email-friendly layout.
return $this->build_email_layout( $gallery_images, $parsed_block, $block_content, $rendering_context );
}
/**
* Extract all images from gallery content with their links and captions.
*
* @param string $block_content The rendered gallery block HTML.
* @param array $parsed_block The parsed block data.
* @return array Array of sanitized image HTML strings.
*/
private function extract_images_from_gallery_content( string $block_content, array $parsed_block ): array {
$gallery_images = array();
$inner_blocks = $parsed_block['innerBlocks'] ?? array();
// Extract images from inner blocks data where the actual image HTML is stored.
foreach ( $inner_blocks as $block ) {
if ( 'core/image' === $block['blockName'] && isset( $block['innerHTML'] ) ) {
$extracted_image = $this->extract_image_from_html( $block['innerHTML'] );
if ( ! empty( $extracted_image ) ) {
$gallery_images[] = $extracted_image;
}
}
}
return $gallery_images;
}
/**
* Extract and sanitize image with optional link and caption from HTML content.
* This is the unified method that handles all image extraction scenarios.
*
* @param string $html_content HTML content containing the image.
* @return string Sanitized image HTML with proper link and caption handling.
*/
private function extract_image_from_html( string $html_content ): string {
$result = '';
// First, try to find a linked image (most common case).
if ( preg_match( '/<a[^>]*href=(["\'])(.*?)\1[^>]*>(\s*<img[^>]*>)\s*<\/a>/s', $html_content, $link_matches ) ) {
// Validate and sanitize the link URL.
$sanitized_url = esc_url( $link_matches[2] );
if ( ! empty( $sanitized_url ) ) {
$sanitized_img = Html_Processing_Helper::sanitize_image_html( $link_matches[3] );
if ( '' !== $sanitized_img ) {
$result .= '<a href="' . $sanitized_url . '">' . $sanitized_img . '</a>';
}
} else {
// If URL is invalid, extract just the image without link.
$sanitized_img = Html_Processing_Helper::sanitize_image_html( $link_matches[3] );
if ( '' !== $sanitized_img ) {
$result .= $sanitized_img;
}
}
} elseif ( preg_match( '/<img[^>]*>/', $html_content, $img_matches ) ) {
// Image is not linked - just extract the img element with sanitization.
$sanitized_img = Html_Processing_Helper::sanitize_image_html( $img_matches[0] );
if ( '' !== $sanitized_img ) {
$result .= $sanitized_img;
}
}
// Extract the caption if it exists (handle both figcaption and span formats).
// Enhanced security: validate container attributes before extracting content.
if ( preg_match( '/(<figcaption[^>]*>)(.*?)(<\/figcaption>)/s', $html_content, $caption_matches ) ) {
// Validate the figcaption container attributes for security.
if ( Html_Processing_Helper::validate_container_attributes( $caption_matches[1] . $caption_matches[3] ) ) {
$sanitized_caption = Html_Processing_Helper::sanitize_caption_html( $caption_matches[2] );
$result .= '<br><div class="wp-element-caption" style="font-size: 13px; line-height: 1.0;">' . $sanitized_caption . '</div>';
}
} elseif ( preg_match( '/(<span class="wp-element-caption"[^>]*>)(.*?)(<\/span>)/s', $html_content, $caption_matches ) ) {
// Validate the span container attributes for security.
if ( Html_Processing_Helper::validate_container_attributes( $caption_matches[1] . $caption_matches[3] ) ) {
$sanitized_caption = Html_Processing_Helper::sanitize_caption_html( $caption_matches[2] );
$result .= '<br><div class="wp-element-caption" style="font-size: 13px; line-height: 1.0;">' . $sanitized_caption . '</div>';
}
}
return $result;
}
/**
* Extract gallery-level caption from the original block content.
*
* @param string $block_content Original block content.
* @return string Gallery caption or empty string if not found.
*/
private function extract_gallery_caption( string $block_content ): string {
// Look for gallery-level caption: <figcaption class="blocks-gallery-caption wp-element-caption">.
// Enhanced security: validate container attributes before extracting content.
if ( preg_match( '/(<figcaption class="blocks-gallery-caption[^"]*"[^>]*>)(.*?)(<\/figcaption>)/s', $block_content, $matches ) ) {
// Validate the figcaption container attributes for security.
if ( Html_Processing_Helper::validate_container_attributes( $matches[1] . $matches[3] ) ) {
return Html_Processing_Helper::sanitize_caption_html( trim( $matches[2] ) );
}
}
return '';
}
/**
* Build the email-friendly layout for gallery blocks.
*
* @param array $gallery_images Array of image HTML strings.
* @param array $parsed_block Full parsed block data.
* @param string $block_content Original block content.
* @param Rendering_Context $rendering_context Rendering context.
* @return string Rendered HTML.
*/
private function build_email_layout( array $gallery_images, array $parsed_block, string $block_content, Rendering_Context $rendering_context ): string {
// Get original wrapper classes from block content.
$original_wrapper_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'figure', 'class' ) ?? '';
// Get gallery attributes.
$block_attrs = $parsed_block['attrs'] ?? array();
$columns = $this->get_columns_from_attributes( $block_attrs );
// Extract gallery-level caption from the original block content.
$gallery_caption = $this->extract_gallery_caption( $block_content );
// Get block styles using the Styles_Helper.
$block_styles = Styles_Helper::get_block_styles( $block_attrs, $rendering_context, array( 'padding', 'border', 'background', 'background-color', 'color' ) );
$block_styles = Styles_Helper::extend_block_styles(
$block_styles,
array(
'width' => '100%',
'border-collapse' => 'collapse',
'text-align' => 'left',
)
);
// Apply class and style attributes to the wrapper table.
$table_attrs = array(
'class' => 'email-block-gallery ' . Html_Processing_Helper::clean_css_classes( $original_wrapper_classname ),
'style' => $block_styles['css'],
'align' => 'left',
'width' => '100%',
);
// Add email width to cell attributes if available.
$cell_attrs = array();
if ( isset( $parsed_block['email_attrs']['width'] ) ) {
$cell_attrs['width'] = $parsed_block['email_attrs']['width'];
}
// Build the gallery rows with proper table structure.
$gallery_content = $this->build_gallery_table( $gallery_images, $columns );
// Add gallery caption if it exists.
if ( ! empty( $gallery_caption ) ) {
$gallery_content .= '<br><div class="blocks-gallery-caption wp-element-caption" style="font-size: 13px; line-height: 1.0; text-align: center;">' . $gallery_caption . '</div>';
}
// Use Table_Wrapper_Helper for the main container (following tiled gallery pattern).
return Table_Wrapper_Helper::render_table_wrapper( $gallery_content, $table_attrs, $cell_attrs );
}
/**
* Build the gallery table structure with proper rows and cells.
* Uses the tiled gallery pattern: separate tables for each row, then wrap in main table.
*
* @param array $gallery_images Array of image HTML strings.
* @param int $columns Number of columns.
* @return string Gallery table HTML.
*/
private function build_gallery_table( array $gallery_images, int $columns ): string {
$content_parts = array();
$image_count = count( $gallery_images );
$cell_padding = 8; // 0.5em equivalent (approximately 8px)
// Process images in chunks based on columns to create rows.
for ( $i = 0; $i < $image_count; $i += $columns ) {
$row_images = array_slice( $gallery_images, $i, $columns );
$content_parts[] = $this->build_gallery_row_table( $row_images, $columns, $cell_padding );
}
return implode( '', $content_parts );
}
/**
* Build a single gallery row as a separate table (following tiled gallery pattern).
*
* @param array $row_images Images for this row.
* @param int $total_columns Total number of columns.
* @param int $cell_padding Cell padding.
* @return string Row table HTML.
*/
private function build_gallery_row_table( array $row_images, int $total_columns, int $cell_padding ): string {
$images_in_row = count( $row_images );
$row_cells = '';
// If there is exactly one image, span full width; otherwise distribute width evenly across the images in this row.
if ( 1 === $images_in_row ) {
$cell_attrs = array(
'style' => sprintf( 'width: %s; padding: %dpx; vertical-align: top; text-align: center;', Html_Processing_Helper::sanitize_css_value( '100%' ), $cell_padding ),
'valign' => 'top',
'colspan' => $total_columns,
);
$row_cells .= Table_Wrapper_Helper::render_table_cell( $row_images[0], $cell_attrs );
} else {
// Evenly distribute available width among the images in this row.
$cell_width_percent = 100 / $images_in_row;
foreach ( $row_images as $image_html ) {
$cell_attrs = array(
'style' => sprintf(
'width: %s; padding: %dpx; vertical-align: top; text-align: center;',
Html_Processing_Helper::sanitize_css_value( sprintf( '%.2f%%', $cell_width_percent ) ),
$cell_padding
),
'valign' => 'top',
);
$row_cells .= Table_Wrapper_Helper::render_table_cell( $image_html, $cell_attrs );
}
}
// Create a separate table for this row (following tiled gallery pattern).
return sprintf(
'<table role="presentation" style="width: %s; border-collapse: collapse; table-layout: fixed;"><tr>%s</tr></table>',
Html_Processing_Helper::sanitize_css_value( '100%' ),
$row_cells
);
}
/**
* Get the columns value from block attributes.
*
* @param array $block_attrs Block attributes.
* @return int Number of columns (1-5).
*/
private function get_columns_from_attributes( array $block_attrs ): int {
$columns = $block_attrs['columns'] ?? 3;
// Ensure the columns are within reasonable bounds.
$columns = max( 1, min( 5, (int) $columns ) );
return $columns;
}
}

View File

@@ -0,0 +1,84 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
/**
* Renders a group block.
*/
class Group extends Abstract_Block_Renderer {
/**
* Renders the block content
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
return str_replace(
'{group_content}',
$this->get_inner_content( $block_content ),
$this->get_block_wrapper( $block_content, $parsed_block, $rendering_context )
);
}
/**
* Returns the block wrapper.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
*/
private function get_block_wrapper( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$original_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'div', 'class' ) ?? '';
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'style' => array(),
'backgroundColor' => '',
'textColor' => '',
'borderColor' => '',
'layout' => array(),
)
);
$table_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'border', 'background', 'background-color', 'color', 'text-align' ) );
$table_styles = Styles_Helper::extend_block_styles(
$table_styles,
array_filter(
array(
'border-collapse' => 'separate',
'background-size' => $table_styles['background-size'] ?? 'cover',
)
)
);
// Padding properties need to be added to the table cell.
$cell_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'padding' ) );
$table_attrs = array(
'class' => 'email-block-group ' . $original_classname,
'style' => $table_styles['css'],
'width' => '100%',
);
$cell_attrs = array(
'class' => 'email-block-group-content',
'style' => $cell_styles['css'],
'width' => $parsed_block['email_attrs']['width'] ?? '100%',
);
return Table_Wrapper_Helper::render_table_wrapper( '{group_content}', $table_attrs, $cell_attrs );
}
}

View File

@@ -0,0 +1,429 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* Renders an image block.
*/
class Image extends Abstract_Block_Renderer {
/**
* Renders the block content
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$parsed_html = $this->parse_block_content( $block_content );
if ( ! $parsed_html ) {
return '';
}
$image_url = $parsed_html['imageUrl'];
$image = $parsed_html['image'];
$caption = $parsed_html['caption'];
$class = $parsed_html['class'];
$anchor_tag_href = $parsed_html['anchor_tag_href'];
$parsed_block = $this->add_image_size_when_missing( $parsed_block, $image_url );
$image = $this->add_image_dimensions( $image, $parsed_block );
$image_with_wrapper = str_replace(
array( '{image_content}', '{caption_content}' ),
array( $image, $caption ),
$this->get_block_wrapper( $parsed_block, $rendering_context, $caption, $anchor_tag_href )
);
$image_with_wrapper = $this->apply_rounded_style( $image_with_wrapper, $parsed_block );
$image_with_wrapper = $this->apply_image_border_style( $image_with_wrapper, $parsed_block, $class );
return $image_with_wrapper;
}
/**
* Apply rounded style to the image.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
*/
private function apply_rounded_style( string $block_content, array $parsed_block ): string {
// Because the isn't an attribute for definition of rounded style, we have to check the class name.
if ( isset( $parsed_block['attrs']['className'] ) && strpos( $parsed_block['attrs']['className'], 'is-style-rounded' ) !== false ) {
// If the image should be in a circle, we need to set the border-radius to 9999px to make it the same as is in the editor
// This style is applied to both wrapper and the image.
$block_content = $this->remove_style_attribute_from_element(
$block_content,
array(
'tag_name' => 'td',
'class_name' => 'email-image-cell',
),
'border-radius'
);
$block_content = $this->add_style_to_element(
$block_content,
array(
'tag_name' => 'td',
'class_name' => 'email-image-cell',
),
'border-radius: 9999px;'
);
$block_content = $this->remove_style_attribute_from_element( $block_content, array( 'tag_name' => 'img' ), 'border-radius' );
$block_content = $this->add_style_to_element( $block_content, array( 'tag_name' => 'img' ), 'border-radius: 9999px;' );
}
return $block_content;
}
/**
* When the width is not set, it's important to get it for the image to be displayed correctly
*
* @param array $parsed_block Parsed block.
* @param string $image_url Image URL.
*/
private function add_image_size_when_missing( array $parsed_block, string $image_url ): array {
if ( isset( $parsed_block['attrs']['width'] ) ) {
return $parsed_block;
}
// Can't determine any width let's go with 100%.
if ( ! isset( $parsed_block['email_attrs']['width'] ) ) {
$parsed_block['attrs']['width'] = '100%';
return $parsed_block;
}
$max_width = Styles_Helper::parse_value( $parsed_block['email_attrs']['width'] );
$image_size = null;
if ( $image_url ) {
// Try to extract width from URL query parameter if it exists.
$parsed_url = wp_parse_url( $image_url );
if ( isset( $parsed_url['query'] ) ) {
parse_str( $parsed_url['query'], $query_params );
if ( isset( $query_params['w'] ) && is_numeric( $query_params['w'] ) && $query_params['w'] > 0 ) {
$image_size = (int) $query_params['w'];
}
}
// Next we check the attachment data if it has an ID.
if ( ! isset( $image_size ) ) {
$attachment_id = $parsed_block['attrs']['id'] ?? null;
if ( $attachment_id ) {
$size_slug = $parsed_block['attrs']['sizeSlug'] ?? 'large';
// Check the metadata first.
$metadata = wp_get_attachment_metadata( $attachment_id );
if ( $metadata ) {
if ( isset( $metadata['sizes'][ $size_slug ]['width'] ) ) {
$image_size = (int) $metadata['sizes'][ $size_slug ]['width'];
} elseif ( 'full' === $size_slug && isset( $metadata['width'] ) ) {
$image_size = (int) $metadata['width'];
}
}
// Try to get dimensions from wp_get_attachment_image_src if metadata didn't have it.
if ( ! isset( $image_size ) ) {
$image_src = wp_get_attachment_image_src( $attachment_id, $size_slug );
if ( $image_src && isset( $image_src[1] ) ) {
$image_size = (int) $image_src[1];
}
}
}
}
// Fallback to wp_getimagesize if we still don't have a size.
if ( ! isset( $image_size ) ) {
$upload_dir = wp_upload_dir();
$image_path = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $image_url );
$result = wp_getimagesize( $image_path );
if ( $result ) {
$image_size = (int) $result[0];
}
}
}
// Use the found image size or fall back to max_width.
$width = isset( $image_size ) ? min( $image_size, $max_width ) : $max_width;
$parsed_block['attrs']['width'] = "{$width}px";
return $parsed_block;
}
/**
* Apply border style to the image.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param string $class_name Class name.
*/
private function apply_image_border_style( string $block_content, array $parsed_block, string $class_name ): string {
// Getting individual border properties.
$border_styles = wp_style_engine_get_styles( array( 'border' => $parsed_block['attrs']['style']['border'] ?? array() ) );
$border_styles = $border_styles['declarations'] ?? array();
if ( ! empty( $border_styles ) ) {
$border_styles['border-style'] = 'solid';
$border_styles['box-sizing'] = 'border-box';
}
$border_element_tag = array(
'tag_name' => 'td',
'class_name' => 'email-image-cell',
);
$content_with_border_styles = $this->add_style_to_element( $block_content, $border_element_tag, \WP_Style_Engine::compile_css( $border_styles, '' ) );
// Remove border styles from the image HTML tag.
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-style' );
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-width' );
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-color' );
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-radius' );
// Add Border related classes to proper element. This is required for inlined border-color styles when defined via class.
$border_classes = array_filter(
explode( ' ', $class_name ),
function ( $class_name ) {
return strpos( $class_name, 'border' ) !== false;
}
);
$html = new \WP_HTML_Tag_Processor( $content_with_border_styles );
if ( $html->next_tag( $border_element_tag ) ) {
$class_name = $html->get_attribute( 'class' ) ?? '';
$border_classes[] = $class_name;
$html->set_attribute( 'class', implode( ' ', $border_classes ) );
}
return $html->get_updated_html();
}
/**
* Settings width and height attributes for images is important for MS Outlook.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
*/
private function add_image_dimensions( string $block_content, array $parsed_block ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
if ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
// Getting height from styles and if it's set, we set the height attribute.
/** @var string $styles */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$styles = $html->get_attribute( 'style' ) ?? '';
$styles = Styles_Helper::parse_styles_to_array( $styles );
$height = $styles['height'] ?? null;
if ( $height && 'auto' !== $height ) {
$height = Styles_Helper::parse_value( $height );
/* @phpstan-ignore-next-line Wrong annotation for parameter in WP. */
$html->set_attribute( 'height', esc_attr( $height ) );
}
if ( isset( $parsed_block['attrs']['width'] ) ) {
$width = Styles_Helper::parse_value( $parsed_block['attrs']['width'] );
/* @phpstan-ignore-next-line Wrong annotation for parameter in WP. */
$html->set_attribute( 'width', esc_attr( $width ) );
}
$block_content = $html->get_updated_html();
}
return $block_content;
}
/**
* This method configure the font size of the caption because it's set to 0 for the parent element to avoid unexpected white spaces
* We try to use font-size passed down from the parent element $parsedBlock['email_attrs']['font-size'], but if it's not set, we use the default font-size from the email theme.
*
* @param Rendering_Context $rendering_context Rendering context.
* @param array $parsed_block Parsed block.
*/
private function get_caption_styles( Rendering_Context $rendering_context, array $parsed_block ): string {
$theme_data = $rendering_context->get_theme_json()->get_data();
$styles = array(
'text-align' => isset( $parsed_block['attrs']['align'] ) ? 'center' : 'left',
);
$styles['font-size'] = $parsed_block['email_attrs']['font-size'] ?? $theme_data['styles']['typography']['fontSize'];
return \WP_Style_Engine::compile_css( $styles, '' );
}
/**
* Based on MJML <mj-image> but because MJML doesn't support captions, our solution is a bit different
*
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @param string|null $caption Caption.
* @param string|null $anchor_tag_href Anchor tag href.
*/
private function get_block_wrapper( array $parsed_block, Rendering_Context $rendering_context, ?string $caption, ?string $anchor_tag_href ): string {
$styles = array(
'border-collapse' => 'collapse',
'border-spacing' => '0px',
'font-size' => '0px',
'vertical-align' => 'top',
'width' => '100%',
);
$width = $parsed_block['attrs']['width'] ?? '100%';
$wrapper_width = ( $width && '100%' !== $width ) ? $width : 'auto';
$wrapper_styles = $styles;
$wrapper_styles['width'] = $wrapper_width;
$wrapper_styles['border-collapse'] = 'separate'; // Needed because of border radius.
$caption_html = '';
if ( $caption ) {
// When the image is not aligned, the wrapper is set to 100% width due to caption that can be longer than the image.
$caption_width = isset( $parsed_block['attrs']['align'] ) ? ( $parsed_block['attrs']['width'] ?? '100%' ) : '100%';
$caption_wrapper_styles = $styles;
$caption_wrapper_styles['width'] = $caption_width;
$caption_styles = $this->get_caption_styles( $rendering_context, $parsed_block );
$caption_table_attrs = array(
'class' => 'email-table-with-width',
'style' => \WP_Style_Engine::compile_css( $caption_wrapper_styles, '' ),
'width' => $caption_width,
);
$caption_cell_attrs = array(
'style' => $caption_styles,
);
$caption_html = Table_Wrapper_Helper::render_table_wrapper( '{caption_content}', $caption_table_attrs, $caption_cell_attrs );
}
$styles['width'] = '100%';
$align = $parsed_block['attrs']['align'] ?? 'left';
$table_attrs = array(
'style' => \WP_Style_Engine::compile_css( $styles, '' ),
'width' => '100%',
);
$cell_attrs = array(
'align' => $align,
);
$image_table_attrs = array(
'class' => 'email-table-with-width',
'style' => \WP_Style_Engine::compile_css( $wrapper_styles, '' ),
'width' => $wrapper_width,
);
$image_cell_attrs = array(
'class' => 'email-image-cell',
'style' => 'overflow: hidden;',
);
$image_content = '{image_content}';
if ( $anchor_tag_href ) {
$image_content = sprintf(
'<a href="%s" rel="noopener nofollow" target="_blank">%s</a>',
esc_url( $anchor_tag_href ),
'{image_content}'
);
}
$image_html = Table_Wrapper_Helper::render_table_wrapper( $image_content, $image_table_attrs, $image_cell_attrs );
$inner_content = $image_html . $caption_html;
return Table_Wrapper_Helper::render_table_wrapper( $inner_content, $table_attrs, $cell_attrs );
}
/**
* Add style to the element.
*
* @param string $block_content Block content.
* @param array{tag_name: string, class_name?: string} $tag Tag to add style to.
* @param string $style Style to add.
*/
private function add_style_to_element( $block_content, array $tag, string $style ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
if ( $html->next_tag( $tag ) ) {
/** @var string $element_style */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$element_style = $html->get_attribute( 'style' ) ?? '';
$element_style = ! empty( $element_style ) ? ( rtrim( $element_style, ';' ) . ';' ) : ''; // Adding semicolon if it's missing.
$element_style .= $style;
$html->set_attribute( 'style', esc_attr( $element_style ) );
$block_content = $html->get_updated_html();
}
return $block_content;
}
/**
* Remove style attribute from the element.
*
* @param string $block_content Block content.
* @param array{tag_name: string, class_name?: string} $tag Tag to remove style from.
* @param string $style_name Name of the style to remove.
*/
private function remove_style_attribute_from_element( $block_content, array $tag, string $style_name ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
if ( $html->next_tag( $tag ) ) {
/** @var string $element_style */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$element_style = $html->get_attribute( 'style' ) ?? '';
$element_style = preg_replace( '/' . preg_quote( $style_name, '/' ) . '\s*:\s*[^;]+;?/', '', $element_style );
$html->set_attribute( 'style', esc_attr( strval( $element_style ) ) );
$block_content = $html->get_updated_html();
}
return $block_content;
}
/**
* Parse block content to get image URL, image HTML and caption HTML.
*
* @param string $block_content Block content.
* @return array{imageUrl: string, image: string, caption: string, class: string, anchor_tag_href: string}|null
*/
private function parse_block_content( string $block_content ): ?array {
// If block's image is not set, we don't need to parse the content.
if ( empty( $block_content ) ) {
return null;
}
$dom_helper = new Dom_Document_Helper( $block_content );
$figure_tag = $dom_helper->find_element( 'figure' );
if ( ! $figure_tag ) {
return null;
}
$img_tag = $dom_helper->find_element( 'img' );
if ( ! $img_tag ) {
return null;
}
$image_src = $dom_helper->get_attribute_value( $img_tag, 'src' );
$image_class = $dom_helper->get_attribute_value( $img_tag, 'class' );
$image_html = $dom_helper->get_outer_html( $img_tag );
$figcaption = $dom_helper->find_element( 'figcaption' );
$figcaption_html = $figcaption ? $dom_helper->get_outer_html( $figcaption ) : '';
$figcaption_html = str_replace( array( '<figcaption', '</figcaption>' ), array( '<span', '</span>' ), $figcaption_html );
$anchor_tag = $dom_helper->find_element( 'a' );
$anchor_tag_href = $anchor_tag ? $dom_helper->get_attribute_value( $anchor_tag, 'href' ) : '';
return array(
'imageUrl' => $image_src ? $image_src : '',
'image' => $this->cleanup_image_html( $image_html ),
'caption' => $figcaption_html ? $figcaption_html : '',
'class' => $image_class ? $image_class : '',
'anchor_tag_href' => $anchor_tag_href ? $anchor_tag_href : '',
);
}
/**
* Cleanup image HTML.
*
* @param string $content_html Content HTML.
*/
private function cleanup_image_html( string $content_html ): string {
$html = new \WP_HTML_Tag_Processor( $content_html );
if ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
$html->remove_attribute( 'srcset' );
$html->remove_attribute( 'class' );
}
return $html->get_updated_html();
}
}

View File

@@ -0,0 +1,64 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
/**
* Renders a list block.
* We have to avoid using keyword `List`
*/
class List_Block extends Abstract_Block_Renderer {
/**
* Renders the block content
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
$tag_name = ( $parsed_block['attrs']['ordered'] ?? false ) ? 'ol' : 'ul';
if ( $html->next_tag( array( 'tag_name' => $tag_name ) ) ) {
/** @var string $styles */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$styles = $html->get_attribute( 'style' ) ?? '';
$styles = Styles_Helper::parse_styles_to_array( $styles );
// Font size.
if ( isset( $parsed_block['email_attrs']['font-size'] ) ) {
$styles['font-size'] = $parsed_block['email_attrs']['font-size'];
} else {
// Use font-size from email theme when those properties are not set.
$theme_data = $rendering_context->get_theme_json()->get_data();
$styles['font-size'] = $theme_data['styles']['typography']['fontSize'];
}
$html->set_attribute( 'style', esc_attr( \WP_Style_Engine::compile_css( $styles, '' ) ) );
$block_content = $html->get_updated_html();
}
$wrapper_style = \WP_Style_Engine::compile_css(
array(
'margin-top' => $parsed_block['email_attrs']['margin-top'] ?? '0px',
),
''
);
// \WP_HTML_Tag_Processor escapes the content, so we have to replace it back
$block_content = str_replace( '&#039;', "'", $block_content );
return sprintf(
'<div style="%1$s">%2$s</div>',
esc_attr( $wrapper_style ),
$block_content
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
/**
* Renders a list item block.
*/
class List_Item extends Abstract_Block_Renderer {
/**
* Override this method to disable spacing (block gap) for list items.
*
* @param string $content Content.
* @param array $email_attrs Email attributes.
*/
protected function add_spacer( $content, $email_attrs ): string {
return $content;
}
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
return $block_content;
}
}

View File

@@ -0,0 +1,190 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
/**
* Media-text block renderer.
* This renderer handles core/media-text blocks with proper email-friendly HTML layout.
*/
class Media_Text extends Abstract_Block_Renderer {
/**
* Renders the media-text block content using a direct table-based layout.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$block_attrs = $parsed_block['attrs'] ?? array();
$inner_blocks = $parsed_block['innerBlocks'] ?? array();
// Extract media content from innerHTML.
$media_content = $this->extract_media_from_html( $parsed_block['innerHTML'] ?? $block_content );
// Render all inner blocks content.
$text_content = '';
foreach ( $inner_blocks as $block ) {
$text_content .= render_block( $block );
}
// If we don't have both media and text content, return empty.
if ( empty( $media_content ) || empty( $text_content ) ) {
return '';
}
// Build the email-friendly layout.
return $this->build_email_layout( $media_content, $text_content, $block_attrs, $block_content, $rendering_context );
}
/**
* Extract media content from the HTML block content.
*
* @param string $block_content Raw block content.
* @return string Media HTML content or empty string if not found.
*/
private function extract_media_from_html( string $block_content ): string {
// Extract inner content from figure element (removing figure wrapper for email compatibility).
$media_content = '';
if ( preg_match( '/<figure[^>]*class="[^"]*\bwp-block-media-text__media\b[^"]*"[^>]*>(.*?)<\/figure>/s', $block_content, $matches ) ) {
$media_content = trim( $matches[1] );
}
return $media_content;
}
/**
* Build the email-friendly layout for media-text blocks.
*
* @param string $media_content Media HTML content.
* @param string $text_content Text content.
* @param array $block_attrs Block attributes.
* @param string $block_content Original block content.
* @param Rendering_Context $rendering_context Rendering context.
* @return string Rendered HTML.
*/
private function build_email_layout( string $media_content, string $text_content, array $block_attrs, string $block_content, Rendering_Context $rendering_context ): string {
// Get original wrapper classes from block content.
$original_wrapper_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'div', 'class' ) ?? '';
// Get layout attributes.
$media_position = $block_attrs['mediaPosition'] ?? 'left';
$vertical_alignment = $this->get_vertical_alignment_from_attributes( $block_attrs );
$media_width = $this->get_media_width_from_attributes( $block_attrs );
$text_width = 100 - $media_width; // Text takes the remaining width.
// Handle image linking for any linkDestination type that has an href.
if ( ! empty( $block_attrs['href'] ) ) {
$media_content = $this->wrap_media_with_link( $media_content, $block_attrs['href'] );
}
// Get block styles using the Styles_Helper.
$block_styles = Styles_Helper::get_block_styles( $block_attrs, $rendering_context, array( 'padding', 'border', 'background', 'background-color', 'color' ) );
$block_styles = Styles_Helper::extend_block_styles(
$block_styles,
array(
'width' => '100%',
'border-collapse' => 'collapse',
'text-align' => 'left',
)
);
// Apply class and style attributes to the wrapper table.
$table_attrs = array(
'class' => 'email-block-media-text ' . $original_wrapper_classname,
'style' => $block_styles['css'],
'align' => 'left',
'width' => '100%',
);
// Build individual table cells.
$media_cell_attrs = array(
'style' => sprintf( 'width: %d%%; padding: 10px; vertical-align: %s;', $media_width, $vertical_alignment ),
'valign' => $vertical_alignment,
);
$text_cell_attrs = array(
'style' => sprintf( 'width: %d%%; padding: 0 8%%; vertical-align: %s;', $text_width, $vertical_alignment ),
'valign' => $vertical_alignment,
);
$media_cell = Table_Wrapper_Helper::render_table_cell( $media_content, $media_cell_attrs );
$text_cell = Table_Wrapper_Helper::render_table_cell( $text_content, $text_cell_attrs );
// Order cells based on media position.
if ( 'right' === $media_position ) {
// Text first, then media.
$cells = $text_cell . $media_cell;
} else {
// Media first, then text (default left position).
$cells = $media_cell . $text_cell;
}
// Use render_cell = false to avoid wrapping in an extra <td>.
return Table_Wrapper_Helper::render_table_wrapper( $cells, $table_attrs, array(), array(), false );
}
/**
* Get the vertical alignment value from block attributes.
*
* @param array $block_attrs Block attributes.
* @return string CSS vertical-align value.
*/
private function get_vertical_alignment_from_attributes( array $block_attrs ): string {
$vertical_alignment = $block_attrs['verticalAlignment'] ?? 'middle';
// Convert WordPress alignment values to CSS values.
switch ( $vertical_alignment ) {
case 'top':
return 'top';
case 'center':
return 'middle';
case 'bottom':
return 'bottom';
default:
return 'middle';
}
}
/**
* Get the media width value from block attributes.
*
* @param array $block_attrs Block attributes.
* @return int Media width percentage (1-99).
*/
private function get_media_width_from_attributes( array $block_attrs ): int {
$media_width = $block_attrs['mediaWidth'] ?? 50;
// Ensure the width is within reasonable bounds.
$media_width = max( 1, min( 99, (int) $media_width ) );
return $media_width;
}
/**
* Wrap media content with a link if it's not already wrapped.
*
* @param string $media_content The media content (inner content from figure element).
* @param string $href The URL to link to.
* @return string Media content wrapped with link.
*/
private function wrap_media_with_link( string $media_content, string $href ): string {
// If media is already wrapped in a link, return as-is.
if ( false !== strpos( $media_content, '<a ' ) ) {
return $media_content;
}
// Wrap the media content with a link.
return '<a href="' . esc_url( $href ) . '">' . $media_content . '</a>';
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
/**
* Stateless renderer for core/post-content block.
*
* This renderer replaces WordPress's default render_block_core_post_content()
* which uses a static $seen_ids array that causes issues when rendering multiple
* emails in a single request (e.g., MailPoet batch processing).
*
* Unlike other block renderers, this class does NOT extend Abstract_Block_Renderer
* because it needs to directly replace WordPress's render_callback with a method
* that matches the exact signature expected by WordPress.
*/
class Post_Content {
/**
* Stateless render callback for core/post-content block.
*
* This implementation avoids using get_the_content() which relies on
* global query state, and instead directly accesses post content
* and applies the_content filter for processing.
*
* Key differences from WordPress's implementation:
* - No static $seen_ids array (allows multiple renders in same request)
* - Uses direct post content access instead of get_the_content()
* - Properly backs up and restores global state
*
* IMPORTANT: This method is only set as the render_callback during email rendering.
* Outside of email rendering, the original callback is restored, so this method
* will never be called in non-email contexts.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param \WP_Block $block Block instance.
* @return string Rendered post content HTML.
*/
public function render_stateless( $attributes, $content, $block ): string {
// This method is only called during email rendering, so we always use stateless logic.
$post_id = $block->context['postId'] ?? null;
if ( ! $post_id ) {
return '';
}
$email_post = get_post( $post_id );
if ( ! $email_post || empty( $email_post->post_content ) ) {
return '';
}
// Backup global state.
global $post, $wp_query;
$backup_post = $post;
$backup_query = $wp_query;
// Set up global state for block rendering.
// This ensures that blocks which depend on global $post work correctly.
$post = $email_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// Create a query specifically for this post to ensure proper context.
$wp_query = new \WP_Query( array( 'p' => $post_id ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// Get raw post content and apply the_content filter.
// The the_content filter processes blocks, shortcodes, etc.
// We don't use get_the_content() to avoid issues with loop state.
$post_content = $email_post->post_content;
// Check for nextpage to display page links for paginated posts.
if ( has_block( 'core/nextpage', $email_post ) ) {
$post_content .= wp_link_pages( array( 'echo' => 0 ) );
}
// Apply the_content filter to process blocks.
$post_content = apply_filters( 'the_content', str_replace( ']]>', ']]&gt;', $post_content ) );
// Restore global state.
$post = $backup_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_query = $backup_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
if ( empty( $post_content ) ) {
return '';
}
return $post_content;
}
}

View File

@@ -0,0 +1,124 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
/**
* Renders a quote block.
*/
class Quote extends Abstract_Block_Renderer {
/**
* Renders the block content
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$dom_helper = new Dom_Document_Helper( $block_content );
// Extract citation if present.
$citation_content = '';
$cite_element = $dom_helper->find_element( 'cite' );
if ( $cite_element ) {
$citation_content = $this->get_citation_wrapper(
$dom_helper->get_element_inner_html( $cite_element ),
$parsed_block,
$rendering_context
);
}
return str_replace(
array( '{quote_content}', '{citation_content}' ),
array( $this->get_inner_content( $block_content ), $citation_content ),
$this->get_block_wrapper( $block_content, $parsed_block, $rendering_context )
);
}
/**
* Returns the citation content with a wrapper.
*
* @param string $citation_content The citation text.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context instance.
* @return string The wrapped citation HTML or empty string if no citation.
*/
private function get_citation_wrapper( string $citation_content, array $parsed_block, Rendering_Context $rendering_context ): string {
if ( empty( $citation_content ) ) {
return '';
}
// The HTML cite tag should use block gap as margin-top.
$theme_styles = $rendering_context->get_theme_styles();
$margin_top = $theme_styles['spacing']['blockGap'] ?? '0px';
$citation_styles = Styles_Helper::get_block_styles( $parsed_block['attrs'], $rendering_context, array( 'text-align' ) );
$citation_styles = Styles_Helper::extend_block_styles( $citation_styles, array( 'margin' => "{$margin_top} 0px 0px 0px" ) );
return $this->add_spacer(
sprintf(
'<p style="%2$s"><cite class="email-block-quote-citation" style="display: block; margin: 0;">%1$s</cite></p>',
$citation_content,
$citation_styles['css'],
),
$parsed_block['email_attrs'] ?? array()
);
}
/**
* Returns the block wrapper.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
*/
private function get_block_wrapper( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$original_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'blockquote', 'class' ) ?? '';
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'style' => array(),
'backgroundColor' => '',
'textColor' => '',
'borderColor' => '',
)
);
// Layout, background, borders need to be on the outer table element.
$table_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'border', 'background', 'background-color', 'color', 'text-align' ) );
$table_styles = Styles_Helper::extend_block_styles(
$table_styles,
array(
'border-collapse' => 'separate',
'background-size' => $table_styles['declarations']['background-size'] ?? 'cover',
)
);
// Padding properties need to be added to the table cell.
$cell_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'padding' ) );
$table_attrs = array(
'class' => 'email-block-quote ' . $original_classname,
'style' => $table_styles['css'],
'width' => '100%',
);
$cell_attrs = array(
'class' => 'email-block-quote-content',
'style' => $cell_styles['css'],
'width' => '100%',
);
return Table_Wrapper_Helper::render_table_wrapper( '{quote_content}{citation_content}', $table_attrs, $cell_attrs );
}
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
/**
* Renders a social link block.
*/
class Social_Link extends Abstract_Block_Renderer {
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// We are not using this because the blocks are rendered in the Social_Links block class.
return $block_content;
}
}

View File

@@ -0,0 +1,418 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Social_Links_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* Renders the social links block.
*/
class Social_Links extends Abstract_Block_Renderer {
/**
* Cache of the core social link services.
*
* @var array<string, array>
*/
private $core_social_link_services_cache = array();
/**
* Supported image types.
*
* @var array<string>
*/
private $supported_image_types = array( 'white', 'brand' );
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$attrs = $parsed_block['attrs'] ?? array();
$inner_blocks = $parsed_block['innerBlocks'] ?? array();
$content = '';
foreach ( $inner_blocks as $block ) {
$content .= $this->generate_social_link_content( $block, $attrs );
}
return str_replace(
'{social_links_content}',
$content,
$this->get_block_wrapper( $block_content, $parsed_block )
);
}
/**
* Generates the social link content.
*
* @param array $block The block data.
* @param array $parent_block_attrs The parent block attributes.
* @return string The generated content.
*/
private function generate_social_link_content( $block, $parent_block_attrs ) {
$service_name = $block['attrs']['service'] ?? '';
$service_url = $block['attrs']['url'] ?? '';
$label = $block['attrs']['label'] ?? '';
if ( empty( $service_name ) || empty( $service_url ) ) {
return '';
}
/**
* Prepend emails with `mailto:` if not set.
* The `is_email` returns false for emails with schema.
*/
if ( is_email( $service_url ) ) {
$service_url = 'mailto:' . antispambot( $service_url );
}
/**
* Prepend URL with https:// if it doesn't appear to contain a scheme
* and it's not a relative link or a fragment.
*/
if ( ! wp_parse_url( $service_url, PHP_URL_SCHEME ) && ! str_starts_with( $service_url, '//' ) && ! str_starts_with( $service_url, '#' ) ) {
$service_url = 'https://' . $service_url;
}
$open_in_new_tab = $parent_block_attrs['openInNewTab'] ?? false;
$show_labels = $parent_block_attrs['showLabels'] ?? false;
$size = $parent_block_attrs['size'] ?? Social_Links_Helper::get_default_social_link_size();
$service_brand_color = Social_Links_Helper::get_service_brand_color( $service_name );
$icon_color_value = $parent_block_attrs['iconColorValue'] ?? '#ffffff'; // use white as default icon color.
$icon_background_color_value = $parent_block_attrs['iconBackgroundColorValue'] ?? '';
$is_logos_only = strpos( $parent_block_attrs['className'] ?? '', 'is-style-logos-only' ) !== false;
$is_pill_shape = strpos( $parent_block_attrs['className'] ?? '', 'is-style-pill-shape' ) !== false;
if ( ! $is_logos_only && Social_Links_Helper::detect_whiteish_color( $icon_color_value ) && ( Social_Links_Helper::detect_whiteish_color( $icon_background_color_value ) || empty( $icon_background_color_value ) ) ) {
// If the icon color is white and the background color is white or empty, use the service brand color for the icon background color.
$icon_background_color_value = ! empty( $service_brand_color ) ? $service_brand_color : '#000';
}
if ( $is_logos_only ) {
// logos only mode does not need background color. We also don't really need the icon color (we can't change png image color anyways).
// We set it so that the label text color will reflect the service brand color.
$icon_color_value = ! empty( $service_brand_color ) ? $service_brand_color : '#000';
}
$icon_size = Social_Links_Helper::get_social_link_size_option_value( $size );
$service_icon_url = $this->get_service_icon_url( $service_name, $is_logos_only ? 'brand' : 'white' );
$service_label = '';
if ( $show_labels ) {
$text = ! empty( $label ) ? trim( $label ) : '';
$service_label = $text ? $text : block_core_social_link_get_name( $service_name );
}
$main_table_styles = $this->compile_css(
array(
'background-color' => $icon_background_color_value,
'border-radius' => '9999px',
'display' => 'inline-table',
'float' => 'none',
)
);
// divide the icon value by 2 to get the font size.
$font_size_value = (int) rtrim( $icon_size, 'px' );
$font_size = ( $font_size_value / 2 ) + 1; // inline with core styles.
$text_font_size = "{$font_size}px";
$anchor_styles = $this->compile_css(
array(
'color' => $icon_color_value,
'text-decoration' => 'none',
'text-transform' => 'none',
'font-size' => $text_font_size,
)
);
$anchor_html = sprintf( ' style="%s" ', esc_attr( $anchor_styles ) );
if ( $open_in_new_tab ) {
$anchor_html .= ' rel="noopener nofollow" target="_blank" ';
}
$row_container_styles = array(
'display' => 'block',
'padding' => '0.25em',
);
if ( $is_pill_shape ) {
$row_container_styles['padding-left'] = '17px';
$row_container_styles['padding-right'] = '17px';
}
$row_container_styles = $this->compile_css( $row_container_styles );
// Generate the icon content.
$icon_content = sprintf(
'<a href="%1$s" %2$s class="wp-block-social-link-anchor">
<img height="%3$s" src="%4$s" style="display:block;margin-right:0;" width="%3$s" alt="%5$s">
</a>',
esc_url( $service_url ),
$anchor_html,
esc_attr( $icon_size ),
esc_url( $service_icon_url ),
// translators: %s is the social service name.
sprintf( __( '%s icon', 'woocommerce' ), $service_name )
);
$icon_content = Table_Wrapper_Helper::render_table_wrapper( $icon_content, array(), array( 'style' => 'vertical-align:middle;' ) );
$icon_content = Table_Wrapper_Helper::render_table_cell( $icon_content, array( 'style' => sprintf( 'vertical-align:middle;font-size:%s;', $text_font_size ) ) );
// Generate the label content if needed.
$label_content = '';
if ( $service_label ) {
$label_content = sprintf(
'<a href="%1$s" %2$s class="wp-block-social-link-anchor">
<span style="margin-left:.5em;margin-right:.5em"> %3$s </span>
</a>',
esc_url( $service_url ),
$anchor_html,
esc_html( $service_label )
);
$label_cell_style = sprintf(
'vertical-align:middle;padding-left:6px;padding-right:6px;font-size:%s;',
$text_font_size
);
$label_content = Table_Wrapper_Helper::render_table_cell( $label_content, array( 'style' => $label_cell_style ) );
}
// Combine icon and label tables.
$social_link_content = $icon_content . $label_content;
// Create the main social link table.
$main_table_attrs = array(
'align' => 'center',
'style' => $main_table_styles,
);
$main_row_attrs = array(
'style' => $row_container_styles,
);
$main_table = Table_Wrapper_Helper::render_table_wrapper( $social_link_content, $main_table_attrs, array(), $main_row_attrs, false );
return Table_Wrapper_Helper::render_outlook_table_cell( $main_table );
}
/**
* Gets the block wrapper.
*
* @param string $block_content The block content.
* @param array $parsed_block The parsed block.
* @return string The block wrapper HTML.
*/
private function get_block_wrapper( $block_content, $parsed_block ) {
$content = $this->adjust_block_content( $block_content, $parsed_block );
$table_styles = $content['table_styles'];
$classes = $content['classes'];
$compiled_styles = $content['compiled_styles'];
$align = $content['align'];
$table_attrs = array(
'class' => 'wp-block-social-links',
'style' => $table_styles . ' vertical-align:top;',
'width' => '100%',
);
$cell_attrs = array(
'class' => $classes,
'style' => $compiled_styles,
'align' => $align,
);
$row_attrs = array(
'role' => 'presentation',
);
$inner_content = Table_Wrapper_Helper::render_outlook_table_wrapper( '{social_links_content}', array( 'align' => 'center' ), array(), array(), false );
$main_wrapper = Table_Wrapper_Helper::render_table_wrapper( $inner_content, $table_attrs, $cell_attrs, $row_attrs );
return Table_Wrapper_Helper::render_outlook_table_wrapper( $main_wrapper, array( 'align' => 'center' ) );
}
/**
* Adjusts the block content.
* Returns css classes and styles compatible with email clients.
*
* @param string $block_content The block content.
* @param array $parsed_block The parsed block.
* @return array The adjusted block content.
*/
private function adjust_block_content( $block_content, $parsed_block ) {
$block_content = $this->adjust_style_attribute( $block_content );
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'textAlign' => 'left',
'style' => array(),
)
);
$html = new \WP_HTML_Tag_Processor( $block_content );
$classes = 'wp-block-social-links';
if ( $html->next_tag() ) {
/** @var string $block_classes */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$block_classes = $html->get_attribute( 'class' ) ?? '';
$classes .= ' ' . $block_classes;
// remove has-background to prevent double padding applied for wrapper and inner element.
$block_classes = str_replace( 'has-background', '', $block_classes );
// remove border related classes because we handle border on wrapping table cell.
$block_classes = preg_replace( '/[a-z-]+-border-[a-z-]+/', '', $block_classes );
/** @var string $block_classes */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$html->set_attribute( 'class', trim( $block_classes ) );
$block_content = $html->get_updated_html();
}
$block_styles = $this->get_styles_from_block(
array(
'color' => $block_attributes['style']['color'] ?? array(),
'spacing' => $block_attributes['style']['spacing'] ?? array(),
'typography' => $block_attributes['style']['typography'] ?? array(),
'border' => $block_attributes['style']['border'] ?? array(),
)
);
$styles = array(
'min-width' => '100%', // prevent Gmail App from shrinking the table on mobile devices.
'vertical-align' => 'middle',
'word-break' => 'break-word',
);
$styles['text-align'] = 'left';
if ( ! empty( $parsed_block['attrs']['textAlign'] ) ) { // in this case, textAlign needs to be one of 'left', 'center', 'right'.
$styles['text-align'] = $parsed_block['attrs']['textAlign'];
} elseif ( in_array( $parsed_block['attrs']['align'] ?? null, array( 'left', 'center', 'right' ), true ) ) {
$styles['text-align'] = $parsed_block['attrs']['align'];
}
$compiled_styles = $this->compile_css( $block_styles['declarations'], $styles );
$table_styles = 'border-collapse: separate;'; // Needed because of border radius.
return array(
'table_styles' => $table_styles,
'classes' => $classes,
'compiled_styles' => $compiled_styles,
'align' => $styles['text-align'],
'block_content' => $block_content,
);
}
/**
* 1) We need to remove padding because we render padding on wrapping table cell
* 2) We also need to replace font-size to avoid clamp() because clamp() is not supported in many email clients.
* The font size values is automatically converted to clamp() when WP site theme is configured to use fluid layouts.
* Currently (WP 6.5), there is no way to disable this behavior.
*
* @param string $block_content Block content.
*/
private function adjust_style_attribute( string $block_content ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
if ( $html->next_tag() ) {
$element_style_value = $html->get_attribute( 'style' );
$element_style = isset( $element_style_value ) ? strval( $element_style_value ) : '';
// Padding may contain value like 10px or variable like var(--spacing-10).
$element_style = preg_replace( '/padding[^:]*:.?[0-9a-z-()]+;?/', '', $element_style );
// Remove border styles. We apply border styles on the wrapping table cell.
$element_style = preg_replace( '/border[^:]*:.?[0-9a-z-()#]+;?/', '', strval( $element_style ) );
// We define the font-size on the wrapper element, but we need to keep font-size definition here
// to prevent CSS Inliner from adding a default value and overriding the value set by user, which is on the wrapper element.
// The value provided by WP uses clamp() function which is not supported in many email clients.
$element_style = preg_replace( '/font-size:[^;]+;?/', 'font-size: inherit;', strval( $element_style ) );
/** @var string $element_style */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$html->set_attribute( 'style', esc_attr( $element_style ) );
$block_content = $html->get_updated_html();
}
return $block_content;
}
/**
* Gets the service icon URL.
*
* Default image type is 'white'.
*
* @param string $service The service name.
* @param string $image_type The image type. e.g 'white', 'brand'.
* @return string The service icon URL.
*/
public function get_service_icon_url( $service, $image_type = '' ) {
$image_type = empty( $image_type ) ? 'white' : $image_type;
$service = empty( $service ) ? '' : strtolower( $service );
if ( empty( $this->core_social_link_services_cache ) ) {
$services = block_core_social_link_services();
$this->core_social_link_services_cache = is_array( $services ) ? $services : array();
}
if ( ! isset( $this->core_social_link_services_cache[ $service ] ) ) {
// not in the list of core services.
return '';
}
if ( ! in_array( $image_type, $this->supported_image_types, true ) ) {
return '';
}
// Get URL to icons/service.png.
$service_icon_url = $this->get_service_png_url( $service, $image_type );
if ( $service_icon_url && ! file_exists( $this->get_service_png_path( $service, $image_type ) ) ) {
// The image file does not exist.
return '';
}
return $service_icon_url;
}
/**
* Gets the service PNG URL.
*
* @param string $service The service name.
* @param string $image_type The image type. e.g 'white', 'brand'.
* @return string The service PNG URL.
*/
public function get_service_png_url( $service, $image_type = 'white' ) {
if ( empty( $service ) ) {
return '';
}
$image_type = empty( $image_type ) ? 'white' : $image_type;
$file_name = "/icons/{$service}/{$service}-{$image_type}.png";
return plugins_url( $file_name, __FILE__ );
}
/**
* Gets the service PNG path.
*
* @param string $service The service name.
* @param string $image_type The image type. e.g 'white', 'brand'.
* @return string The service PNG path.
*/
public function get_service_png_path( $service, $image_type = 'white' ) {
if ( empty( $service ) ) {
return '';
}
$image_type = empty( $image_type ) ? 'white' : $image_type;
$file_name = "/icons/{$service}/{$service}-{$image_type}.png";
return __DIR__ . $file_name;
}
}

View File

@@ -0,0 +1,575 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
/**
* Renders a table block.
*/
class Table extends Abstract_Block_Renderer {
/**
* Valid text alignment values.
*/
private const VALID_TEXT_ALIGNMENTS = array( 'left', 'center', 'right' );
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// Extract table content and caption from figure wrapper if present.
$extracted_data = $this->extract_table_and_caption_from_figure( $block_content );
$table_content = $extracted_data['table'];
$caption = $extracted_data['caption'];
// Validate that we have actual table content.
if ( ! $this->is_valid_table_content( $table_content ) ) {
return '';
}
// Check for empty table structures - tables with no th or td elements.
if ( ! preg_match( '/<(th|td)/i', $table_content ) ) {
return '';
}
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'textAlign' => 'left',
'style' => array(),
)
);
$html = new \WP_HTML_Tag_Processor( $table_content );
$classes = 'email-table-block';
if ( $html->next_tag() ) {
$block_classes = (string) ( $html->get_attribute( 'class' ) ?? '' );
$classes .= ' ' . $block_classes;
// Clean classes for table element.
$block_classes = Html_Processing_Helper::clean_css_classes( $block_classes );
$html->set_attribute( 'class', $block_classes );
$table_content = $html->get_updated_html();
}
// Clean wrapper classes.
$classes = Html_Processing_Helper::clean_css_classes( $classes );
// Get spacing styles for wrapper and table-specific styles separately.
$spacing_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'spacing' ) );
$table_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'background-color', 'color', 'typography' ) );
// Ensure background styles are completely removed from spacing styles and force transparent background.
$spacing_css = $spacing_styles['css'] ?? '';
$spacing_css = (string) ( preg_replace( '/background[^;]*;?/', '', $spacing_css ) ?? '' );
$spacing_css = (string) ( preg_replace( '/\s*;\s*;/', ';', $spacing_css ) ?? '' ); // Clean up double semicolons.
$spacing_css = trim( $spacing_css, '; ' );
// Force transparent background on wrapper to prevent any background leakage.
$spacing_styles['css'] = $spacing_css ? $spacing_css . '; background: transparent !important;' : 'background: transparent !important;';
$additional_styles = array(
'min-width' => '100%', // Prevent Gmail App from shrinking the table on mobile devices.
);
// Add fallback text color when no custom text color or preset text color is set.
if ( empty( $table_styles['declarations']['color'] ) ) {
$email_styles = $rendering_context->get_theme_styles();
$color = $parsed_block['email_attrs']['color'] ?? $email_styles['color']['text'] ?? '#000000';
// Sanitize color value to ensure it's a valid hex color.
$additional_styles['color'] = Html_Processing_Helper::sanitize_color( $color );
}
$additional_styles['text-align'] = 'left';
if ( ! empty( $parsed_block['attrs']['textAlign'] ) ) { // In this case, textAlign needs to be one of 'left', 'center', 'right'.
$text_align = $parsed_block['attrs']['textAlign'];
if ( in_array( $text_align, self::VALID_TEXT_ALIGNMENTS, true ) ) {
$additional_styles['text-align'] = $text_align;
}
} elseif ( in_array( $parsed_block['attrs']['align'] ?? null, self::VALID_TEXT_ALIGNMENTS, true ) ) {
$additional_styles['text-align'] = $parsed_block['attrs']['align'];
}
$table_styles = Styles_Helper::extend_block_styles( $table_styles, $additional_styles );
// Check if this is a striped table style.
$is_striped_table = $this->is_striped_table( $block_content, $parsed_block );
// Process the table content to ensure email compatibility BEFORE wrapping.
$table_content = $this->process_table_content( $table_content, $parsed_block, $rendering_context, $is_striped_table );
// Apply table-specific styles (background, color, typography) directly to the table element.
$table_content_with_styles = $this->apply_styles_to_table_element( $table_content, $table_styles['css'] );
// Add wp-block-table class to the table element for theme.json CSS rules.
if ( false !== strpos( $block_content, 'wp-block-table' ) ) {
$table_content_with_styles = $this->add_class_to_table_element( $table_content_with_styles, 'wp-block-table' );
}
// Build complete content (table + caption).
$complete_content = $table_content_with_styles;
if ( ! empty( $caption ) ) {
// Use HTML API to safely allow specific tags in caption.
$sanitized_caption = Html_Processing_Helper::sanitize_caption_html( $caption );
// Extract typography styles from table styles (not spacing styles) and apply to caption.
$caption_styles = $this->extract_typography_styles_for_caption( $table_styles['css'] );
$complete_content .= '<div style="text-align: center; margin-top: 8px; ' . $caption_styles . '">' . $sanitized_caption . '</div>';
}
$table_attrs = array(
'style' => 'border-collapse: separate;', // Needed because of border radius.
'width' => '100%',
);
// Use spacing styles only for the wrapper.
$cell_attrs = array(
'class' => $classes,
'style' => $spacing_styles['css'],
'align' => $additional_styles['text-align'],
);
$rendered_table = Table_Wrapper_Helper::render_table_wrapper( $complete_content, $table_attrs, $cell_attrs );
return $rendered_table;
}
/**
* Process table content to ensure email client compatibility.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @param bool $is_striped_table Whether this is a striped table.
* @return string
*/
private function process_table_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context, bool $is_striped_table = false ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
// Extract custom border color and width from block attributes.
$custom_border_color = $this->get_custom_border_color( $parsed_block, $rendering_context );
$custom_border_width = $this->get_custom_border_width( $parsed_block );
// Use custom border color if available, otherwise fall back to default.
if ( $custom_border_color ) {
$border_color = $custom_border_color;
} else {
// Get theme styles once to avoid repeated calls.
$email_styles = $rendering_context->get_theme_styles();
$border_color = Html_Processing_Helper::sanitize_color( $parsed_block['email_attrs']['color'] ?? $email_styles['color']['text'] ?? '#000000' );
}
// Track row context for striped styling.
$current_section = ''; // Table sections: thead, tbody, tfoot.
$row_count = 0;
// Process table elements.
while ( $html->next_tag() ) {
$tag_name = $html->get_tag();
if ( 'TABLE' === $tag_name ) {
// Ensure table has proper email attributes.
$html->set_attribute( 'border', '1' );
$html->set_attribute( 'cellpadding', '8' );
$html->set_attribute( 'cellspacing', '0' );
$html->set_attribute( 'role', 'presentation' );
$html->set_attribute( 'width', '100%' );
// Get existing style and add email-specific styles.
$existing_style = (string) ( $html->get_attribute( 'style' ) ?? '' );
// Check for fixed layout class and apply table-layout: fixed.
$class_attr = (string) ( $html->get_attribute( 'class' ) ?? '' );
$table_layout = $this->has_fixed_layout( $class_attr ) ? 'table-layout: fixed; ' : '';
// Use border-collapse: collapse to ensure consistent borders between table and cells.
$email_table_styles = "{$table_layout}border-collapse: collapse; width: 100%;";
$existing_style = rtrim( $existing_style, "; \t\n\r\0\x0B" );
$new_style = $existing_style ? $existing_style . '; ' . $email_table_styles : $email_table_styles;
$html->set_attribute( 'style', $new_style );
// Remove problematic classes from the table but keep has-fixed-layout and alignment classes for editor UI.
$class_attr = Html_Processing_Helper::clean_css_classes( $class_attr );
$html->set_attribute( 'class', $class_attr );
} elseif ( 'THEAD' === $tag_name ) {
$current_section = 'thead';
$row_count = 0;
} elseif ( 'TBODY' === $tag_name ) {
$current_section = 'tbody';
$row_count = 0;
} elseif ( 'TFOOT' === $tag_name ) {
$current_section = 'tfoot';
$row_count = 0;
} elseif ( 'TR' === $tag_name ) {
++$row_count;
} elseif ( 'TD' === $tag_name || 'TH' === $tag_name ) {
// Ensure table cells have proper email attributes with borders and padding.
$html->set_attribute( 'valign', 'top' );
// Get existing style and add email-specific styles with borders and padding.
$existing_style = (string) ( $html->get_attribute( 'style' ) ?? '' );
$existing_style = rtrim( $existing_style, "; \t\n\r\0\x0B" );
$border_width = $custom_border_width ? $custom_border_width : '1px';
$border_style = $this->get_custom_border_style( $parsed_block );
// Extract cell-specific text alignment.
$cell_text_align = $this->get_cell_text_alignment( $html );
$email_cell_styles = "vertical-align: top; border: {$border_width} {$border_style} {$border_color}; padding: 8px; text-align: {$cell_text_align};";
// Add thicker borders for header and footer cells when no custom border is set.
$email_cell_styles = $this->add_header_footer_borders( $html, $email_cell_styles, $border_color, $current_section, $custom_border_width );
// Add striped styling for tbody rows (first row gets background, then alternates).
if ( $is_striped_table && 'tbody' === $current_section && 1 === $row_count % 2 ) {
$email_cell_styles .= ' background-color: #f8f9fa;';
}
$new_cell_style = $existing_style ? $existing_style . '; ' . $email_cell_styles : $email_cell_styles;
$html->set_attribute( 'style', $new_cell_style );
}
}
return $html->get_updated_html();
}
/**
* Get custom border color from block attributes.
*
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string|null Custom border color or null if not set.
*/
private function get_custom_border_color( array $parsed_block, Rendering_Context $rendering_context ): ?string {
$block_attributes = $parsed_block['attrs'] ?? array();
if ( ! empty( $block_attributes['borderColor'] ) ) {
$border_color = $rendering_context->translate_slug_to_color( $block_attributes['borderColor'] );
return Html_Processing_Helper::sanitize_color( $border_color );
}
return null;
}
/**
* Get custom border width from block attributes.
*
* @param array $parsed_block Parsed block.
* @return string|null Custom border width or null if not set.
*/
private function get_custom_border_width( array $parsed_block ): ?string {
$block_attributes = $parsed_block['attrs'] ?? array();
if ( ! empty( $block_attributes['style']['border']['width'] ) ) {
$border_width = $block_attributes['style']['border']['width'];
// Sanitize the border width value.
$border_width = Html_Processing_Helper::sanitize_css_value( $border_width );
if ( empty( $border_width ) ) {
return null;
}
// Ensure the border width has a unit, default to px if not specified.
if ( is_numeric( $border_width ) ) {
return $border_width . 'px';
}
// Validate that the border width contains only valid CSS units and numbers.
if ( preg_match( '/^[0-9]+\.?[0-9]*(px|em|rem|pt|pc|in|cm|mm|ex|ch|vw|vh|vmin|vmax)$/', $border_width ) ) {
return $border_width;
}
// If invalid, return null to use default.
return null;
}
return null;
}
/**
* Get custom border style from block attributes.
*
* @param array $parsed_block Parsed block.
* @return string Custom border style or 'solid' as default.
*/
private function get_custom_border_style( array $parsed_block ): string {
$style = strtolower( (string) ( $parsed_block['attrs']['style']['border']['style'] ?? '' ) );
$allowed = array( 'solid', 'dashed', 'dotted' ); // Email-safe subset.
return in_array( $style, $allowed, true ) ? $style : 'solid';
}
/**
* Add thicker borders for table headers and footers when no custom border is set.
*
* @param \WP_HTML_Tag_Processor $html HTML tag processor.
* @param string $base_styles Base cell styles.
* @param string $border_color Border color.
* @param string $current_section Current table section (thead, tbody, tfoot).
* @param string|null $custom_border_width Custom border width if set.
* @return string Updated cell styles.
*/
private function add_header_footer_borders( \WP_HTML_Tag_Processor $html, string $base_styles, string $border_color, string $current_section = '', ?string $custom_border_width = null ): string {
$tag_name = $html->get_tag();
// Only add thicker borders if no custom border width is set.
if ( $custom_border_width ) {
return $base_styles;
}
// Add thicker bottom border to all TH elements (headers).
if ( 'TH' === $tag_name ) {
$base_styles .= " border-bottom: 3px solid {$border_color};";
}
// Add thicker top border to footer cells (TD elements in tfoot).
if ( 'TD' === $tag_name && 'tfoot' === $current_section ) {
$base_styles .= " border-top: 3px solid {$border_color};";
}
return $base_styles;
}
/**
* Get text alignment for a table cell.
*
* @param \WP_HTML_Tag_Processor $html HTML tag processor.
* @return string Text alignment value (left, center, right).
*/
private function get_cell_text_alignment( \WP_HTML_Tag_Processor $html ): string {
// Check for data-align attribute first.
$data_align = $html->get_attribute( 'data-align' );
if ( $data_align && in_array( $data_align, self::VALID_TEXT_ALIGNMENTS, true ) ) {
return $data_align;
}
// Check for has-text-align-* classes.
$class_attr = (string) ( $html->get_attribute( 'class' ) ?? '' );
if ( false !== strpos( $class_attr, 'has-text-align-center' ) ) {
return 'center';
}
if ( false !== strpos( $class_attr, 'has-text-align-right' ) ) {
return 'right';
}
if ( false !== strpos( $class_attr, 'has-text-align-left' ) ) {
return 'left';
}
// Default to left alignment.
return 'left';
}
/**
* Check if table has fixed layout class.
*
* @param string $class_attr Class attribute string.
* @return bool True if has-fixed-layout class is present.
*/
private function has_fixed_layout( string $class_attr ): bool {
return false !== strpos( $class_attr, 'has-fixed-layout' );
}
/**
* Extract table content and caption from figure wrapper if present.
*
* @param string $block_content Block content.
* @return array Array with 'table' and 'caption' keys.
*/
private function extract_table_and_caption_from_figure( string $block_content ): array {
$dom_helper = new Dom_Document_Helper( $block_content );
// Look for figure element with wp-block-table class.
$figure_tag = $dom_helper->find_element( 'figure' );
if ( ! $figure_tag ) {
// If no figure wrapper found, return original content as table.
return array(
'table' => $block_content,
'caption' => '',
);
}
$figure_class_attr = $dom_helper->get_attribute_value( $figure_tag, 'class' );
$figure_class = (string) ( $figure_class_attr ? $figure_class_attr : '' );
if ( false === strpos( $figure_class, 'wp-block-table' ) ) {
// If figure doesn't have wp-block-table class, return original content as table.
return array(
'table' => $block_content,
'caption' => '',
);
}
// Extract table element from within the matched figure only.
$figure_html = $dom_helper->get_outer_html( $figure_tag );
// Use regex to extract table from within the figure to avoid document conflicts.
if ( ! preg_match( '/<table[^>]*>.*?<\/table>/is', $figure_html, $table_matches ) ) {
return array(
'table' => $block_content,
'caption' => '',
);
}
$table_html = $table_matches[0];
// Extract figcaption if present (scoped to the figure).
$caption = '';
if ( preg_match( '/<figcaption[^>]*>(.*?)<\/figcaption>/is', $figure_html, $figcaption_matches ) ) {
$caption = $figcaption_matches[1];
}
return array(
'table' => $table_html,
'caption' => $caption,
);
}
/**
* Apply CSS styles directly to the table element.
*
* @param string $table_content Table HTML content.
* @param string $styles CSS styles to apply.
* @return string Table content with styles applied.
*/
private function apply_styles_to_table_element( string $table_content, string $styles ): string {
$html = new \WP_HTML_Tag_Processor( $table_content );
if ( $html->next_tag( array( 'tag_name' => 'TABLE' ) ) ) {
$existing_style = (string) ( $html->get_attribute( 'style' ) ?? '' );
$existing_style = rtrim( $existing_style, "; \t\n\r\0\x0B" );
// Add default border widths if individual border colors are present but no widths.
$border_width_styles = $this->get_default_border_widths( $existing_style );
$new_style = $existing_style;
if ( ! empty( $border_width_styles ) ) {
$new_style = $new_style ? $new_style . '; ' . $border_width_styles : $border_width_styles;
}
if ( ! empty( $styles ) ) {
$new_style = $new_style ? $new_style . '; ' . $styles : $styles;
}
$html->set_attribute( 'style', $new_style );
return $html->get_updated_html();
}
return $table_content;
}
/**
* Get default border widths for table element when individual border colors are present.
*
* @param string $existing_style Existing style attribute of the table element.
* @return string CSS border width styles or empty string if not needed.
*/
private function get_default_border_widths( string $existing_style ): string {
// Check if individual border colors are present but no corresponding widths.
$sides = array( 'top', 'right', 'bottom', 'left' );
$border_width_styles = array();
foreach ( $sides as $side ) {
$has_color = strpos( $existing_style, "border-{$side}-color:" ) !== false;
$has_width = strpos( $existing_style, "border-{$side}-width:" ) !== false;
// If border color is present but no width, add default width.
if ( $has_color && ! $has_width ) {
$border_width_styles[] = "border-{$side}-width: 1.5px";
}
}
return implode( '; ', $border_width_styles );
}
/**
* Add a CSS class to the table element.
*
* @param string $table_content Table HTML content.
* @param string $class_name CSS class to add.
* @return string Table content with class added.
*/
private function add_class_to_table_element( string $table_content, string $class_name ): string {
// Validate class name to prevent XSS.
if ( ! preg_match( '/^[a-zA-Z0-9\-_]+$/', $class_name ) ) {
return $table_content;
}
$html = new \WP_HTML_Tag_Processor( $table_content );
if ( $html->next_tag( array( 'tag_name' => 'TABLE' ) ) ) {
$existing_class = (string) ( $html->get_attribute( 'class' ) ?? '' );
$existing_class = trim( $existing_class );
// Only add if not already present.
if ( false === strpos( $existing_class, $class_name ) ) {
$new_class = $existing_class ? $existing_class . ' ' . $class_name : $class_name;
$html->set_attribute( 'class', $new_class );
}
return $html->get_updated_html();
}
return $table_content;
}
/**
* Extract typography styles from CSS string for caption.
*
* @param string $css CSS string to extract typography from.
* @return string Typography CSS for caption.
*/
private function extract_typography_styles_for_caption( string $css ): string {
$typography_properties = Html_Processing_Helper::get_caption_css_properties();
$caption_styles = array();
foreach ( $typography_properties as $property ) {
// Use regex to extract each typography property.
if ( preg_match( '/' . preg_quote( $property, '/' ) . '\s*:\s*([^;]+)/i', $css, $matches ) ) {
$value = trim( $matches[1] );
// Sanitize the CSS value to prevent injection.
$sanitized_value = Html_Processing_Helper::sanitize_css_value( $value );
if ( ! empty( $sanitized_value ) ) {
$caption_styles[] = $property . ': ' . $sanitized_value;
}
}
}
return implode( '; ', $caption_styles );
}
/**
* Check if the table has striped styling.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @return bool True if it's a striped table, false otherwise.
*/
private function is_striped_table( string $block_content, array $parsed_block ): bool {
// Check for is-style-stripes in block attributes.
if ( isset( $parsed_block['attrs']['className'] ) && false !== strpos( $parsed_block['attrs']['className'], 'is-style-stripes' ) ) {
return true;
}
// Check for is-style-stripes in figure classes.
if ( false !== strpos( $block_content, 'is-style-stripes' ) ) {
return true;
}
return false;
}
/**
* Validate if the content is a valid table HTML.
*
* @param string $content The content to validate.
* @return bool True if it's a valid table, false otherwise.
*/
private function is_valid_table_content( string $content ): bool {
// Only assert that a <table> exists; downstream checks handle emptiness and KSES handles sanitization.
return (bool) preg_match( '/<table[^>]*>.*?<\/table>/is', $content );
}
}

View File

@@ -0,0 +1,135 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package.
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* This renderer covers both core/paragraph, core/heading and core/site-title blocks.
*/
class Text extends Abstract_Block_Renderer {
/**
* Renders the block content.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
// Do not render empty blocks.
if ( empty( trim( wp_strip_all_tags( $block_content ) ) ) ) {
return '';
}
$block_content = $this->adjustStyleAttribute( $block_content );
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'textAlign' => 'left',
'style' => array(),
)
);
$html = new \WP_HTML_Tag_Processor( $block_content );
$classes = 'email-text-block';
$alignment_from_class = null;
if ( $html->next_tag() ) {
/** @var string $block_classes */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$block_classes = $html->get_attribute( 'class' ) ?? '';
$classes .= ' ' . $block_classes;
// Extract text alignment from has-text-align-* classes before they're potentially modified.
$class_attr = (string) $block_classes;
if ( false !== strpos( $class_attr, 'has-text-align-center' ) ) {
$alignment_from_class = 'center';
} elseif ( false !== strpos( $class_attr, 'has-text-align-right' ) ) {
$alignment_from_class = 'right';
} elseif ( false !== strpos( $class_attr, 'has-text-align-left' ) ) {
$alignment_from_class = 'left';
}
// remove has-background to prevent double padding applied for wrapper and inner element.
$block_classes = str_replace( 'has-background', '', $block_classes );
// remove border related classes because we handle border on wrapping table cell.
$block_classes = preg_replace( '/[a-z-]+-border-[a-z-]+/', '', $block_classes );
/** @var string $block_classes */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$html->set_attribute( 'class', trim( $block_classes ) );
$block_content = $html->get_updated_html();
}
$block_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'spacing', 'border', 'background-color', 'color', 'typography' ) );
$additional_styles = array(
'min-width' => '100%', // prevent Gmail App from shrinking the table on mobile devices.
);
// Add fallback text color when no custom text color or preset text color is set.
if ( empty( $block_styles['declarations']['color'] ) ) {
$email_styles = $rendering_context->get_theme_styles();
$additional_styles['color'] = $parsed_block['email_attrs']['color'] ?? $email_styles['color']['text'] ?? '#000000'; // Fallback for the text color.
}
$additional_styles['text-align'] = 'left';
if ( ! empty( $parsed_block['attrs']['textAlign'] ) ) { // in this case, textAlign needs to be one of 'left', 'center', 'right'.
$additional_styles['text-align'] = $parsed_block['attrs']['textAlign'];
} elseif ( in_array( $parsed_block['attrs']['align'] ?? null, array( 'left', 'center', 'right' ), true ) ) {
$additional_styles['text-align'] = $parsed_block['attrs']['align'];
} elseif ( null !== $alignment_from_class ) {
$additional_styles['text-align'] = $alignment_from_class;
}
$block_styles = Styles_Helper::extend_block_styles( $block_styles, $additional_styles );
$table_attrs = array(
'style' => 'border-collapse: separate;', // Needed because of border radius.
'width' => '100%',
);
$cell_attrs = array(
'class' => $classes,
'style' => $block_styles['css'],
'align' => $additional_styles['text-align'],
);
return Table_Wrapper_Helper::render_table_wrapper( $block_content, $table_attrs, $cell_attrs );
}
/**
* 1) We need to remove padding because we render padding on wrapping table cell
* 2) We also need to replace font-size to avoid clamp() because clamp() is not supported in many email clients.
* The font size values is automatically converted to clamp() when WP site theme is configured to use fluid layouts.
* Currently (WP 6.5), there is no way to disable this behavior.
*
* @param string $block_content Block content.
*/
private function adjustStyleAttribute( string $block_content ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
if ( $html->next_tag() ) {
$element_style_value = $html->get_attribute( 'style' );
$element_style = isset( $element_style_value ) ? strval( $element_style_value ) : '';
// Padding may contain value like 10px or variable like var(--spacing-10).
$element_style = preg_replace( '/padding[^:]*:.?[0-9a-z-()]+;?/', '', $element_style );
// Remove border styles. We apply border styles on the wrapping table cell.
$element_style = preg_replace( '/border[^:]*:.?[0-9a-z-()#]+;?/', '', strval( $element_style ) );
// We define the font-size on the wrapper element, but we need to keep font-size definition here
// to prevent CSS Inliner from adding a default value and overriding the value set by user, which is on the wrapper element.
// The value provided by WP uses clamp() function which is not supported in many email clients.
$element_style = preg_replace( '/font-size:[^;]+;?/', 'font-size: inherit;', strval( $element_style ) );
/** @var string $element_style */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
$html->set_attribute( 'style', esc_attr( $element_style ) );
$block_content = $html->get_updated_html();
}
return $block_content;
}
}

View File

@@ -0,0 +1,208 @@
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
/**
* Video block renderer.
* This renderer handles core/video blocks by reusing the cover block renderer
* to show a thumbnail with a play button overlay.
*/
class Video extends Cover {
/**
* Renders the video block content by transforming it into a cover block structure.
* Shows the video poster/thumbnail with a play button overlay using the parent cover renderer.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
$block_attrs = $parsed_block['attrs'] ?? array();
// Extract poster URL from video attributes.
$poster_url = $this->extract_poster_url( $block_attrs, $block_content );
// If no poster image, return empty content.
if ( empty( $poster_url ) ) {
return '';
}
// Transform video block into cover block structure and delegate to parent.
$cover_block = $this->transform_to_cover_block( $parsed_block, $poster_url );
return parent::render_content( $block_content, $cover_block, $rendering_context );
}
/**
* Extract poster URL from block attributes.
*
* @param array $block_attrs Block attributes.
* @param string $block_content Original block content (unused, kept for consistency).
* @return string Poster URL or empty string.
*/
private function extract_poster_url( array $block_attrs, string $block_content ): string {
// Check for poster attribute.
if ( ! empty( $block_attrs['poster'] ) ) {
return esc_url( $block_attrs['poster'] );
}
return '';
}
/**
* Extract video URL from block content.
*
* @param string $block_content Block content HTML.
* @return string Video URL or empty string.
*/
private function extract_video_url( string $block_content ): string {
// Use Dom_Document_Helper for robust HTML parsing.
$dom_helper = new Dom_Document_Helper( $block_content );
// Find the wp-block-embed__wrapper div.
$wrapper_element = $dom_helper->find_element( 'div' );
if ( ! $wrapper_element ) {
return '';
}
// Check if this div has the correct class.
$class_attr = $dom_helper->get_attribute_value( $wrapper_element, 'class' );
if ( strpos( $class_attr, 'wp-block-embed__wrapper' ) === false ) {
return '';
}
// Get the inner HTML content from the wrapper div.
$inner_html = $dom_helper->get_element_inner_html( $wrapper_element );
// Look for HTTP/HTTPS URLs in the inner HTML content.
if ( preg_match( '/(?<![a-zA-Z0-9.-])https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[a-zA-Z0-9\/?=&%-]*(?![a-zA-Z0-9.-])/', $inner_html, $matches ) ) {
$url = $matches[0];
// Decode HTML entities and validate URL.
$url = html_entity_decode( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
// Validate the URL.
if ( filter_var( $url, FILTER_VALIDATE_URL ) && wp_http_validate_url( $url ) ) {
return $url;
}
}
return '';
}
/**
* Transform a video block into a cover block structure.
*
* @param array $video_block Original video block.
* @param string $poster_url Poster URL to use as background.
* @return array Cover block structure.
*/
private function transform_to_cover_block( array $video_block, string $poster_url ): array {
$block_attrs = $video_block['attrs'] ?? array();
$block_content = $video_block['innerHTML'] ?? '';
// Extract video URL from block content, fall back to post URL.
// Priority: 1) Video URL (if found), 2) Post permalink (fallback).
$video_url = $this->extract_video_url( $block_content );
$link_url = ! empty( $video_url ) ? $video_url : $this->get_current_post_url();
return array(
'blockName' => 'core/cover',
'attrs' => array(
'url' => $poster_url,
'minHeight' => '390px', // Custom attribute for video blocks.
),
'innerBlocks' => array(
array(
'blockName' => 'core/html',
'attrs' => array(),
'innerBlocks' => array(),
'innerHTML' => $this->create_play_button_html( $link_url ),
'innerContent' => array( $this->create_play_button_html( $link_url ) ),
),
),
'innerHTML' => $block_content,
);
}
/**
* Create the play button HTML with optional link.
*
* @param string $link_url Optional URL to link to.
* @return string Play button HTML.
*/
private function create_play_button_html( string $link_url = '' ): string {
$play_icon_url = $this->get_play_icon_url();
$play_button = sprintf(
'<img src="%s" alt="%s" style="width: 48px; height: 48px; display: inline-block;" />',
esc_url( $play_icon_url ),
// translators: Alt text for video play button icon.
esc_attr( __( 'Play', 'woocommerce' ) )
);
// Wrap the play button in a link if URL is provided.
if ( ! empty( $link_url ) ) {
$play_button = sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer nofollow" style="display: inline-block; text-decoration: none;">%s</a>',
esc_url( $link_url ),
$play_button
);
}
return sprintf(
'<p style="text-align: center;">%s</p>',
$play_button
);
}
/**
* Get the URL for the play button icon.
*
* @return string Play button icon URL.
*/
private function get_play_icon_url(): string {
$file_name = '/icons/video/play2x.png';
return plugins_url( $file_name, __FILE__ );
}
/**
* Get the current post permalink with security validation.
*
* @return string Post permalink or empty string if invalid.
*/
private function get_current_post_url(): string {
global $post;
if ( ! $post instanceof \WP_Post ) {
return '';
}
$permalink = get_permalink( $post->ID );
if ( empty( $permalink ) ) {
return '';
}
// Validate URL type and format (following audio block pattern).
if ( strpos( $permalink, 'https://' ) !== 0 && strpos( $permalink, 'http://' ) !== 0 ) {
// Reject non-HTTP protocols for security.
return '';
}
// For all HTTP(S) URLs, validate with wp_http_validate_url.
if ( ! wp_http_validate_url( $permalink ) ) {
return '';
}
return $permalink;
}
}

Some files were not shown because too many files have changed in this diff Show More