244 lines
6.2 KiB
PHP
244 lines
6.2 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
function findJsonEnd(string $html, int $start): ?int {
|
|
$length = strlen($html);
|
|
$stack = [];
|
|
|
|
$inString = false;
|
|
$escaped = false;
|
|
|
|
for ($i = $start; $i < $length; $i++) {
|
|
$char = $html[$i];
|
|
|
|
if ($inString) {
|
|
if ($escaped) {
|
|
$escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '\\') {
|
|
$escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '"') {
|
|
$inString = false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($char === '"') {
|
|
$inString = true;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '{' || $char === '[') {
|
|
$stack[] = $char;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '}' || $char === ']') {
|
|
if (empty($stack)) {
|
|
return null;
|
|
}
|
|
|
|
$open = array_pop($stack);
|
|
if (($open === '{' && $char !== '}') || ($open === '[' && $char !== ']')) {
|
|
return null;
|
|
}
|
|
|
|
if (empty($stack)) {
|
|
return $i + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function repair(string $html): string {
|
|
$length = strlen($html);
|
|
$offset = 0;
|
|
$output = '';
|
|
$changed = false;
|
|
|
|
while ($offset < $length) {
|
|
$match = preg_match(
|
|
'/data-mage-init\s*=\s*"/i',
|
|
$html,
|
|
$matches,
|
|
PREG_OFFSET_CAPTURE,
|
|
$offset
|
|
);
|
|
|
|
if ($match !== 1) {
|
|
$output .= substr($html, $offset);
|
|
break;
|
|
}
|
|
|
|
$attributeOffset = $matches[0][1];
|
|
$attributeToken = $matches[0][0];
|
|
$attributeTokenLength = strlen($attributeToken);
|
|
|
|
// Only process data-mage-init when it appears to be an actual attribute boundary, not as part of another token.
|
|
// Example: foo-data-mage-init="..."
|
|
if ($attributeOffset === 0) {
|
|
$output .= substr($html, $offset, $attributeTokenLength);
|
|
$offset = $attributeTokenLength;
|
|
continue;
|
|
}
|
|
$previousCharacter = $html[$attributeOffset - 1];
|
|
if (!ctype_space($previousCharacter)) {
|
|
$output .= substr(
|
|
$html,
|
|
$offset,
|
|
($attributeOffset + $attributeTokenLength) - $offset
|
|
);
|
|
$offset = $attributeOffset + $attributeTokenLength;
|
|
continue;
|
|
}
|
|
|
|
// Copy everything before data-mage-init="...
|
|
$output .= substr(
|
|
$html,
|
|
$offset,
|
|
$attributeOffset - $offset
|
|
);
|
|
|
|
$jsonStart = $attributeOffset + $attributeTokenLength;
|
|
|
|
// The malformed value must begin with a JSON object or array.
|
|
if ($jsonStart >= $length || ($html[$jsonStart] !== '{' && $html[$jsonStart] !== '[')) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
$jsonEnd = findJsonEnd($html, $jsonStart);
|
|
|
|
// Could not safely identify a JSON value. Leave this occurrence untouched.
|
|
if ($jsonEnd === null || $jsonEnd >= $length) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
// The malformed attribute should end with a literal ".
|
|
//
|
|
// Example:
|
|
// data-mage-init="{"key":"value"}"
|
|
// ^
|
|
if ($html[$jsonEnd] !== '"') {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
// Ensure it's the end of the HTML attribute.
|
|
$afterJson = $jsonEnd + 1;
|
|
if ($afterJson < $length) {
|
|
$nextCharacter = $html[$afterJson];
|
|
if ($nextCharacter !== '>' && $nextCharacter !== '/' && !ctype_space($nextCharacter)) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Extract the JSON from $html
|
|
$json = substr(
|
|
$html,
|
|
$jsonStart,
|
|
$jsonEnd - $jsonStart
|
|
);
|
|
|
|
// Validate that this really is JSON.
|
|
try {
|
|
json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException $e) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
// JSON normally contains double quotes and no apostrophes, so single quotes are the safest HTML attribute delimiter.
|
|
if (strpos($json, "'") === false) {
|
|
$output .= 'data-mage-init=\'' . $json . '\'';
|
|
} else {
|
|
// The JSON contains a literal apostrophe, so use double quotes and HTML-escape its double quotes.
|
|
$escapedJson = htmlspecialchars(
|
|
$json,
|
|
ENT_COMPAT | ENT_SUBSTITUTE,
|
|
'UTF-8',
|
|
false
|
|
);
|
|
|
|
$output .= 'data-mage-init="' . $escapedJson . '"';
|
|
}
|
|
|
|
$changed = true;
|
|
|
|
// Consume the final quote of the malformed attribute.
|
|
$offset = $afterJson;
|
|
}
|
|
|
|
return $changed ? $output : $html;
|
|
}
|
|
|
|
$tests = [
|
|
[
|
|
'name' => 'Basic',
|
|
'input' => '<div data-mage-init="{"key":"value"}"></div>',
|
|
'expected' => '<div data-mage-init=\'{"key":"value"}\'></div>',
|
|
],
|
|
[
|
|
'name' => 'Magento JSON',
|
|
'input' => '<div data-mage-init="{"Magento_Ui/js/core/app":{"components":{"x":{"component":"foo"}}}}"></div>',
|
|
'expected' => '<div data-mage-init=\'{"Magento_Ui/js/core/app":{"components":{"x":{"component":"foo"}}}}\'></div>',
|
|
],
|
|
[
|
|
'name' => 'Nested JSON string',
|
|
'input' => '<div data-mage-init="{"config":{"label":"He said \\"hello\\""}}"></div>',
|
|
'expected' => '<div data-mage-init=\'{"config":{"label":"He said \\"hello\\""}}\'></div>',
|
|
],
|
|
[
|
|
'name' => 'JSON with apostrophe',
|
|
'input' => '<div data-mage-init="{"label":"Bob\'s product"}"></div>',
|
|
'expected' => '<div data-mage-init="{"label":"Bob\'s product"}"></div>',
|
|
],
|
|
[
|
|
'name' => 'Already valid libxml output',
|
|
'input' => '<div data-mage-init="{"key":"value"}"></div>',
|
|
'expected' => '<div data-mage-init="{"key":"value"}"></div>',
|
|
],
|
|
[
|
|
'name' => 'Other attribute',
|
|
'input' => '<div foo-data-mage-init="{"key":"value"}"></div>',
|
|
'expected' => '<div foo-data-mage-init="{"key":"value"}"></div>',
|
|
],
|
|
[
|
|
'name' => 'Multiple attributes',
|
|
'input' =>
|
|
'<div data-mage-init="{"one":"1"}"></div>' .
|
|
'<div data-mage-init="{"two":"2"}"></div>',
|
|
'expected' =>
|
|
'<div data-mage-init=\'{"one":"1"}\'></div>' .
|
|
'<div data-mage-init=\'{"two":"2"}\'></div>',
|
|
],
|
|
];
|
|
|
|
foreach ($tests as $test) {
|
|
$actual = repair($test['input']);
|
|
|
|
echo $test['name'] . ': '
|
|
. ($actual === $test['expected'] ? 'PASS' : 'FAIL')
|
|
. PHP_EOL;
|
|
|
|
if ($actual !== $test['expected']) {
|
|
echo "Expected: " . $test['expected'] . PHP_EOL;
|
|
echo "Actual: " . $actual . PHP_EOL;
|
|
}
|
|
}
|