1647 lines
53 KiB
PHP
1647 lines
53 KiB
PHP
<?php namespace ProcessWire;
|
||
|
||
/**
|
||
* ProcessWire Markup Regions
|
||
*
|
||
* Supportings finding and manipulating of markup regions in an HTML document.
|
||
*
|
||
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
|
||
* https://processwire.com
|
||
*
|
||
*/
|
||
|
||
class WireMarkupRegions extends Wire {
|
||
|
||
/**
|
||
* Debug during development of this class
|
||
*
|
||
*/
|
||
const debug = false;
|
||
|
||
/**
|
||
* Markup landmark where debug notes should be placed
|
||
*
|
||
*/
|
||
const debugLandmark = "<!--PW-REGION-DEBUG-->";
|
||
|
||
/**
|
||
* @var array
|
||
*
|
||
*/
|
||
protected $debugNotes = array();
|
||
|
||
/**
|
||
* HTML tag names that require no closing tag
|
||
*
|
||
* @var array
|
||
*
|
||
*/
|
||
protected $selfClosingTags = array(
|
||
'link',
|
||
'area',
|
||
'base',
|
||
'br',
|
||
'col',
|
||
'command',
|
||
'embed',
|
||
'hr',
|
||
'img',
|
||
'input',
|
||
'keygen',
|
||
'link',
|
||
'meta',
|
||
'param',
|
||
'source',
|
||
'track',
|
||
'wbr',
|
||
);
|
||
|
||
/**
|
||
* Supported markup region actions
|
||
*
|
||
* @var array
|
||
*
|
||
*/
|
||
protected $actions = array(
|
||
'prepend',
|
||
'append',
|
||
'before',
|
||
'after',
|
||
'replace',
|
||
'remove',
|
||
);
|
||
|
||
/**
|
||
* Locate and return all regions of markup having the given attribute
|
||
*
|
||
* @param string $selector Specify one of the following:
|
||
* - Name of an attribute that must be present, i.e. "data-region", or "attribute=value" or "tag[attribute=value]".
|
||
* - Specify `#name` to match a specific `id='name'` attribute.
|
||
* - Specify `.name` or `tag.name` to match a specific `class='name'` attribute (class can appear anywhere in class attribute).
|
||
* - Specify `.name*` to match class name starting with a name prefix.
|
||
* - Specify `<tag>` to match all of those HTML tags (i.e. `<head>`, `<body>`, `<title>`, `<footer>`, etc.)
|
||
* @param string $markup HTML document markup to perform the find in.
|
||
* @param array $options Optional options to modify default behavior:
|
||
* - `single` (bool): Return just the markup from the first matching region (default=false).
|
||
* - `verbose` (bool): Specify true to return array of verbose info detailing what was found (default=false).
|
||
* - `wrap` (bool): Include wrapping markup? Default is false for id or attribute matches, and true for class matches.
|
||
* - `max` (int): Maximum allowed regions to return (default=500).
|
||
* - `exact` (bool): Return region markup exactly as-is? (default=false). Specify true when using return values for replacement.
|
||
* - `leftover` (bool): Specify true if you want to return a "leftover" key in return value with leftover markup.
|
||
* @return array Returns one of the following:
|
||
* - Associative array of [ 'id' => 'markup' ] when finding specific attributes or #id attributes.
|
||
* - Regular array of markup regions when finding regions having a specific class attribute.
|
||
* - Associative array of verbose information when the verbose option is used.
|
||
* @throws WireException if given invalid $find string
|
||
*
|
||
*/
|
||
public function find($selector, $markup, array $options = array()) {
|
||
|
||
if(strpos($selector, ',')) return $this->findMulti($selector, $markup, $options);
|
||
|
||
$defaults = array(
|
||
'single' => false,
|
||
'verbose' => false,
|
||
'wrap' => null,
|
||
'max' => 500,
|
||
'exact' => false,
|
||
'leftover' => false,
|
||
);
|
||
|
||
$options = array_merge($defaults, $options);
|
||
$selectorInfo = $this->parseFindSelector($selector);
|
||
$tests = $selectorInfo['tests'];
|
||
$findTag = $selectorInfo['findTag'];
|
||
$hasClass = $selectorInfo['hasClass'];
|
||
$regions = array();
|
||
$_markup = self::debug ? $markup : '';
|
||
|
||
if(self::debug) {
|
||
$options['debugNote'] = (empty($options['debugNote']) ? "" : "$options[debugNote] => ") . "find($selector)";
|
||
}
|
||
|
||
// strip out comments if markup isn't required to stay the same
|
||
if(!$options['exact']) $markup = $this->stripRegions('<!--', $markup);
|
||
|
||
// determine auto value for wrap option
|
||
if(is_null($options['wrap'])) $options['wrap'] = $hasClass ? true : false;
|
||
|
||
$startPos = 0;
|
||
$whileCnt = 0; // number of do-while loop completions
|
||
$iterations = 0; // total number of iterations, including continue statements
|
||
|
||
do {
|
||
if(++$iterations >= $options['max'] * 2) break;
|
||
|
||
// find all the positions where each test appears
|
||
$positions = array();
|
||
foreach($tests as $str) {
|
||
$pos = stripos($markup, $str, $startPos);
|
||
if($pos === false) continue;
|
||
if($selector === '.pw-*') {
|
||
// extra validation for .pw-* selectors to confirm they match a pw-[action] (legacy support)
|
||
$testAction = '';
|
||
$testPW = substr($markup, $pos, 20);
|
||
foreach($this->actions as $testAction) {
|
||
if(strpos($testPW, $testAction) !== false) {
|
||
$testAction = true;
|
||
break;
|
||
}
|
||
}
|
||
if($testAction !== true) continue; // if not a pw-[action] then skip
|
||
}
|
||
if($str[0] == '<') $pos++; // HTML tag match, bump+1 to enable match
|
||
$positions[$pos] = $pos;
|
||
}
|
||
|
||
// if no tests matched, we can abort now
|
||
if(empty($positions)) break;
|
||
|
||
// sort the matching test positions closest to furthest and get the first match
|
||
ksort($positions);
|
||
$pos = reset($positions);
|
||
$startPos = $pos;
|
||
$markupBefore = substr($markup, 0, $pos);
|
||
$markupAfter = substr($markup, $pos);
|
||
$openTagPos = strrpos($markupBefore, '<');
|
||
$closeOpenTagPos = strpos($markupAfter, '>');
|
||
$startPos += $closeOpenTagPos; // for next iteration, if a continue occurs
|
||
|
||
// if the orders of "<" and ">" aren't what's expected in our markupBefore and markupAfter, then abort
|
||
$testPos = strpos($markupAfter, '<');
|
||
if($testPos !== false && $testPos < $closeOpenTagPos) continue;
|
||
if(strrpos($markupBefore, '>') > $openTagPos) continue;
|
||
|
||
// build the HTML tag by combining the halfs from markupBefore and markupAfter
|
||
$tag = substr($markupBefore, $openTagPos) . substr($markupAfter, 0, $closeOpenTagPos + 1);
|
||
$tagInfo = $this->getTagInfo($tag);
|
||
|
||
// pre-checks to make sure this iteration is allowed
|
||
if($findTag && $tagInfo['name'] !== $findTag) continue;
|
||
if(!$tagInfo['action'] && $hasClass && !$this->hasClass($hasClass, $tagInfo['classes'])) continue;
|
||
|
||
// build the region (everything in $markupAfter that starts after the ">")
|
||
$regionMarkup = empty($tagInfo['close']) ? '' : substr($markupAfter, $closeOpenTagPos + 1);
|
||
$region = $this->getTagRegion($regionMarkup, $tagInfo, $options);
|
||
$region['startPos'] = $openTagPos;
|
||
|
||
if($options['single']) {
|
||
// single mode means we just return the markup
|
||
$regions = $region;
|
||
break;
|
||
|
||
} else {
|
||
while(isset($regions[$pos])) $pos++;
|
||
$regions[$pos] = $region;
|
||
}
|
||
|
||
$replaceQty = 0;
|
||
$markup = str_replace($region['html'], '', $markup, $replaceQty);
|
||
if(!$replaceQty) {
|
||
// region html was not present, try replacement without closing tag (which might be missing)
|
||
$markup = str_replace($region['open'] . $region['region'], '', $markup, $replaceQty);
|
||
if($replaceQty) {
|
||
$this->debugNotes[] = "ERROR: missing closing tag for: $region[open]";
|
||
} else {
|
||
$this->debugNotes[] = "ERROR: unable to populate: $region[open]";
|
||
}
|
||
}
|
||
|
||
if($replaceQty) $startPos = 0;
|
||
$markup = trim($markup);
|
||
if(empty($markup)) break;
|
||
|
||
} while(++$whileCnt < $options['max']);
|
||
|
||
/*
|
||
foreach($regions as $regionKey => $region) {
|
||
foreach($regions as $rk => $r) {
|
||
if($rk === $regionKey) continue;
|
||
if(strpos($region['html'], $r['html']) !== false) {
|
||
$regions[$regionKey]['region'] = str_replace($r['html'], '', $region['region']);
|
||
//$k = 'html';
|
||
//$this->debugNotes[] =
|
||
// "\nREPLACE “" . htmlentities($r[$k]) . "”".
|
||
// "\nIN “" . htmlentities($region[$k]) . "”" .
|
||
// "\nRESULT “" . htmlentities($regions[$regionKey][$k]) . "”";
|
||
}
|
||
}
|
||
}
|
||
*/
|
||
|
||
if($options['leftover']) $regions["leftover"] = trim($markup);
|
||
|
||
if(self::debug && $options['verbose']) {
|
||
$debugNote = "$options[debugNote] in [sm]" . $this->debugNoteStr($_markup, 50) . " …[/sm] => ";
|
||
$numRegions = 0;
|
||
foreach($regions as $key => $region) {
|
||
if($key == 'leftover') continue;
|
||
$details = empty($region['details']) ? '' : " ($region[details]) ";
|
||
$debugNote .= "$region[name]#$region[pwid]$details, ";
|
||
$numRegions++;
|
||
}
|
||
if(!$numRegions) $debugNote .= 'NONE';
|
||
$this->debugNotes[] = rtrim($debugNote, ", ") . " [sm](iterations=$iterations)[/sm]";
|
||
}
|
||
|
||
return $regions;
|
||
}
|
||
|
||
/**
|
||
* Multi-selector version of find(), where $selector contains CSV
|
||
*
|
||
* @param string $selector
|
||
* @param string $markup
|
||
* @param array $options
|
||
* @return array
|
||
*
|
||
*/
|
||
protected function findMulti($selector, $markup, array $options = array()) {
|
||
|
||
$regions = array();
|
||
$o = $options;
|
||
$o['leftover'] = true;
|
||
$leftover = '';
|
||
|
||
foreach(explode(',', $selector) as $s) {
|
||
foreach($this->find(trim($s), $markup, $o) as $key => $region) {
|
||
if($key === 'leftover') {
|
||
$leftover .= $region;
|
||
} else {
|
||
while(isset($regions[$key])) $key++;
|
||
$regions[$key] = $region;
|
||
}
|
||
}
|
||
$markup = $leftover;
|
||
}
|
||
|
||
if(!empty($options['leftover'])) {
|
||
if(!empty($leftover)) {
|
||
foreach($regions as $region) {
|
||
if(strpos($leftover, $region['html']) !== false) {
|
||
$leftover = str_replace($region['html'], '', $leftover);
|
||
}
|
||
}
|
||
}
|
||
$regions['leftover'] = $leftover;
|
||
}
|
||
|
||
ksort($regions);
|
||
|
||
return $regions;
|
||
}
|
||
|
||
|
||
/**
|
||
* Does the given class exist in given $classes array?
|
||
*
|
||
* @param string $class May be class name, or class prefix if $class has "*" at end.
|
||
* @param array $classes
|
||
* @return bool|string Returns false if no match, or class name if matched
|
||
*
|
||
*/
|
||
protected function hasClass($class, array $classes) {
|
||
$has = false;
|
||
if(strpos($class, '*')) {
|
||
// partial class match
|
||
$class = rtrim($class, '*');
|
||
foreach($classes as $c) {
|
||
if(strpos($c, $class) === 0) {
|
||
$has = $c;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
// exact class match
|
||
$key = array_search($class, $classes);
|
||
if($key !== false) $has = $classes[$key];
|
||
}
|
||
return $has;
|
||
}
|
||
|
||
/**
|
||
* Given a $find selector return array with tests and other meta info
|
||
*
|
||
* @param string $find
|
||
* @return array Returns array of [
|
||
* 'tests' => [ 0 => '', 1 => '', ...],
|
||
* 'find' => '',
|
||
* 'findTag' => '',
|
||
* 'hasClass' => '',
|
||
* ]
|
||
*
|
||
*/
|
||
protected function parseFindSelector($find) {
|
||
|
||
$findTag = '';
|
||
$hasClass = '';
|
||
$finds = array();
|
||
|
||
if(strpos($find, '.') > 0) {
|
||
// i.e. "div.myclass"
|
||
list($findTag, $_find) = explode('.', $find, 2);
|
||
if($this->wire()->sanitizer->alphanumeric($findTag) === $findTag) {
|
||
$find = ".$_find";
|
||
} else {
|
||
$findTag = '';
|
||
}
|
||
}
|
||
|
||
$c = substr($find, 0, 1);
|
||
$z = substr($find, -1);
|
||
|
||
if($c === '#') {
|
||
// match an id, pw-id or data-pw-id attribute
|
||
$find = trim($find, '# ');
|
||
foreach(array('pw-id', 'data-pw-id', 'id') as $attr) {
|
||
$finds[] = " $attr=\"$find\"";
|
||
$finds[] = " $attr='$find'";
|
||
$finds[] = " $attr=$find ";
|
||
$finds[] = " $attr=$find>";
|
||
}
|
||
|
||
} else if($find === '[pw-action]') {
|
||
// find pw-[action] boolean attributes
|
||
foreach($this->actions as $action) {
|
||
$finds[] = " data-pw-$action ";
|
||
$finds[] = " data-pw-$action>";
|
||
$finds[] = " data-pw-$action=";
|
||
$finds[] = " pw-$action ";
|
||
$finds[] = " pw-$action>";
|
||
$finds[] = " pw-$action=";
|
||
}
|
||
|
||
/*
|
||
} else if($c === '[' && $z === ']') {
|
||
// find any attribute (not currently used by markup regions)
|
||
|
||
if(strpos($find, '=') === false) {
|
||
// match an attribute only
|
||
$attr = trim($find, '[]*+');
|
||
$tail = substr($find, -2);
|
||
if($tail === '*]') {
|
||
// match an attribute prefix
|
||
$finds = array(" $attr");
|
||
} else {
|
||
// match a whole attribute name
|
||
$finds = array(
|
||
" $attr ",
|
||
" $attr>",
|
||
" $attr=",
|
||
);
|
||
}
|
||
} else {
|
||
// match attribute and value (not yet implemented)
|
||
}
|
||
*/
|
||
|
||
} else if($c === '.' && $z === '*') {
|
||
// match a class name prefix or action prefix
|
||
$find = trim($find, '.*');
|
||
$finds = array(
|
||
' class="' . $find,
|
||
" class='$find",
|
||
" class=$find",
|
||
" $find",
|
||
"'$find",
|
||
"\"$find",
|
||
);
|
||
if(strpos($find, 'pw-') !== false) $finds[] = " data-$find";
|
||
$hasClass = "$find*";
|
||
|
||
} else if($c === '.') {
|
||
// match a class name or action
|
||
$find = trim($find, '.');
|
||
$finds = array(
|
||
' class="' . $find . '"',
|
||
" class='$find'",
|
||
" class=$find ",
|
||
" class=$find>",
|
||
" $find'",
|
||
"'$find ",
|
||
" $find\"",
|
||
"\"$find ",
|
||
" $find ",
|
||
);
|
||
if(strpos($find, 'pw-') !== false) $finds[] = " data-$find";
|
||
$hasClass = $find;
|
||
|
||
} else if($c === '<') {
|
||
// matching a single-use HTML tag
|
||
$finds = array(
|
||
$find,
|
||
rtrim($find, '>') . ' ',
|
||
);
|
||
|
||
} else if(strpos($find, '=') !== false) {
|
||
// some other specified attribute in attr=value format
|
||
if(strpos($find, '[') !== false && $z === ']') {
|
||
// i.e. div[attr=value]
|
||
list($findTag, $find) = explode('[', $find, 2);
|
||
$find = rtrim($find, ']');
|
||
}
|
||
list($attr, $val) = explode('=', $find);
|
||
if(strlen($val)) {
|
||
$finds = array(
|
||
" $attr=\"$val\"",
|
||
" $attr='$val'",
|
||
" $attr=$val",
|
||
" $attr=$val>"
|
||
);
|
||
} else {
|
||
$finds = array(" $attr=");
|
||
}
|
||
|
||
if(strpos($find, 'id=') === 0) {
|
||
// when finding an id attribute, also allow for "pw-id=" and "data-pw-id="
|
||
foreach($finds as $find) {
|
||
$find = ltrim($find);
|
||
$finds[] = " pw-id=";
|
||
$finds[] = " data-pw-id=";
|
||
}
|
||
}
|
||
|
||
} else if($z === '*') {
|
||
// data-pw-* matches non-value attribute starting with a prefix
|
||
$finds = array(
|
||
" $find",
|
||
);
|
||
|
||
} else {
|
||
// "data-something" matches attributes with no value, like "<div data-something>"
|
||
$finds = array(
|
||
" $find ",
|
||
" $find>",
|
||
" $find"
|
||
);
|
||
}
|
||
|
||
return array(
|
||
'tests' => $finds,
|
||
'selector' => $find,
|
||
'findTag' => $findTag,
|
||
'hasClass' => $hasClass
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Given all markup after a tag, return just the portion that is the tag body/region
|
||
*
|
||
* @param string $region Markup that occurs after the ">" of the tag you want to get the region of.
|
||
* @param array $tagInfo Array returned by getTagInfo method.
|
||
* @param array $options Options to modify behavior, see getMarkupRegions $options argument.
|
||
* - `verbose` (bool): Verbose mode (default=false)
|
||
* - `wrap` (bool): Whether or not wrapping markup should be included (default=false)
|
||
* @return array|string Returns string except when verbose mode enabled it returns array.
|
||
*
|
||
*/
|
||
protected function getTagRegion($region, array $tagInfo, array $options) {
|
||
|
||
// options
|
||
$verbose = empty($options['verbose']) ? false : true;
|
||
$wrap = empty($options['wrap']) ? false : $options['wrap'];
|
||
|
||
if($verbose) $verboseRegion = array(
|
||
'name' => "$tagInfo[name]",
|
||
'pwid' => "$tagInfo[pwid]",
|
||
'open' => "$tagInfo[src]",
|
||
'close' => "$tagInfo[close]",
|
||
'attrs' => $tagInfo['attrs'],
|
||
'classes' => $tagInfo['classes'],
|
||
'action' => $tagInfo['action'],
|
||
'actionType' => $tagInfo['actionType'],
|
||
'actionTarget' => $tagInfo['actionTarget'],
|
||
'error' => false,
|
||
'details' => '',
|
||
'region' => '', // region without wrapping tags
|
||
'html' => '', // region with wrapping tags
|
||
);
|
||
|
||
if($tagInfo['pwid']) $tagID = $tagInfo['pwid'];
|
||
else if(!empty($tagInfo['id'])) $tagID = $tagInfo['id'];
|
||
else if(!empty($tagInfo['attrs']['id'])) $tagID = $tagInfo['attrs']['id'];
|
||
else if(!empty($tagInfo['actionTarget'])) $tagID = $tagInfo['actionTarget'];
|
||
else $tagID = '';
|
||
|
||
$selfClose = empty($tagInfo['close']);
|
||
$closeHint = "$tagInfo[close]<!--#$tagID-->";
|
||
$closeQty = $selfClose ? 1 : substr_count($region, $tagInfo['close']);
|
||
|
||
if(!$closeQty) {
|
||
// there is no close tag, meaning all of markupAfter is the region
|
||
if($verbose) $verboseRegion['details'] = 'No closing tag, matched rest of document';
|
||
|
||
} else if($selfClose) {
|
||
$region = '';
|
||
if($verbose) $verboseRegion['details'] = 'Self closing tag (empty region)';
|
||
|
||
} else if($tagID && false !== ($pos = strpos($region, $closeHint))) {
|
||
// close tag indicates what it closes, i.e. “</div><!--#content-->”
|
||
$region = substr($region, 0, $pos);
|
||
$tagInfo['close'] = $closeHint;
|
||
if($verbose) $verboseRegion['details'] = "Fast match with HTML comment hint";
|
||
|
||
} else if($closeQty === 1) {
|
||
// just one close tag present, making our job easy
|
||
$region = substr($region, 0, strrpos($region, $tagInfo['close']));
|
||
if($verbose) $verboseRegion['details'] = "Only 1 possible closing tag: $tagInfo[close]";
|
||
|
||
} else {
|
||
// multiple close tags present, must figure out which is the right one
|
||
$testStart = 0;
|
||
$doCnt = 0;
|
||
$maxDoCnt = 100000;
|
||
$openTag = "<$tagInfo[name]";
|
||
$openTags = array("$openTag>", "$openTag ", "$openTag\n", "$openTag\r", "$openTag\t");
|
||
$fail = false;
|
||
|
||
do {
|
||
$doCnt++;
|
||
$testPos = stripos($region, $tagInfo['close'], $testStart);
|
||
if($testPos === false) {
|
||
$fail = true;
|
||
break;
|
||
}
|
||
$test = substr($region, 0, $testPos);
|
||
$openCnt = 0;
|
||
foreach($openTags as $openTag) {
|
||
if(strpos($test, $openTag) !== false) $openCnt += substr_count($test, $openTag);
|
||
}
|
||
$closeCnt = substr_count($test, $tagInfo['close']);
|
||
if($openCnt == $closeCnt) {
|
||
// open and close tags balance, meaning we've found our region
|
||
$region = $test;
|
||
break;
|
||
} else {
|
||
// tags within don't balance, so try again
|
||
$testStart = $testPos + strlen($tagInfo['close']);
|
||
}
|
||
} while($doCnt < $maxDoCnt && $testStart < strlen($region));
|
||
|
||
if($fail) {
|
||
if($verbose) {
|
||
$verboseRegion['error'] = true;
|
||
$verboseRegion['details'] = "Failed to find closing tag $tagInfo[close] after $doCnt iterations";
|
||
} else {
|
||
$region = 'error';
|
||
}
|
||
} else if($doCnt >= $maxDoCnt) {
|
||
if($verbose) {
|
||
$verboseRegion['error'] = true;
|
||
$verboseRegion['details'] = "Failed region match after $doCnt tests for <$tagInfo[name]> tag(s)";
|
||
} else {
|
||
$region = 'error';
|
||
}
|
||
} else if($verbose) {
|
||
$verboseRegion['details'] = "Matched region after testing $doCnt <$tagInfo[name]> tag(s)";
|
||
}
|
||
}
|
||
|
||
// region with wrapping tag
|
||
$wrapRegion = $selfClose ? "$tagInfo[src]" : "$tagInfo[src]$region$tagInfo[close]";
|
||
|
||
if($verbose) {
|
||
$verboseRegion['region'] = $region;
|
||
$verboseRegion['html'] = $wrapRegion;
|
||
/*
|
||
if(self::debug) {
|
||
$debugNote = (isset($options['debugNote']) ? "$options[debugNote] => " : "") .
|
||
"getTagRegion() => $verboseRegion[name]#$verboseRegion[pwid] " .
|
||
"[sm]$verboseRegion[details][/sm]";
|
||
$this->debugNotes[] = $debugNote;
|
||
}
|
||
*/
|
||
return $verboseRegion;
|
||
}
|
||
|
||
// include wrapping markup if asked for
|
||
if($wrap) $region = $wrapRegion;
|
||
|
||
return $region;
|
||
}
|
||
|
||
/**
|
||
* Given HTML tag like “<div id='foo' class='bar baz'>” return associative array of info about it
|
||
*
|
||
* Returned info includes:
|
||
* - `name` (string): Tag name
|
||
* - `id` (string): Value of id attribute
|
||
* - `pwid` (string): PW region ID from 'pw-id' or 'data-pw-id', or if not present, then same as 'id'
|
||
* - `action` (string): Action for this region, without “pw-” prefix.
|
||
* - `actionTarget` (string): Target id for the action, if applicable.
|
||
* - `actionType` (string): "class" if action specified as class name, "attr" if specified as a pw- or data-pw attribute.
|
||
* - `classes` (array): Array of class names (from class attribute).
|
||
* - `attrs` (array): Associative array of all attributes, all values are strings.
|
||
* - `attrStr` (string): All attributes in a string
|
||
* - `tag` (string): The entire tag as given
|
||
* - `close` (string): The HTML string that would close this tag
|
||
*
|
||
* @param string $tag Must be a tag in format “<tag attrs>”
|
||
* @return array
|
||
*
|
||
*/
|
||
public function getTagInfo($tag) {
|
||
|
||
$attrs = array();
|
||
$attrStr = '';
|
||
$name = '';
|
||
$tagName = '';
|
||
$val = '';
|
||
$inVal = false;
|
||
$inTagName = true;
|
||
$inQuote = '';
|
||
$originalTag = $tag;
|
||
|
||
// normalize tag to include only what's between "<" and ">" and remove unnecessary whitespace
|
||
$tag = str_replace(array("\r", "\n", "\t"), ' ', $tag);
|
||
$tag = trim($tag, '</> ');
|
||
$pos = strpos($tag, '>');
|
||
if($pos) $tag = substr($tag, 0, $pos);
|
||
while(strpos($tag, ' ') !== false) $tag = str_replace(' ', ' ', $tag);
|
||
$tag = str_replace(array(' =', '= '), '=', $tag);
|
||
$tag .= ' '; // extra space for loop below
|
||
|
||
// iterate through each character in the tag
|
||
for($n = 0; $n < strlen($tag); $n++) {
|
||
|
||
$c = $tag[$n];
|
||
|
||
if($c == '"' || $c == "'") {
|
||
if($inVal) {
|
||
// building a value
|
||
if($inQuote === $c) {
|
||
// end of value, populate to attrs and reset val
|
||
$attrs[$name] = $val;
|
||
$inQuote = false;
|
||
$inVal = false;
|
||
$name = '';
|
||
$val = '';
|
||
} else if(!strlen($val)) {
|
||
// starting a value
|
||
$inQuote = $c;
|
||
} else {
|
||
// continue appending value
|
||
$val .= $c;
|
||
}
|
||
} else {
|
||
// not building a value, but found a quote, not sure what it's for, so skip it
|
||
}
|
||
|
||
} else if($c === ' ') {
|
||
// space can either separate attributes or be part of a quoted value
|
||
if($inVal) {
|
||
if($inQuote) {
|
||
// quoted space is part of value
|
||
$val .= $c;
|
||
} else {
|
||
// unquoted space ends the attribute value
|
||
if($name) $attrs[$name] = $val;
|
||
$inVal = false;
|
||
$name = '';
|
||
$val = '';
|
||
}
|
||
} else {
|
||
if($name && !isset($attrs[$name])) {
|
||
// attribute without a value
|
||
$attrs[$name] = true;
|
||
}
|
||
// start of a new attribute name
|
||
$name = '';
|
||
$inTagName = false;
|
||
}
|
||
|
||
} else if($c === '=') {
|
||
// equals separates attribute names from values, or can appear in an attribute value
|
||
if($inVal && $inQuote) {
|
||
// part of a value
|
||
$val .= $c;
|
||
} else {
|
||
// start new value
|
||
$inVal = true;
|
||
}
|
||
} else if($inVal) {
|
||
// append attribute value
|
||
$val .= $c;
|
||
} else if(trim($c)) {
|
||
// tag name or attribute name
|
||
if($inTagName) {
|
||
$tagName .= $c;
|
||
} else {
|
||
$name .= $c;
|
||
}
|
||
}
|
||
|
||
if(!$inTagName) $attrStr .= $c;
|
||
}
|
||
|
||
if($name && !isset($attrs[$name])) $attrs[$name] = $val;
|
||
$tag = rtrim($tag); // remove extra space we added
|
||
$tagName = strtolower($tagName);
|
||
$selfClosing = in_array($tagName, $this->selfClosingTags);
|
||
$classes = isset($attrs['class']) ? explode(' ', $attrs['class']) : array();
|
||
$id = isset($attrs['id']) ? $attrs['id'] : '';
|
||
$pwid = '';
|
||
$action = '';
|
||
$actionTarget = '';
|
||
$actionType = '';
|
||
|
||
// determine action and action target from attributes
|
||
foreach($attrs as $name => $value) {
|
||
$pwpos = strpos($name, 'pw-');
|
||
if($pwpos === false) continue;
|
||
if($name === 'pw-id' || $name === 'data-pw-id') {
|
||
// id attribute
|
||
if(!$pwid) $pwid = $value;
|
||
unset($attrs[$name]);
|
||
} else if($pwpos === 0) {
|
||
// action attribute
|
||
list($prefix, $action) = explode('-', $name, 2);
|
||
if($prefix) {} // ignore
|
||
} else if(strpos($name, 'data-pw-') === 0) {
|
||
// action data attribute
|
||
list($ignore, $prefix, $action) = explode('-', $name, 3);
|
||
if($ignore && $prefix) {} // ignore
|
||
}
|
||
if($action && !$actionTarget) {
|
||
if(strpos($action, '-')) {
|
||
list($action, $actionTarget) = explode('-', $action, 2);
|
||
} else {
|
||
$actionTarget = $value;
|
||
}
|
||
if($actionTarget && in_array($action, $this->actions)) {
|
||
// found a valid action and target
|
||
unset($attrs[$name]);
|
||
$actionType = $actionTarget === true ? 'bool' : 'attr';
|
||
} else {
|
||
// unrecognized action
|
||
$action = '';
|
||
$actionTarget = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// if action was not specified as an attribute, see if action is specified as a class name
|
||
if(!$action) foreach($classes as $key => $class) {
|
||
if(strpos($class, 'pw-') !== 0) continue;
|
||
list($prefix, $action) = explode('-', $class, 2);
|
||
if(strpos($action, '-')) list($action, $actionTarget) = explode('-', $action, 2);
|
||
if($prefix && $actionTarget) {} // ignore
|
||
if(in_array($action, $this->actions)) {
|
||
// valid action, remove action from classes and class attribute
|
||
unset($classes[$key]);
|
||
$attrs['class'] = implode(' ', $classes);
|
||
$actionType = 'class';
|
||
break;
|
||
} else {
|
||
// unrecognized action
|
||
$action = '';
|
||
$actionTarget = '';
|
||
}
|
||
}
|
||
|
||
if(!$pwid) $pwid = $id;
|
||
|
||
// if there's an action, but no target, the target is assumed to be the pw-id or id
|
||
if($action && (!$actionTarget || $actionTarget === true)) $actionTarget = $pwid;
|
||
|
||
$info = array(
|
||
'id' => $id,
|
||
'pwid' => $pwid ? $pwid : $id,
|
||
'name' => $tagName,
|
||
'classes' => $classes,
|
||
'attrs' => $attrs,
|
||
'attrStr' => $attrStr,
|
||
'src' => $originalTag,
|
||
'tag' => "<$tag>",
|
||
'close' => $selfClosing ? "" : "</$tagName>",
|
||
'action' => $action,
|
||
'actionTarget' => $actionTarget,
|
||
'actionType' => $actionType,
|
||
);
|
||
|
||
return $info;
|
||
}
|
||
|
||
/**
|
||
* Strip the given region non-nested tags from the document
|
||
*
|
||
* Note that this only works on non-nested tags like HTML comments, script or style tags.
|
||
*
|
||
* @param string $tag Specify "<!--" to remove comments or "<script" to remove scripts, or "<tag" for any other tags.
|
||
* @param string $markup Markup to remove the tags from
|
||
* @param bool $getRegions Specify true to return array of the strip regions rather than the updated markup
|
||
* @return string|array
|
||
*
|
||
*/
|
||
public function stripRegions($tag, $markup, $getRegions = false) {
|
||
|
||
$startPos = 0;
|
||
$regions = array();
|
||
|
||
$open = strpos($tag, '<') === 0 ? $tag : "<$tag";
|
||
$close = $tag == '<!--' ? '-->' : '</' . trim($tag, '<>') . '>';
|
||
|
||
// keep comments that start with <!--#
|
||
if($tag == "<!--" && strpos($markup, "<!--#") !== false) {
|
||
$hasHints = true;
|
||
$markup = str_replace("<!--#", "<!~~#", $markup);
|
||
$markup = preg_replace('/<!--#([-_.a-zA-Z0-9]+)-->/', '<!~~$1~~>', $markup);
|
||
} else {
|
||
$hasHints = false;
|
||
}
|
||
|
||
do {
|
||
$pos = stripos($markup, $open, $startPos);
|
||
if($pos === false) break;
|
||
$endPos = stripos($markup, $close, $pos);
|
||
if($endPos === false) {
|
||
$endPos = strlen($markup);
|
||
} else {
|
||
$endPos += strlen($close);
|
||
}
|
||
$regions[] = substr($markup, $pos, $endPos - $pos);
|
||
$startPos = $endPos;
|
||
} while(1);
|
||
|
||
if($getRegions) return $regions;
|
||
if(count($regions)) $markup = str_replace($regions, '', $markup);
|
||
|
||
if($hasHints) {
|
||
// keep comments that start with <!--#
|
||
$markup = str_replace(array("<!~~#", "~~>"), array("<!--#", "-->"), $markup);
|
||
}
|
||
|
||
return $markup;
|
||
}
|
||
|
||
/**
|
||
* Strip optional tags/comments from given markup
|
||
*
|
||
* @param string $markup
|
||
* @param bool $debug
|
||
* @return string
|
||
*
|
||
*/
|
||
public function stripOptional($markup, $debug = false) {
|
||
static $level = 0;
|
||
$attrs = array('data-pw-optional', 'pw-optional');
|
||
foreach($attrs as $attrName) {
|
||
if(strpos($markup, " $attrName") === false) continue;
|
||
$regions = $this->find($attrName, $markup, array('verbose' => true));
|
||
foreach($regions as $region) {
|
||
$pos = strpos($markup, $region['html']);
|
||
if($pos === false) continue;
|
||
$content = trim($region['region']);
|
||
if(strpos($content, 'pw-optional') && $level < 10) {
|
||
// go resursive if pw-optional elements are nested
|
||
$level++;
|
||
$nestedContent = trim($this->stripOptional($content, $debug));
|
||
$level--;
|
||
if(strlen($nestedContent) && $nestedContent !== $content) {
|
||
$html = str_replace($content, $nestedContent, $region['html']);
|
||
$markup = str_replace($region['html'], $html, $markup);
|
||
}
|
||
$content = $nestedContent;
|
||
}
|
||
if(strlen($content)) {
|
||
// not empty, remove just the pw-optional attribute
|
||
$open = $region['open'];
|
||
$open = str_ireplace(" $attrName", '', $open);
|
||
$markup = substr($markup, 0, $pos) . $open . substr($markup, $pos + strlen($region['open']));
|
||
} else {
|
||
// empty optional region, can be removed
|
||
$markup = substr($markup, 0, $pos) . substr($markup, $pos + strlen($region['html']));
|
||
if($debug) {
|
||
$optional = '[sm]' . ($level ? "nested (×$level) optional region" : "optional region") . '[/sm]';
|
||
$this->debugNotes[] = "REMOVED #$region[pwid] $optional";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return $markup;
|
||
}
|
||
|
||
/**
|
||
* Merge attributes from one HTML tag to another
|
||
*
|
||
* - Attributes (except class) that appear in $mergeTag replace those in $htmlTag.
|
||
* - Attributes in $mergeTag not already present in $htmlTag are added to it.
|
||
* - Class attribute is combined with all classes from $htmlTag and $mergeTag.
|
||
* - The tag name from $htmlTag is used, and the one from $mergeTag is ignored.
|
||
*
|
||
* @param string $htmlTag HTML tag string, optionally containing attributes
|
||
* @param array|string $mergeTag HTML tag to merge (or attributes array)
|
||
* @return string Updated HTML tag string with merged attributes
|
||
*
|
||
*/
|
||
public function mergeTags($htmlTag, $mergeTag) {
|
||
|
||
if(is_string($mergeTag)) {
|
||
$mergeTagInfo = $this->getTagInfo($mergeTag);
|
||
$mergeAttrs = $mergeTagInfo['attrs'];
|
||
} else {
|
||
$mergeAttrs = $mergeTag;
|
||
}
|
||
|
||
$tagInfo = $this->getTagInfo($htmlTag);
|
||
$attrs = $tagInfo['attrs'];
|
||
$changes = 0;
|
||
|
||
foreach($mergeAttrs as $name => $value) {
|
||
if(isset($attrs[$name])) {
|
||
// attribute is already present
|
||
if($attrs[$name] === $value) continue;
|
||
if($name === 'class') {
|
||
// merge classes
|
||
$classes = explode(' ', $value);
|
||
$classes = array_merge($tagInfo['classes'], $classes);
|
||
$classes = array_unique($classes);
|
||
// identify remove classes
|
||
foreach($classes as $key => $class) {
|
||
if(strpos($class, '-') !== 0) continue;
|
||
$removeClass = ltrim($class, '-');
|
||
unset($classes[$key]);
|
||
while(false !== ($k = array_search($removeClass, $classes))) unset($classes[$k]);
|
||
}
|
||
$attrs['class'] = implode(' ', $classes);
|
||
} else {
|
||
// replace
|
||
$attrs[$name] = $value;
|
||
}
|
||
} else {
|
||
// add attribute not already present
|
||
$attrs[$name] = $value;
|
||
}
|
||
$changes++;
|
||
}
|
||
|
||
if($changes) {
|
||
$htmlTag = "<$tagInfo[name] " . $this->renderAttributes($attrs, false);
|
||
$htmlTag = trim($htmlTag) . '>';
|
||
}
|
||
|
||
return $htmlTag;
|
||
}
|
||
|
||
/**
|
||
* Given an associative array of “key=value” attributes, render an HTML attribute string of them
|
||
*
|
||
* - For boolean attributes without value (like "checked" or "selected") specify boolean true as the value.
|
||
* - If value of any attribute is an array, it will be converted to a space-separated string.
|
||
* - Values get entity encoded, unless you specify false for the second argument.
|
||
*
|
||
* @param array $attrs Associative array of attributes.
|
||
* @param bool $encode Entity encode attribute values? Default is true, so if they are already encoded, specify false.
|
||
* @param string $quote Quote style, specify double quotes, single quotes, or blank for none except when required (default=")
|
||
* @return string
|
||
*
|
||
*/
|
||
public function renderAttributes(array $attrs, $encode = true, $quote = '"') {
|
||
|
||
$sanitizer = $this->wire()->sanitizer;
|
||
$str = '';
|
||
|
||
foreach($attrs as $name => $value) {
|
||
|
||
if(!ctype_alnum($name)) {
|
||
// only allow [-_a-zA-Z] attribute names
|
||
$name = $sanitizer->name($name);
|
||
}
|
||
|
||
// convert arrays to space separated string
|
||
if(is_array($value)) $value = implode(' ', $value);
|
||
|
||
if($value === true) {
|
||
// attribute without value, i.e. "checked" or "selected" or "data-uk-grid", etc.
|
||
$str .= "$name ";
|
||
continue;
|
||
} else if($name == 'class' && !strlen($value)) {
|
||
continue;
|
||
}
|
||
|
||
$q = $quote;
|
||
if(!$q && !ctype_alnum($value)) $q = '"';
|
||
|
||
if($encode) {
|
||
// entity encode value
|
||
$value = $sanitizer->entities($value);
|
||
|
||
} else if(strpos($value, '"') !== false && strpos($value, "'") === false) {
|
||
// if value has a quote in it, use single quotes rather than double quotes
|
||
$q = "'";
|
||
}
|
||
|
||
$str .= "$name=$q$value$q ";
|
||
}
|
||
|
||
return trim($str);
|
||
}
|
||
|
||
/**
|
||
* Does the given attribute name and value appear somewhere in the given html?
|
||
*
|
||
* @param string $name
|
||
* @param string|bool $value Value to find, or specify boolean true for boolean attribute without value
|
||
* @param string $html
|
||
* @return bool Returns false if it doesn't appear, true if it does
|
||
*
|
||
*/
|
||
public function hasAttribute($name, $value, &$html) {
|
||
|
||
$pos = null;
|
||
|
||
if($value === true) {
|
||
$tests = array(
|
||
" $name ",
|
||
" $name>",
|
||
" $name=",
|
||
" $name/",
|
||
);
|
||
|
||
} else {
|
||
$tests = array(
|
||
" $name=\"$value\"",
|
||
" $name='$value'",
|
||
);
|
||
|
||
// if there's no space in value, we also check non-quoted values
|
||
if(strpos($value, ' ') === false) {
|
||
$tests[] = " $name=$value ";
|
||
$tests[] = " $name=$value>";
|
||
}
|
||
}
|
||
|
||
if($name == 'id') {
|
||
foreach($tests as $test) {
|
||
$test = ltrim($test);
|
||
$tests[] = " pw-id-$test";
|
||
$tests[] = " data-pw-id-$test";
|
||
}
|
||
}
|
||
|
||
foreach($tests as $test) {
|
||
$pos = stripos($html, $test);
|
||
if($pos === false) continue;
|
||
// if another tag starts before the one in the attribute closes
|
||
// then the matched attribute is apparently not part of an HTML tag
|
||
$close = strpos($html, '>', $pos);
|
||
$open = strpos($html, '<', $pos);
|
||
if($close > $open) $pos = false;
|
||
if($pos !== false) break;
|
||
}
|
||
|
||
if($pos === false && stripos($html, $name) !== false && stripos($html, $value) !== false) {
|
||
// maybe doesn't appear due to some whitespace difference, check again using a regex
|
||
if($name == 'id') {
|
||
$names = '(id|pw-id|data-pw-id)';
|
||
} else {
|
||
$names = preg_quote($name);
|
||
}
|
||
if($value === true) {
|
||
$regex = '!<[^<>]*\s' . $names . '[=\s/>]!i';
|
||
} else {
|
||
$regex = '/<[^<>]*\s' . $names . '\s*=\s*["\']?' . preg_quote($value) . '(?:["\']|[\s>])/i';
|
||
}
|
||
if(preg_match($regex, $html)) $pos = true;
|
||
}
|
||
|
||
return $pos !== false;
|
||
}
|
||
|
||
/**
|
||
* Update the region(s) that match the given $selector with the given $content (markup/text)
|
||
*
|
||
* @param string $selector Specify one of the following:
|
||
* - Name of an attribute that must be present, i.e. "data-region", or "attribute=value" or "tag[attribute=value]".
|
||
* - Specify `#name` to match a specific `id='name'` attribute.
|
||
* - Specify `.name` or `tag.name` to match a specific `class='name'` attribute (class can appear anywhere in class attribute).
|
||
* - Specify `<tag>` to match all of those HTML tags (i.e. `<head>`, `<body>`, `<title>`, `<footer>`, etc.)
|
||
* @param string $content Markup/text to update with
|
||
* @param string $markup Document markup where region(s) exist
|
||
* @param array $options Specify any of the following:
|
||
* - `action` (string): May be 'replace', 'append', 'prepend', 'before', 'after', 'remove', or 'auto'.
|
||
* - `mergeAttr` (array): Array of attrs to add/merge to the wrapping element, or HTML tag with attrs to merge.
|
||
* @return string
|
||
*
|
||
*/
|
||
public function update($selector, $content, $markup, array $options = array()) {
|
||
|
||
$defaults = array(
|
||
'action' => 'auto',
|
||
'mergeAttr' => array(),
|
||
);
|
||
|
||
$options = array_merge($defaults, $options);
|
||
|
||
$findOptions = array(
|
||
'verbose' => true,
|
||
'exact' => true,
|
||
);
|
||
|
||
if(self::debug) {
|
||
$findOptions['debugNote'] = "update.$options[action]($selector)";
|
||
}
|
||
|
||
$findRegions = $this->find($selector, $markup, $findOptions);
|
||
|
||
foreach($findRegions as $region) {
|
||
|
||
$action = $options['action'];
|
||
|
||
if(count($options['mergeAttr'])) {
|
||
$region['open'] = $this->mergeTags($region['open'], $options['mergeAttr']);
|
||
}
|
||
|
||
if($action == 'auto') {
|
||
// auto mode delegates to the region action
|
||
$action = '';
|
||
if(in_array($region['action'], $this->actions)) $action = $region['action'];
|
||
}
|
||
|
||
switch($action) {
|
||
case 'append':
|
||
$replacement = $region['open'] . $region['region'] . $content . $region['close'];
|
||
break;
|
||
case 'prepend':
|
||
$replacement = $region['open'] . $content . $region['region'] . $region['close'];
|
||
break;
|
||
case 'before':
|
||
$replacement = $content . $region['html'];
|
||
break;
|
||
case 'after':
|
||
$replacement = $region['html'] . $content;
|
||
break;
|
||
case 'remove':
|
||
$replacement = '';
|
||
break;
|
||
default:
|
||
// replace
|
||
$replacement = $region['open'] . $content . $region['close'];
|
||
}
|
||
|
||
$markup = str_replace($region['html'], $replacement, $markup);
|
||
}
|
||
|
||
return $markup;
|
||
}
|
||
|
||
/**
|
||
* Replace the region(s) that match the given $selector with the given $replace markup
|
||
*
|
||
* @param string $selector See the update() method $selector argument for supported formats
|
||
* @param string $replace Markup to replace with
|
||
* @param string $markup Document markup where region(s) exist
|
||
* @param array $options See $options argument for update() method
|
||
* @return string
|
||
*
|
||
*/
|
||
public function replace($selector, $replace, $markup, array $options = array()) {
|
||
$options['action'] = 'replace';
|
||
return $this->replace($selector, $replace, $markup, $options);
|
||
}
|
||
|
||
/**
|
||
* Append the region(s) that match the given $selector with the given $content markup
|
||
*
|
||
* @param string $selector See the update() method $selector argument for details
|
||
* @param string $content Markup to append
|
||
* @param string $markup Document markup where region(s) exist
|
||
* @param array $options See the update() method $options argument for details
|
||
* @return string
|
||
*
|
||
*/
|
||
public function append($selector, $content, $markup, array $options = array()) {
|
||
$options['action'] = 'append';
|
||
return $this->replace($selector, $content, $markup, $options);
|
||
}
|
||
|
||
/**
|
||
* Prepend the region(s) that match the given $selector with the given $content markup
|
||
*
|
||
* @param string $selector See the update() method for details
|
||
* @param string $content Markup to prepend
|
||
* @param string $markup Document markup where region(s) exist
|
||
* @param array $options See the update() method for details
|
||
* @return string
|
||
*
|
||
*/
|
||
public function prepend($selector, $content, $markup, array $options = array()) {
|
||
$options['action'] = 'prepend';
|
||
return $this->replace($selector, $content, $markup, $options);
|
||
}
|
||
|
||
/**
|
||
* Insert region(s) that match the given $selector before the given $content markup
|
||
*
|
||
* @param string $selector See the update() method for details
|
||
* @param string $content Markup to prepend
|
||
* @param string $markup Document markup where region(s) exist
|
||
* @param array $options See the update() method for details
|
||
* @return string
|
||
*
|
||
*/
|
||
public function before($selector, $content, $markup, array $options = array()) {
|
||
$options['action'] = 'before';
|
||
return $this->replace($selector, $content, $markup, $options);
|
||
}
|
||
|
||
/**
|
||
* Insert the region(s) that match the given $selector after the given $content markup
|
||
*
|
||
* @param string $selector See the update() method for details
|
||
* @param string $content Markup to prepend
|
||
* @param string $markup Document markup where region(s) exist
|
||
* @param array $options See the update() method for details
|
||
* @return string
|
||
*
|
||
*/
|
||
public function after($selector, $content, $markup, array $options = array()) {
|
||
$options['action'] = 'after';
|
||
return $this->replace($selector, $content, $markup, $options);
|
||
}
|
||
|
||
/**
|
||
* Remove the region(s) that match the given $selector
|
||
*
|
||
* @param string $selector See the update() method for details
|
||
* @param string $markup Document markup where region(s) exist
|
||
* @param array $options See the update() method for details
|
||
* @return string
|
||
*
|
||
*/
|
||
public function remove($selector, $markup, array $options = array()) {
|
||
$options['action'] = 'after'; // after intended
|
||
return $this->replace($selector, '', $markup, $options);
|
||
}
|
||
|
||
/**
|
||
* Identify and populate markup regions in given HTML
|
||
*
|
||
* To use this, you must set `$config->useMarkupRegions = true;` in your /site/config.php file.
|
||
* In the future it may be enabled by default for any templates with text/html content-type.
|
||
*
|
||
* This takes anything output before the opening `<!DOCTYPE` and connects it to the right places
|
||
* within the `<html>` that comes after it. For instance, if there's a `<div id='content'>` in the
|
||
* document, then a #content element output prior to the doctype will replace it during page render.
|
||
* This enables one to use delayed output as if it’s direct output. It also makes every HTML element
|
||
* in the output with an “id” attribute a region that can be populated from any template file. It’s
|
||
* a good pairing with a `$config->appendTemplateFile` that contains the main markup and region
|
||
* definitions, though can be used with or without it.
|
||
*
|
||
* Beyond replacement of elements, append, prepend, insert before, insert after, and remove are also
|
||
* supported via “pw-” prefix attributes that you can add. The attributes do not appear in the final output
|
||
* markup. When performing replacements or modifications to elements, PW will merge the attributes
|
||
* so that attributes present in the final output are present, plus any that were added by the markup
|
||
* regions. See the examples for more details.
|
||
*
|
||
* Examples
|
||
* ========
|
||
* Below are some examples. Note that “main” is used as an example “id” attribute of an element that
|
||
* appears in the main document markup, and the examples below focus on manipulating it. The examples
|
||
* assume there is a `<div id=main>` in the _main.php file (appendTemplateFile), and the lines in the
|
||
* examples would be output from a template file, which manipulates what would ultimately be output
|
||
* when the page is rendered.
|
||
*
|
||
* In the examples, a “pw-id” or “data-pw-id” attribute may be used instead of an “id” attribute, when
|
||
* or if preferred. In addition, any “pw-” attribute may be specified as a “data-pw-” attribute if you
|
||
* prefer it.
|
||
* ~~~~~~
|
||
* Replacing and removing elements
|
||
*
|
||
* <div id='main'>This replaces the #main div and merges any attributes</div>
|
||
* <div pw-replace='main'>This does the same as above</div>
|
||
* <div id='main' pw-replace>This does the same as above</div>
|
||
* <div pw-remove='main'>This removes the #main div</div>
|
||
* <div id='main' pw-remove>This removes the #main div (same as above)</div>
|
||
*
|
||
* Prepending and appending elements
|
||
*
|
||
* <div id='main' class='pw-prepend'><p>This prepends #main with this p tag</p></div>
|
||
* <p pw-prepend='main'>This does the same as above</p>
|
||
* <div id='main' pw-append><p>This appends #main with this p tag</p></div>
|
||
* <p pw-append='main'>Removes the #main div</p>
|
||
*
|
||
* Modifying attributes on an existing element
|
||
*
|
||
* <div id='main' class='bar' pw-prepend><p>This prepends #main and adds "bar" class to main</p></div>
|
||
* <div id='main' class='foo' pw-append><p>This appends #main and adds a "foo" class to #main</p></div>
|
||
* <div id='main' title='hello' pw-append>Appends #main with this text + adds title attribute to #main</div>
|
||
* <div id='main' class='-baz' pw-append>Appends #main with this text + removes class “baz” from #main</div>
|
||
*
|
||
* Inserting new elements
|
||
*
|
||
* <h2 pw-before='main'>This adds an h2 headline with this text before #main</h2>
|
||
* <footer pw-after='main'><p>This adds a footer element with this text after #main</p></footer>
|
||
* <div pw-append='main' class='foo'>This appends a div.foo to #main with this text</div>
|
||
* <div pw-prepend='main' class='bar'>This prepends a div.bar to #main with this text</div>
|
||
*
|
||
* ~~~~~~
|
||
*
|
||
* @param string $htmlDocument Document to populate regions to
|
||
* @param string|array $htmlRegions Markup containing regions (or regions array from a find call)
|
||
* @param array $options Options to modify behavior:
|
||
* - `useClassActions` (bool): Allow "pw-*" actions to be specified in class names? Per original/legacy spec. (default=false)
|
||
* @return int Number of updates made to $htmlDocument
|
||
*
|
||
*/
|
||
public function populate(&$htmlDocument, $htmlRegions, array $options = array()) {
|
||
|
||
static $recursionLevel = 0;
|
||
static $callQty = 0;
|
||
|
||
$recursionLevel++;
|
||
$callQty++;
|
||
|
||
$defaults = array(
|
||
'useClassActions' => false // allow use of "pw-*" class actions? (legacy)
|
||
);
|
||
|
||
$options = array_merge($defaults, $options);
|
||
$leftoverMarkup = '';
|
||
$hasDebugLandmark = strpos($htmlDocument, self::debugLandmark) !== false;
|
||
$debug = $hasDebugLandmark && $this->wire()->config->debug;
|
||
$debugTimer = $debug ? Debug::timer() : 0;
|
||
|
||
if(is_array($htmlRegions)) {
|
||
$regions = $htmlRegions;
|
||
$leftoverMarkup = '';
|
||
|
||
} else if($this->hasRegions($htmlRegions)) {
|
||
$htmlRegions = $this->stripRegions('<!--', $htmlRegions);
|
||
$selector = $options['useClassActions'] ? ".pw-*, id=" : "[pw-action], id=";
|
||
$regions = $this->find($selector, $htmlRegions, array(
|
||
'verbose' => true,
|
||
'leftover' => true,
|
||
'debugNote' => (isset($options['debugNote']) ? "$options[debugNote] => " : "") . "populate($callQty)",
|
||
));
|
||
$leftoverMarkup = trim($regions["leftover"]);
|
||
unset($regions["leftover"]);
|
||
|
||
} else {
|
||
$regions = array();
|
||
}
|
||
|
||
if(!count($regions)) {
|
||
$recursionLevel--;
|
||
$this->removeRegionTags($htmlDocument);
|
||
if($recursionLevel) return 0;
|
||
if(strpos($htmlDocument, 'pw-optional')) $htmlDocument = $this->stripOptional($htmlDocument, $debug);
|
||
if($debug) {
|
||
if(!count($this->debugNotes)) $this->debugNotes[] = 'No regions';
|
||
$this->populateDebugNotes($htmlDocument, $this->debugNotes, $debugTimer);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
$xregions = array(); // regions that weren't populated
|
||
$populatedNotes = array();
|
||
$rejectedNotes = array();
|
||
$updates = array();
|
||
$numUpdates = 0;
|
||
|
||
foreach($regions as /* $regionKey => */ $region) {
|
||
|
||
if(empty($region['action'])) $region['action'] = 'auto'; // replace
|
||
if(empty($region['actionTarget'])) $region['actionTarget'] = $region['pwid']; // replace
|
||
|
||
// $xregion = $region;
|
||
$action = $region['action'];
|
||
$actionTarget = $region['actionTarget'];
|
||
$regionHTML = $region['region'];
|
||
$mergeAttr = $region['attrs'];
|
||
|
||
unset($mergeAttr['id']);
|
||
$documentHasTarget = $this->hasAttribute('id', $actionTarget, $htmlDocument);
|
||
$isNew = ($region['actionType'] == 'attr' && $region['action'] != 'replace');
|
||
if(!$isNew) $isNew = $action == 'before' || $action == 'after';
|
||
|
||
if($isNew) {
|
||
// element is newly added element not already present
|
||
$mergeAttr = array();
|
||
$regionHTML = $region['html'];
|
||
$attrs = $region['attrs'];
|
||
$attrStr = count($attrs) ? ' ' . $this->renderAttributes($attrs, false) : '';
|
||
if(!strlen(trim($attrStr))) $attrStr = '';
|
||
if($region['actionType'] == 'bool') {
|
||
$regionHTML = $region['region'];
|
||
} else {
|
||
$regionHTML = str_replace($region['open'], "<$region[name]$attrStr>", $regionHTML);
|
||
}
|
||
}
|
||
|
||
if($debug) {
|
||
$debugAction = $region['action'];
|
||
if($debugAction == 'auto') $debugAction = $isNew ? 'insert' : 'replace';
|
||
if($debugAction == 'replace' && empty($region['close'])) $debugAction = 'attr-update';
|
||
$pwid = empty($region['pwid']) ? $region['actionTarget'] : $region['pwid'];
|
||
$open = $region['open'];
|
||
$openLen = strlen($open);
|
||
if($openLen > 50) $open = substr($open, 0, 30) . '[sm]... +' . ($openLen - 30) . ' bytes[/sm]>';
|
||
$debugRegionStart = "[sm]" . trim(substr($region['region'], 0, 80));
|
||
$pos = strrpos($debugRegionStart, '>');
|
||
if($pos) $debugRegionStart = substr($debugRegionStart, 0, $pos+1);
|
||
$debugRegionStart .= " … [b]" . strlen($region['region']) . " bytes[/b][/sm]";
|
||
//$debugRegionEnd = substr($region['region'], -30);
|
||
//$pos = strpos($debugRegionEnd, '</');
|
||
//if($pos !== false) $debugRegionEnd = substr($debugRegionEnd, $pos);
|
||
$region['note'] = strtoupper($debugAction) . " [b]#{$pwid}[/b] " .
|
||
($region['actionTarget'] != $pwid ? "(target=$region[actionTarget])" : "") .
|
||
"[sm]with[/sm] $open";
|
||
if($region['close']) {
|
||
$region['note'] .= $this->debugNoteStr($debugRegionStart) . $region['close'];
|
||
}
|
||
$regionNote = $region['note']; // [sm](position=$regionKey)[/sm]";
|
||
|
||
} else {
|
||
$regionNote = '';
|
||
}
|
||
|
||
if(!$documentHasTarget) {
|
||
// if the id attribute doesn't appear in the html, skip it
|
||
// $xregions[$regionKey] = $xregion;
|
||
if(self::debug) $rejectedNotes[] = $regionNote;
|
||
|
||
} else {
|
||
// update the markup
|
||
$updates[] = array(
|
||
'actionTarget' => "#$actionTarget",
|
||
'regionHTML' => $regionHTML,
|
||
'action' => $action,
|
||
'mergeAttr' => $mergeAttr,
|
||
);
|
||
if(is_string($htmlRegions)) {
|
||
// remove region markup from $htmlRegions so we can later determine what’s left
|
||
$htmlRegions = str_replace($region['html'], '', $htmlRegions);
|
||
}
|
||
$populatedNotes[] = $regionNote;
|
||
$numUpdates++;
|
||
}
|
||
}
|
||
|
||
foreach($updates as $u) {
|
||
$htmlDocument = $this->update($u['actionTarget'], $u['regionHTML'], $htmlDocument, array(
|
||
'action' => $u['action'],
|
||
'mergeAttr' => $u['mergeAttr'],
|
||
));
|
||
}
|
||
|
||
$htmlRegions = trim($htmlRegions);
|
||
|
||
if($debug) {
|
||
$leftoverBytes = strlen($leftoverMarkup);
|
||
$htmlRegionsLen = strlen($htmlRegions);
|
||
$debugNotes = array();
|
||
if($recursionLevel > 1) $debugNotes[] = "PASS: $recursionLevel";
|
||
if(count($populatedNotes)) $debugNotes = array_merge($debugNotes, $populatedNotes); // implode($bull, $populatedNotes);
|
||
if(count($rejectedNotes)) foreach($rejectedNotes as $note) $debugNotes[] = "SKIPPED: $note";
|
||
if($leftoverBytes) $debugNotes[] = "$leftoverBytes non-region bytes skipped: [sm]{$leftoverMarkup}[/sm]";
|
||
if($htmlRegionsLen) {
|
||
if($recursionLevel > 1) {
|
||
$debugNotes[] = "$htmlRegionsLen HTML-region bytes found no home after 2nd pass: [sm]{$htmlRegions}[/sm]";
|
||
} else if($this->hasRegions($htmlRegions)) {
|
||
$debugNotes[] = "$htmlRegionsLen HTML-region bytes remaining for 2nd pass: [sm]{$htmlRegions}[/sm]";
|
||
} else {
|
||
$debugNotes[] = "$htmlRegionsLen HTML bytes remaining, but no regions present: [sm]{$htmlRegions}[/sm]";
|
||
}
|
||
}
|
||
if(count($this->debugNotes)) {
|
||
$this->debugNotes = array_unique($this->debugNotes);
|
||
$debugNotes[] = "---------------";
|
||
foreach($this->debugNotes as $s) {
|
||
$debugNotes[] = $this->debugNoteStr($s);
|
||
}
|
||
}
|
||
$this->populateDebugNotes($htmlDocument, $debugNotes, $debugTimer);
|
||
} else if($hasDebugLandmark) {
|
||
$htmlDocument = str_replace(self::debugLandmark, '', $htmlDocument);
|
||
}
|
||
|
||
if(count($xregions) && $recursionLevel < 3) {
|
||
// see if they can be populated now
|
||
$numUpdates += $this->populate($htmlDocument, $xregions, $options);
|
||
}
|
||
|
||
if($recursionLevel === 1 && strlen($htmlRegions) && $this->hasRegions($htmlRegions)) {
|
||
// see if more regions can be pulled from leftover $htmlRegions
|
||
$numUpdates += $this->populate($htmlDocument, $htmlRegions, $options);
|
||
}
|
||
|
||
// remove region tags and pw-id attributes
|
||
if($recursionLevel === 1 && $this->removeRegionTags($htmlDocument)) $numUpdates++;
|
||
|
||
// if there is any leftover markup, place it above the HTML where it would usually go
|
||
if(strlen($leftoverMarkup)) {
|
||
$htmlDocument = $leftoverMarkup . $htmlDocument;
|
||
$numUpdates++;
|
||
}
|
||
|
||
$recursionLevel--;
|
||
|
||
if(!$recursionLevel) {
|
||
if($debug) {
|
||
$this->debugNotes = array();
|
||
$debugTimer = Debug::timer();
|
||
}
|
||
if(strpos($htmlDocument, 'pw-optional')) {
|
||
// strip optional regions
|
||
$htmlDocument = $this->stripOptional($htmlDocument, $debug);
|
||
}
|
||
if($debug) {
|
||
// populate any additional debug notes
|
||
if(count($this->debugNotes)) {
|
||
$this->populateDebugNotes($htmlDocument, $this->debugNotes, $debugTimer);
|
||
} else {
|
||
Debug::timer($debugTimer); // clear
|
||
}
|
||
}
|
||
}
|
||
|
||
return $numUpdates;
|
||
}
|
||
|
||
/**
|
||
* Remove any <region> or <pw-region> tags present in the markup, leaving their innerHTML contents
|
||
*
|
||
* Also removes data-pw-id and pw-id attributes
|
||
*
|
||
* @param string $html
|
||
* @return bool True if tags or attributes were removed, false if not
|
||
*
|
||
*/
|
||
public function removeRegionTags(&$html) {
|
||
|
||
$updated = false;
|
||
|
||
if(stripos($html, '</region>') !== false || strpos($html, '</pw-region>') !== false) {
|
||
$html = preg_replace('!</?(?:region|pw-region)(?:\s[^>]*>|>)!i', '', $html);
|
||
$updated = true;
|
||
}
|
||
|
||
if(stripos($html, ' data-pw-id=') || stripos($html, ' pw-id=')) {
|
||
$html = preg_replace('/(<[^>]+)(?: data-pw-id| pw-id)=["\']?[^>\s"\']+["\']?/i', '$1', $html);
|
||
$updated = true;
|
||
}
|
||
|
||
return $updated;
|
||
}
|
||
|
||
/**
|
||
* Is the given HTML markup likely to have regions?
|
||
*
|
||
* @param string $html
|
||
* @return bool
|
||
*
|
||
*/
|
||
public function hasRegions(&$html) {
|
||
if(strpos($html, ' id=') === false && strpos($html, 'pw-') === false) return false;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Does the given HTML markup have references to any pw-actions?
|
||
*
|
||
* Note: not currently used by this class.
|
||
*
|
||
* @param string $html
|
||
* @return bool
|
||
*
|
||
*/
|
||
public function hasRegionActions(&$html) {
|
||
$has = false;
|
||
foreach($this->actions as $action) {
|
||
if(strpos($html, "pw-$action") !== false) {
|
||
// found pw-action, now perform a more thorough check
|
||
if(preg_match('![="\'\s]pw-' . $action . '(?:["\'\s=][^<>]*>|>)!', $html)) {
|
||
$has = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return $has;
|
||
}
|
||
|
||
protected function debugNoteStr($str, $maxLength = 0) {
|
||
$str = str_replace(array("\r", "\n", "\t"), ' ', $str);
|
||
while(strpos($str, ' ') !== false) $str= str_replace(' ', ' ', $str);
|
||
if($maxLength) $str = substr($str, 0, $maxLength);
|
||
return trim($str);
|
||
}
|
||
|
||
protected function renderDebugNotes(array $debugNotes, $debugTimer = null) {
|
||
if(!count($debugNotes)) $debugNotes[] = "Nothing found";
|
||
if($debugTimer !== null) $debugNotes[] = '[sm]' . Debug::timer($debugTimer) . ' seconds[/sm]';
|
||
$out = "• " . implode("\n• ", $debugNotes);
|
||
$out = $this->wire()->sanitizer->entities($out);
|
||
$out = str_replace(array('[sm]', '[/sm]'), array('<small style="opacity:0.7">', '</small>'), $out);
|
||
$out = str_replace(array('[b]', '[/b]'), array('<strong>', '</strong>'), $out);
|
||
$out = "<pre class='pw-debug pw-region-debug'>$out</pre>" . self::debugLandmark;
|
||
return $out;
|
||
}
|
||
|
||
protected function populateDebugNotes(&$markup, $debugNotes = null, $debugTimer = null) {
|
||
if($debugNotes === null) $debugNotes = $this->debugNotes;
|
||
$out = $this->renderDebugNotes($debugNotes, $debugTimer);
|
||
$markup = str_replace(self::debugLandmark, $out, $markup);
|
||
}
|
||
|
||
}
|