Files

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="{&quot;label&quot;:&quot;Bob\'s product&quot;}"></div>',
],
[
'name' => 'Already valid libxml output',
'input' => '<div data-mage-init="{&quot;key&quot;:&quot;value&quot;}"></div>',
'expected' => '<div data-mage-init="{&quot;key&quot;:&quot;value&quot;}"></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;
}
}