| 1 |
<?php |
| 2 |
|
| 3 |
namespace Linguise\WordPress; |
| 4 |
|
| 5 |
defined('ABSPATH') || die(''); |
| 6 |
|
| 7 |
/** |
| 8 |
* A collection of HTML helper functions used by Linguise |
| 9 |
* |
| 10 |
* Mostly used to parse HTML content and protect HTML entities |
| 11 |
* for processing by the Fragment handler system |
| 12 |
*/ |
| 13 |
class HTMLHelper |
| 14 |
{ |
| 15 |
/** |
| 16 |
* Marker used to protect the HTML entities |
| 17 |
* |
| 18 |
* @var array |
| 19 |
*/ |
| 20 |
protected static $marker_entity = [ |
| 21 |
'common' => 'linguise-internal-entity', |
| 22 |
'named' => 'linguise-internal-entity1', |
| 23 |
'numeric' => 'linguise-internal-entity2', |
| 24 |
]; |
| 25 |
|
| 26 |
/** |
| 27 |
* Protect the HTML entities in the source code. |
| 28 |
* |
| 29 |
* Adapted from: https://github.com/ivopetkov/html5-dom-document-php/blob/master/src/HTML5DOMDocument.php |
| 30 |
* |
| 31 |
* @param string $source The source code to be protected |
| 32 |
* |
| 33 |
* @return string The protected source code |
| 34 |
*/ |
| 35 |
public static function protectEntity($source) |
| 36 |
{ |
| 37 |
// Replace the entity with our own |
| 38 |
$source = preg_replace('/&([a-zA-Z]*);/', self::$marker_entity['named'] . '-$1-end', $source); |
| 39 |
$source = preg_replace('/&#([0-9]*);/', self::$marker_entity['numeric'] . '-$1-end', $source); |
| 40 |
|
| 41 |
return $source; |
| 42 |
} |
| 43 |
|
| 44 |
/** |
| 45 |
* Unprotect the HTML entities in the source code. |
| 46 |
* |
| 47 |
* @param string $html The HTML code to be unprotected |
| 48 |
* |
| 49 |
* @return string The unprotected HTML code |
| 50 |
*/ |
| 51 |
public static function unprotectEntity($html) |
| 52 |
{ |
| 53 |
if (strpos($html, self::$marker_entity['common']) !== false) { |
| 54 |
$html = preg_replace('/' . self::$marker_entity['named'] . '-(.*?)-end/', '&$1;', $html); |
| 55 |
$html = preg_replace('/' . self::$marker_entity['numeric'] . '-(.*?)-end/', '&#$1;', $html); |
| 56 |
} |
| 57 |
|
| 58 |
return $html; |
| 59 |
} |
| 60 |
|
| 61 |
|
| 62 |
/** |
| 63 |
* Protect the HTML string before processing with DOMDocument. |
| 64 |
* |
| 65 |
* It does: |
| 66 |
* - Add CDATA around script tags content |
| 67 |
* - Preserve html entities |
| 68 |
* |
| 69 |
* Adapted from: https://github.com/ivopetkov/html5-dom-document-php/blob/master/src/HTML5DOMDocument.php |
| 70 |
* |
| 71 |
* @param string $source The HTML source code to be protected |
| 72 |
* |
| 73 |
* @return string The protected HTML source code |
| 74 |
*/ |
| 75 |
private static function protectHTML($source) |
| 76 |
{ |
| 77 |
// Add CDATA around script tags content |
| 78 |
$matches = null; |
| 79 |
preg_match_all('/<script(.*?)>/', $source, $matches); |
| 80 |
if (isset($matches[0])) { |
| 81 |
$matches[0] = array_unique($matches[0]); |
| 82 |
foreach ($matches[0] as $match) { |
| 83 |
if (substr($match, -2, 1) !== '/') { // check if ends with /> |
| 84 |
$source = str_replace($match, $match . '<![CDATA[-linguise-dom-internal-cdata', $source); // Add CDATA after the open tag |
| 85 |
} |
| 86 |
} |
| 87 |
} |
| 88 |
|
| 89 |
$source = str_replace('</script>', '-linguise-dom-internal-cdata]]></script>', $source); // Add CDATA before the end tag |
| 90 |
$source = str_replace('<![CDATA[-linguise-dom-internal-cdata-linguise-dom-internal-cdata]]>', '', $source); // Clean empty script tags |
| 91 |
$matches = null; |
| 92 |
preg_match_all('/\<!\[CDATA\[-linguise-dom-internal-cdata.*?-linguise-dom-internal-cdata\]\]>/s', $source, $matches); |
| 93 |
if (isset($matches[0])) { |
| 94 |
$matches[0] = array_unique($matches[0]); |
| 95 |
foreach ($matches[0] as $match) { |
| 96 |
if (strpos($match, '</') !== false) { // check if contains </ |
| 97 |
$source = str_replace($match, str_replace('</', '<-linguise-dom-internal-cdata-endtagfix/', $match), $source); |
| 98 |
} |
| 99 |
} |
| 100 |
} |
| 101 |
|
| 102 |
// Preserve html entities |
| 103 |
$source = self::protectEntity($source); |
| 104 |
|
| 105 |
return $source; |
| 106 |
} |
| 107 |
|
| 108 |
|
| 109 |
/** |
| 110 |
* Unclobber the CDATA internal |
| 111 |
* |
| 112 |
* NOTE: Only use this when processing a script content internal data not the whole HTML data. |
| 113 |
* |
| 114 |
* This is used to protect the CDATA internal data from being mangled by the DOMDocument. |
| 115 |
* |
| 116 |
* @param string $html_data The HTML data to be unclobbered |
| 117 |
* |
| 118 |
* @return string The unclobbered HTML data |
| 119 |
*/ |
| 120 |
public static function unclobberCdataInternal($html_data) |
| 121 |
{ |
| 122 |
// Unclobber the CDATA internal |
| 123 |
$html_data = str_replace('<![CDATA[-linguise-dom-internal-cdata', '', $html_data); |
| 124 |
$html_data = str_replace('-linguise-dom-internal-cdata]]>', '', $html_data); |
| 125 |
$html_data = str_replace('<-linguise-dom-internal-cdata-endtagfix/', '</', $html_data); |
| 126 |
|
| 127 |
return $html_data; |
| 128 |
} |
| 129 |
|
| 130 |
/** |
| 131 |
* Clobber back the CDATA internal |
| 132 |
* |
| 133 |
* NOTE: Only use this when processing a script content internal data not the whole HTML data. |
| 134 |
* |
| 135 |
* This is used to protect the CDATA internal data from being mangled by the DOMDocument. |
| 136 |
* |
| 137 |
* @param string $html_data The HTML data to be clobbered |
| 138 |
* |
| 139 |
* @return string The clobbered HTML data |
| 140 |
*/ |
| 141 |
public static function clobberCdataInternal($html_data) |
| 142 |
{ |
| 143 |
// Append prefix and suffix |
| 144 |
return '<![CDATA[-linguise-dom-internal-cdata' . $html_data . '-linguise-dom-internal-cdata]]>'; |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* Load the HTML data into a DOMDocument object. |
| 149 |
* |
| 150 |
* @param string $html_data The HTML data to be loaded |
| 151 |
* |
| 152 |
* @return \DOMDocument|null The loaded HTML DOM object |
| 153 |
*/ |
| 154 |
public static function loadHTML($html_data) |
| 155 |
{ |
| 156 |
// Check if DOMDocument is available or xml extension is loaded |
| 157 |
if (!class_exists('\\DOMDocument') || !extension_loaded('xml')) { |
| 158 |
// @codeCoverageIgnoreStart |
| 159 |
return null; |
| 160 |
// @codeCoverageIgnoreEnd |
| 161 |
} |
| 162 |
if (empty($html_data)) { |
| 163 |
return null; |
| 164 |
} |
| 165 |
|
| 166 |
// Load HTML |
| 167 |
$html_dom = new \DOMDocument(); |
| 168 |
@$html_dom->loadHTML(self::protectHTML($html_data), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 169 |
|
| 170 |
/** |
| 171 |
* Avoid mangling the CSS and JS code with encoded HTML entities |
| 172 |
* |
| 173 |
* See: https://www.php.net/manual/en/domdocument.savehtml.php#119813 |
| 174 |
* |
| 175 |
* While testing, we found this issue with css inner text got weirdly mangled |
| 176 |
* following the issue above, I manage to correct this by adding proper content-type equiv |
| 177 |
* which is really weird honestly but it manages to fix the issue. |
| 178 |
*/ |
| 179 |
$has_utf8 = false; |
| 180 |
$meta_attrs = $html_dom->getElementsByTagName('meta'); |
| 181 |
foreach ($meta_attrs as $meta) { |
| 182 |
if ($meta->hasAttribute('http-equiv') && strtolower($meta->getAttribute('http-equiv')) === 'content-type') { |
| 183 |
// force UTF-8s |
| 184 |
$meta->setAttribute('content', 'text/html; charset=UTF-8'); |
| 185 |
$has_utf8 = true; |
| 186 |
break; |
| 187 |
} |
| 188 |
} |
| 189 |
|
| 190 |
if (!$has_utf8) { |
| 191 |
// We didn't found any meta tag with content-type equiv, add our own |
| 192 |
$meta = $html_dom->createElement('meta'); |
| 193 |
$meta->setAttribute('http-equiv', 'Content-Type'); |
| 194 |
$meta->setAttribute('content', 'text/html; charset=UTF-8'); |
| 195 |
$head_doc = $html_dom->getElementsByTagName('head'); |
| 196 |
if ($head_doc->length > 0) { |
| 197 |
// Add to head tag on the child as the first node |
| 198 |
$head = $head_doc->item(0); |
| 199 |
@$head->insertBefore($meta, $head->firstChild); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- ignore any errors for now |
| 200 |
} |
| 201 |
} |
| 202 |
|
| 203 |
return $html_dom; |
| 204 |
} |
| 205 |
|
| 206 |
/** |
| 207 |
* Save the HTML data into a string. |
| 208 |
* |
| 209 |
* @param \DOMDocument $dom The DOMDocument object to be saved |
| 210 |
* |
| 211 |
* @return string The saved HTML data |
| 212 |
*/ |
| 213 |
public static function saveHTML($dom) |
| 214 |
{ |
| 215 |
// Save HTML |
| 216 |
$html_data = $dom->saveHTML(); |
| 217 |
if ($html_data === false) { |
| 218 |
// @codeCoverageIgnoreStart |
| 219 |
return ''; |
| 220 |
// @codeCoverageIgnoreEnd |
| 221 |
} |
| 222 |
|
| 223 |
// Unprotect HTML entities |
| 224 |
$html_data = self::unprotectEntity($html_data); |
| 225 |
|
| 226 |
// Unprotect HTML |
| 227 |
$code_to_be_removed = [ |
| 228 |
'linguise-dom-internal-content', |
| 229 |
'<![CDATA[-linguise-dom-internal-cdata', |
| 230 |
'-linguise-dom-internal-cdata]]>', |
| 231 |
'-linguise-dom-internal-cdata-endtagfix' |
| 232 |
]; |
| 233 |
foreach ($code_to_be_removed as $code) { |
| 234 |
$html_data = str_replace($code, '', $html_data); |
| 235 |
} |
| 236 |
|
| 237 |
// Unmangle stuff like &#xE5; |
| 238 |
$html_data = preg_replace('/&#x([0-9A-Fa-f]+);/', '&#x$1;', $html_data); |
| 239 |
|
| 240 |
return $html_data; |
| 241 |
} |
| 242 |
} |
| 243 |
|