$c['title'],
'existingRegions' => $c['existing'],
'proposed' => array_map(fn(int $i) => $c['els'][$i]['name'], $c['regions']),
'images' => array_map(fn(array $i) => ['src' => $i['src'], 'hasAlt' => $i['hasAlt'], 'inRegion' => $i['inRegion'], 'tagged' => $i['tagged']], $c['images']),
'refs' => $c['refs'],
'seo' => $c['seo'],
];
}
/**
* Insert the requested markers into a page.
*
* @param array $options Booleans: content, images, seo.
* @return array|null ['html' => string, 'changes' => string[]], or null when the page needs no change.
*/
public static function apply(string $html, string $editableClass, array $options): ?array
{
$c = self::scan($html, $editableClass, !empty($options['content']));
$edits = [];
$changes = [];
if (!empty($options['content'])) {
foreach ($c['regions'] as $i) {
$edit = self::classEdit($html, $c['els'][$i], $editableClass);
if ($edit) {
$edits[] = $edit;
$changes[] = "Made <{$c['els'][$i]['name']}> editable";
}
}
}
if (!empty($options['images'])) {
foreach ($c['images'] as $img) {
if (!$img['inRegion'] && !$img['tagged'] && $img['taggable']) {
$edits[] = [$img['insertAt'], ' data-neo-image'];
$changes[] = !empty($img['placeholder'])
? 'Made image placeholder editable' . ($img['label'] !== '' ? ': ' . $img['label'] : '')
: 'Made image editable: ' . $img['src'];
}
}
}
if (!empty($options['seo']) && ($seo = self::seoEdit($html, $c))) {
$edits[] = $seo['edit'];
array_push($changes, ...$seo['changes']);
}
if (!$edits) {
return null;
}
usort($edits, fn(array $a, array $b) => $b[0] <=> $a[0]);
foreach ($edits as [$offset, $text]) {
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
}
return ['html' => $html, 'changes' => $changes];
}
/** Tokenise a page and gather everything analyse() and apply() need. */
private static function scan(string $html, string $class, bool $withRegions): array
{
$els = self::elements($html);
$existing = 0;
foreach ($els as $k => $e) {
$els[$k]['editable'] = self::hasClassToken($e['attrs'], $class) || ($e['parent'] >= 0 && $els[$e['parent']]['editable']);
$existing += self::hasClassToken($e['attrs'], $class) ? 1 : 0;
}
$regions = ($withRegions && $existing === 0) ? self::chooseRegions($html, $els) : [];
$inChosen = static function (int $k) use ($els, $regions): bool {
for ($i = $k; $i >= 0; $i = $els[$i]['parent']) {
if (in_array($i, $regions, true)) {
return true;
}
}
return false;
};
// Containers that already hold a real
are not placeholders.
$hasImg = [];
foreach ($els as $e) {
for ($p = $e['name'] === 'img' ? $e['parent'] : -1; $p >= 0; $p = $els[$p]['parent']) {
$hasImg[$p] = true;
}
}
$images = [];
$refs = [];
$meta = [];
$title = null;
$h1 = '';
$firstParagraph = '';
foreach ($els as $k => $e) {
$name = $e['name'];
if ($name === 'img') {
$src = self::attr($e['attrs'], 'src');
$images[] = [
'src' => (string) $src,
'hasAlt' => self::attr($e['attrs'], 'alt') !== null,
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
'inRegion' => $e['editable'] || $inChosen($k),
'taggable' => $src !== null && $src !== '' && !str_starts_with(strtolower(trim($src)), 'data:')
&& !(self::attr($e['attrs'], 'width') === '1' && self::attr($e['attrs'], 'height') === '1'),
'insertAt' => $e['start'] + 1 + strlen($name),
'skip' => $e['skip'],
];
if ($src) {
$refs[] = ['type' => 'img', 'url' => $src];
}
} elseif (strtolower((string) self::attr($e['attrs'], 'role')) === 'img' && !in_array($name, ['svg'], true)
&& self::attr($e['attrs'], 'aria-hidden') !== 'true' && empty($hasImg[$k])) {
// A styled box standing in for a photo (for example a "Your photo here" block).
$images[] = [
'src' => '', 'hasAlt' => true, 'placeholder' => true,
'label' => (string) self::attr($e['attrs'], 'aria-label'),
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
'inRegion' => $e['editable'] || $inChosen($k),
'taggable' => true,
'insertAt' => $e['start'] + 1 + strlen($name),
'skip' => $e['skip'],
];
} elseif ($name === 'link' && preg_match('/\bstylesheet\b/i', (string) self::attr($e['attrs'], 'rel')) && ($h = self::attr($e['attrs'], 'href'))) {
$refs[] = ['type' => 'link', 'url' => $h];
} elseif ($name === 'script' && ($s = self::attr($e['attrs'], 'src'))) {
$refs[] = ['type' => 'script', 'url' => $s];
} elseif ($name === 'meta') {
$key = self::attr($e['attrs'], 'name') ?? self::attr($e['attrs'], 'property');
if ($key !== null && trim((string) self::attr($e['attrs'], 'content')) !== '') {
$meta[strtolower($key)] = trim(html_entity_decode((string) self::attr($e['attrs'], 'content'), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
} elseif ($name === 'title' && $title === null) {
$title = self::text($html, $e);
} elseif ($name === 'h1' && $h1 === '') {
$h1 = self::text($html, $e);
} elseif ($name === 'p' && $firstParagraph === '' && !$e['skip'] && strlen($t = self::text($html, $e)) >= 20) {
$firstParagraph = $t;
}
}
$canonical = false;
foreach ($els as $e) {
if ($e['name'] === 'link' && preg_match('/\bcanonical\b/i', (string) self::attr($e['attrs'], 'rel'))) {
$canonical = true;
}
}
return [
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
'seo' => [
'title' => trim((string) $title) !== '', 'description' => isset($meta['description']), 'canonical' => $canonical,
'ogTitle' => isset($meta['og:title']), 'ogDescription' => isset($meta['og:description']),
'ogImage' => isset($meta['og:image']), 'robots' => isset($meta['robots']),
],
];
}
/** Flat list of elements with source offsets, parent index, skipped-subtree and close-position data. */
private static function elements(string $html): array
{
$pattern = '#|<(script|style|textarea|template)\b[^>]*>.*?\1\s*>|<(/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#is';
preg_match_all($pattern, $html, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
$els = [];
$stack = [];
foreach ($tokens as $t) {
// Group 3 is the tag name; comments and raw script/style blocks never set it, but script src still matters.
if (!isset($t[3]) || $t[3][1] < 0) {
if (!empty($t[1][0]) && preg_match('#^