PageRenderTime 79ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 1ms

/public_html/wire/core/WireMarkupRegions.php

https://bitbucket.org/thomas1151/mats
PHP | 1556 lines | 1371 code | 47 blank | 138 comment | 51 complexity | 595600e6b16d58214db6b5b2ed87ad8f MD5 | raw file
Possible License(s): Apache-2.0, BSD-3-Clause, LGPL-2.1, MPL-2.0-no-copyleft-exception

Large files files are truncated, but you can click here to view the full file

  1. <?php namespace ProcessWire;
  2. /**
  3. * ProcessWire Markup Regions
  4. *
  5. * Supportings finding and manipulating of markup regions in an HTML document.
  6. *
  7. * ProcessWire 3.x, Copyright 2017 by Ryan Cramer
  8. * https://processwire.com
  9. *
  10. */
  11. class WireMarkupRegions extends Wire {
  12. /**
  13. * Debug during development of this class
  14. *
  15. */
  16. const debug = false;
  17. /**
  18. * @var array
  19. *
  20. */
  21. protected $debugNotes = array();
  22. /**
  23. * HTML tag names that require no closing tag
  24. *
  25. * @var array
  26. *
  27. */
  28. protected $selfClosingTags = array(
  29. 'link',
  30. 'area',
  31. 'base',
  32. 'br',
  33. 'col',
  34. 'command',
  35. 'embed',
  36. 'hr',
  37. 'img',
  38. 'input',
  39. 'keygen',
  40. 'link',
  41. 'meta',
  42. 'param',
  43. 'source',
  44. 'track',
  45. 'wbr',
  46. );
  47. /**
  48. * Supported markup region actions
  49. *
  50. * @var array
  51. *
  52. */
  53. protected $actions = array(
  54. 'prepend',
  55. 'append',
  56. 'before',
  57. 'after',
  58. 'replace',
  59. 'remove',
  60. );
  61. /**
  62. * Locate and return all regions of markup having the given attribute
  63. *
  64. * @param string $selector Specify one of the following:
  65. * - Name of an attribute that must be present, i.e. "data-region", or "attribute=value" or "tag[attribute=value]".
  66. * - Specify `#name` to match a specific `id='name'` attribute.
  67. * - Specify `.name` or `tag.name` to match a specific `class='name'` attribute (class can appear anywhere in class attribute).
  68. * - Specify `.name*` to match class name starting with a name prefix.
  69. * - Specify `<tag>` to match all of those HTML tags (i.e. `<head>`, `<body>`, `<title>`, `<footer>`, etc.)
  70. * @param string $markup HTML document markup to perform the find in.
  71. * @param array $options Optional options to modify default behavior:
  72. * - `single` (bool): Return just the markup from the first matching region (default=false).
  73. * - `verbose` (bool): Specify true to return array of verbose info detailing what was found (default=false).
  74. * - `wrap` (bool): Include wrapping markup? Default is false for id or attribute matches, and true for class matches.
  75. * - `max` (int): Maximum allowed regions to return (default=500).
  76. * - `exact` (bool): Return region markup exactly as-is? (default=false). Specify true when using return values for replacement.
  77. * - `leftover` (bool): Specify true if you want to return a "leftover" key in return value with leftover markup.
  78. * @return array Returns one of the following:
  79. * - Associative array of [ 'id' => 'markup' ] when finding specific attributes or #id attributes.
  80. * - Regular array of markup regions when finding regions having a specific class attribute.
  81. * - Associative array of verbose information when the verbose option is used.
  82. * @throws WireException if given invalid $find string
  83. *
  84. */
  85. public function find($selector, $markup, array $options = array()) {
  86. if(strpos($selector, ',')) return $this->findMulti($selector, $markup, $options);
  87. $defaults = array(
  88. 'single' => false,
  89. 'verbose' => false,
  90. 'wrap' => null,
  91. 'max' => 500,
  92. 'exact' => false,
  93. 'leftover' => false,
  94. );
  95. $options = array_merge($defaults, $options);
  96. $selectorInfo = $this->parseFindSelector($selector);
  97. $tests = $selectorInfo['tests'];
  98. $findTag = $selectorInfo['findTag'];
  99. $hasClass = $selectorInfo['hasClass'];
  100. $regions = array();
  101. $_markup = self::debug ? $markup : '';
  102. if(self::debug) {
  103. $options['debugNote'] = (empty($options['debugNote']) ? "" : "$options[debugNote] => ") . "find($selector)";
  104. }
  105. // strip out comments if markup isn't required to stay the same
  106. if(!$options['exact']) $markup = $this->stripRegions('<!--', $markup);
  107. // determine auto value for wrap option
  108. if(is_null($options['wrap'])) $options['wrap'] = $hasClass ? true : false;
  109. $startPos = 0;
  110. $whileCnt = 0; // number of do-while loop completions
  111. $iterations = 0; // total number of iterations, including continue statements
  112. do {
  113. if(++$iterations >= $options['max'] * 2) break;
  114. // find all the positions where each test appears
  115. $positions = array();
  116. foreach($tests as $str) {
  117. $pos = stripos($markup, $str, $startPos);
  118. if($pos === false) continue;
  119. if($selector === '.pw-*') {
  120. // extra validation for .pw-* selectors to confirm they match a pw-[action] (legacy support)
  121. $testAction = '';
  122. $testPW = substr($markup, $pos, 20);
  123. foreach($this->actions as $testAction) {
  124. if(strpos($testPW, $testAction) !== false) {
  125. $testAction = true;
  126. break;
  127. }
  128. }
  129. if($testAction !== true) continue; // if not a pw-[action] then skip
  130. }
  131. if($str[0] == '<') $pos++; // HTML tag match, bump+1 to enable match
  132. $positions[$pos] = $pos;
  133. }
  134. // if no tests matched, we can abort now
  135. if(empty($positions)) break;
  136. // sort the matching test positions closest to furthest and get the first match
  137. ksort($positions);
  138. $pos = reset($positions);
  139. $startPos = $pos;
  140. $markupBefore = substr($markup, 0, $pos);
  141. $markupAfter = substr($markup, $pos);
  142. $openTagPos = strrpos($markupBefore, '<');
  143. $closeOpenTagPos = strpos($markupAfter, '>');
  144. $startPos += $closeOpenTagPos; // for next iteration, if a continue occurs
  145. // if the orders of "<" and ">" aren't what's expected in our markupBefore and markupAfter, then abort
  146. $testPos = strpos($markupAfter, '<');
  147. if($testPos !== false && $testPos < $closeOpenTagPos) continue;
  148. if(strrpos($markupBefore, '>') > $openTagPos) continue;
  149. // build the HTML tag by combining the halfs from markupBefore and markupAfter
  150. $tag = substr($markupBefore, $openTagPos) . substr($markupAfter, 0, $closeOpenTagPos + 1);
  151. $tagInfo = $this->getTagInfo($tag);
  152. // pre-checks to make sure this iteration is allowed
  153. if($findTag && $tagInfo['name'] !== $findTag) continue;
  154. if(!$tagInfo['action'] && $hasClass && !$this->hasClass($hasClass, $tagInfo['classes'])) continue;
  155. // build the region (everything in $markupAfter that starts after the ">")
  156. $regionMarkup = empty($tagInfo['close']) ? '' : substr($markupAfter, $closeOpenTagPos + 1);
  157. $region = $this->getTagRegion($regionMarkup, $tagInfo, $options);
  158. if($options['single']) {
  159. // single mode means we just return the markup
  160. $regions = $region;
  161. break;
  162. } else {
  163. while(isset($regions[$pos])) $pos++;
  164. $regions[$pos] = $region;
  165. }
  166. $replaceQty = 0;
  167. $markup = str_replace($region['html'], '', $markup, $replaceQty);
  168. if(!$replaceQty) {
  169. // region html was not present, try replacement without closing tag (which might be missing)
  170. $markup = str_replace($region['open'] . $region['region'], '', $markup, $replaceQty);
  171. if($replaceQty) {
  172. $this->debugNotes[] = "ERROR: missing closing tag for: $region[open]";
  173. } else {
  174. $this->debugNotes[] = "ERROR: unable to populate: $region[open]";
  175. }
  176. }
  177. if($replaceQty) $startPos = 0;
  178. $markup = trim($markup);
  179. if(empty($markup)) break;
  180. } while(++$whileCnt < $options['max']);
  181. /*
  182. foreach($regions as $regionKey => $region) {
  183. foreach($regions as $rk => $r) {
  184. if($rk === $regionKey) continue;
  185. if(strpos($region['html'], $r['html']) !== false) {
  186. $regions[$regionKey]['region'] = str_replace($r['html'], '', $region['region']);
  187. //$k = 'html';
  188. //$this->debugNotes[] =
  189. // "\nREPLACE “" . htmlentities($r[$k]) . "”".
  190. // "\nIN “" . htmlentities($region[$k]) . "”" .
  191. // "\nRESULT “" . htmlentities($regions[$regionKey][$k]) . "”";
  192. }
  193. }
  194. }
  195. */
  196. if($options['leftover']) $regions["leftover"] = trim($markup);
  197. if(self::debug && $options['verbose']) {
  198. $debugNote = "$options[debugNote] in [sm]" . $this->debugNoteStr($_markup, 50) . " …[/sm] => ";
  199. $numRegions = 0;
  200. foreach($regions as $key => $region) {
  201. if($key == 'leftover') continue;
  202. $details = empty($region['details']) ? '' : " ($region[details]) ";
  203. $debugNote .= "$region[name]#$region[pwid]$details, ";
  204. $numRegions++;
  205. }
  206. if(!$numRegions) $debugNote .= 'NONE';
  207. $this->debugNotes[] = rtrim($debugNote, ", ") . " [sm](iterations=$iterations)[/sm]";
  208. }
  209. return $regions;
  210. }
  211. /**
  212. * Multi-selector version of find(), where $selector contains CSV
  213. *
  214. * @param string $selector
  215. * @param string $markup
  216. * @param array $options
  217. * @return array
  218. *
  219. */
  220. protected function findMulti($selector, $markup, array $options = array()) {
  221. $regions = array();
  222. $o = $options;
  223. $o['leftover'] = true;
  224. $leftover = '';
  225. foreach(explode(',', $selector) as $s) {
  226. foreach($this->find(trim($s), $markup, $o) as $key => $region) {
  227. if($key === 'leftover') {
  228. $leftover .= $region;
  229. } else {
  230. while(isset($regions[$key])) $key++;
  231. $regions[$key] = $region;
  232. }
  233. }
  234. $markup = $leftover;
  235. }
  236. if(!empty($options['leftover'])) {
  237. if(!empty($leftover)) {
  238. foreach($regions as $key => $region) {
  239. if(strpos($leftover, $region['html']) !== false) {
  240. $leftover = str_replace($region['html'], '', $leftover);
  241. }
  242. }
  243. }
  244. $regions['leftover'] = $leftover;
  245. }
  246. ksort($regions);
  247. return $regions;
  248. }
  249. /**
  250. * Does the given class exist in given $classes array?
  251. *
  252. * @param string $class May be class name, or class prefix if $class has "*" at end.
  253. * @param array $classes
  254. * @return bool|string Returns false if no match, or class name if matched
  255. *
  256. */
  257. protected function hasClass($class, array $classes) {
  258. $has = false;
  259. if(strpos($class, '*')) {
  260. // partial class match
  261. $class = rtrim($class, '*');
  262. foreach($classes as $c) {
  263. if(strpos($c, $class) === 0) {
  264. $has = $c;
  265. break;
  266. }
  267. }
  268. } else {
  269. // exact class match
  270. $key = array_search($class, $classes);
  271. if($key !== false) $has = $classes[$key];
  272. }
  273. return $has;
  274. }
  275. /**
  276. * Given a $find selector return array with tests and other meta info
  277. *
  278. * @param string $find
  279. * @return array Returns array of [
  280. * 'tests' => [ 0 => '', 1 => '', ...],
  281. * 'find' => '',
  282. * 'findTag' => '',
  283. * 'hasClass' => '',
  284. * ]
  285. *
  286. */
  287. protected function parseFindSelector($find) {
  288. $findTag = '';
  289. $hasClass = '';
  290. $finds = array();
  291. if(strpos($find, '.') > 0) {
  292. // i.e. "div.myclass"
  293. list($findTag, $_find) = explode('.', $find, 2);
  294. if($this->wire('sanitizer')->alphanumeric($findTag) === $findTag) {
  295. $find = ".$_find";
  296. } else {
  297. $findTag = '';
  298. }
  299. }
  300. $c = substr($find, 0, 1);
  301. $z = substr($find, -1);
  302. if($c === '#') {
  303. // match an id, pw-id or data-pw-id attribute
  304. $find = trim($find, '# ');
  305. foreach(array('pw-id', 'data-pw-id', 'id') as $attr) {
  306. $finds[] = " $attr=\"$find\"";
  307. $finds[] = " $attr='$find'";
  308. $finds[] = " $attr=$find ";
  309. $finds[] = " $attr=$find>";
  310. }
  311. } else if($find === '[pw-action]') {
  312. // find pw-[action] boolean attributes
  313. foreach($this->actions as $action) {
  314. $finds[] = " data-pw-$action ";
  315. $finds[] = " data-pw-$action>";
  316. $finds[] = " data-pw-$action=";
  317. $finds[] = " pw-$action ";
  318. $finds[] = " pw-$action>";
  319. $finds[] = " pw-$action=";
  320. }
  321. /*
  322. } else if($c === '[' && $z === ']') {
  323. // find any attribute (not currently used by markup regions)
  324. if(strpos($find, '=') === false) {
  325. // match an attribute only
  326. $attr = trim($find, '[]*+');
  327. $tail = substr($find, -2);
  328. if($tail === '*]') {
  329. // match an attribute prefix
  330. $finds = array(" $attr");
  331. } else {
  332. // match a whole attribute name
  333. $finds = array(
  334. " $attr ",
  335. " $attr>",
  336. " $attr=",
  337. );
  338. }
  339. } else {
  340. // match attribute and value (not yet implemented)
  341. }
  342. */
  343. } else if($c === '.' && $z === '*') {
  344. // match a class name prefix or action prefix
  345. $find = trim($find, '.*');
  346. $finds = array(
  347. ' class="' . $find,
  348. " class='$find",
  349. " class=$find",
  350. " $find",
  351. "'$find",
  352. "\"$find",
  353. );
  354. if(strpos($find, 'pw-') !== false) $finds[] = " data-$find";
  355. $hasClass = "$find*";
  356. } else if($c === '.') {
  357. // match a class name or action
  358. $find = trim($find, '.');
  359. $finds = array(
  360. ' class="' . $find . '"',
  361. " class='$find'",
  362. " class=$find ",
  363. " class=$find>",
  364. " $find'",
  365. "'$find ",
  366. " $find\"",
  367. "\"$find ",
  368. " $find ",
  369. );
  370. if(strpos($find, 'pw-') !== false) $finds[] = " data-$find";
  371. $hasClass = $find;
  372. } else if($c === '<') {
  373. // matching a single-use HTML tag
  374. $finds = array(
  375. $find,
  376. rtrim($find, '>') . ' ',
  377. );
  378. } else if(strpos($find, '=') !== false) {
  379. // some other specified attribute in attr=value format
  380. if(strpos($find, '[') !== false && $z === ']') {
  381. // i.e. div[attr=value]
  382. list($findTag, $find) = explode('[', $find, 2);
  383. $find = rtrim($find, ']');
  384. }
  385. list($attr, $val) = explode('=', $find);
  386. if(strlen($val)) {
  387. $finds = array(
  388. " $attr=\"$val\"",
  389. " $attr='$val'",
  390. " $attr=$val",
  391. " $attr=$val>"
  392. );
  393. } else {
  394. $finds = array(" $attr=");
  395. }
  396. if(strpos($find, 'id=') === 0) {
  397. // when finding an id attribute, also allow for "pw-id=" and "data-pw-id="
  398. foreach($finds as $find) {
  399. $find = ltrim($find);
  400. $finds[] = " pw-id=";
  401. $finds[] = " data-pw-id=";
  402. }
  403. }
  404. } else if($z === '*') {
  405. // data-pw-* matches non-value attribute starting with a prefix
  406. $finds = array(
  407. " $find",
  408. );
  409. } else {
  410. // "data-something" matches attributes with no value, like "<div data-something>"
  411. $finds = array(
  412. " $find ",
  413. " $find>",
  414. " $find"
  415. );
  416. }
  417. return array(
  418. 'tests' => $finds,
  419. 'selector' => $find,
  420. 'findTag' => $findTag,
  421. 'hasClass' => $hasClass
  422. );
  423. }
  424. /**
  425. * Given all markup after a tag, return just the portion that is the tag body/region
  426. *
  427. * @param string $region Markup that occurs after the ">" of the tag you want to get the region of.
  428. * @param array $tagInfo Array returned by getTagInfo method.
  429. * @param array $options Options to modify behavior, see getMarkupRegions $options argument.
  430. * - `verbose` (bool): Verbose mode (default=false)
  431. * - `wrap` (bool): Whether or not wrapping markup should be included (default=false)
  432. * @return array|string Returns string except when verbose mode enabled it returns array.
  433. *
  434. */
  435. protected function getTagRegion($region, array $tagInfo, array $options) {
  436. // options
  437. $verbose = empty($options['verbose']) ? false : true;
  438. $wrap = empty($options['wrap']) ? false : $options['wrap'];
  439. if($verbose) $verboseRegion = array(
  440. 'name' => "$tagInfo[name]",
  441. 'pwid' => "$tagInfo[pwid]",
  442. 'open' => "$tagInfo[src]",
  443. 'close' => "$tagInfo[close]",
  444. 'attrs' => $tagInfo['attrs'],
  445. 'classes' => $tagInfo['classes'],
  446. 'action' => $tagInfo['action'],
  447. 'actionType' => $tagInfo['actionType'],
  448. 'actionTarget' => $tagInfo['actionTarget'],
  449. 'error' => false,
  450. 'details' => '',
  451. 'region' => '', // region without wrapping tags
  452. 'html' => '', // region with wrapping tags
  453. );
  454. if($tagInfo['pwid']) $tagID = $tagInfo['pwid'];
  455. else if(!empty($tagInfo['id'])) $tagID = $tagInfo['id'];
  456. else if(!empty($tagInfo['attrs']['id'])) $tagID = $tagInfo['attrs']['id'];
  457. else if(!empty($tagInfo['actionTarget'])) $tagID = $tagInfo['actionTarget'];
  458. else $tagID = '';
  459. $selfClose = empty($tagInfo['close']);
  460. $closeHint = "$tagInfo[close]<!--#$tagID-->";
  461. $closeQty = $selfClose ? 1 : substr_count($region, $tagInfo['close']);
  462. if(!$closeQty) {
  463. // there is no close tag, meaning all of markupAfter is the region
  464. if($verbose) $verboseRegion['details'] = 'No closing tag, matched rest of document';
  465. } else if($selfClose) {
  466. $region = '';
  467. if($verbose) $verboseRegion['details'] = 'Self closing tag (empty region)';
  468. } else if($tagID && false !== ($pos = strpos($region, $closeHint))) {
  469. // close tag indicates what it closes, i.e. “</div><!--#content-->”
  470. $region = substr($region, 0, $pos);
  471. $tagInfo['close'] = $closeHint;
  472. if($verbose) $verboseRegion['details'] = "Fast match with HTML comment hint";
  473. } else if($closeQty === 1) {
  474. // just one close tag present, making our job easy
  475. $region = substr($region, 0, strrpos($region, $tagInfo['close']));
  476. if($verbose) $verboseRegion['details'] = "Only 1 possible closing tag: $tagInfo[close]";
  477. } else {
  478. // multiple close tags present, must figure out which is the right one
  479. $testStart = 0;
  480. $doCnt = 0;
  481. $maxDoCnt = 100000;
  482. $openTag1 = "<$tagInfo[name]>";
  483. $openTag2 = "<$tagInfo[name] ";
  484. $fail = false;
  485. do {
  486. $doCnt++;
  487. $testPos = stripos($region, $tagInfo['close'], $testStart);
  488. if($testPos === false) {
  489. $fail = true;
  490. break;
  491. }
  492. $test = substr($region, 0, $testPos);
  493. $openCnt = substr_count($test, $openTag1) + substr_count($test, $openTag2);
  494. $closeCnt = substr_count($test, $tagInfo['close']);
  495. if($openCnt == $closeCnt) {
  496. // open and close tags balance, meaning we've found our region
  497. $region = $test;
  498. break;
  499. } else {
  500. // tags within don't balance, so try again
  501. $testStart = $testPos + strlen($tagInfo['close']);
  502. }
  503. } while($doCnt < $maxDoCnt && $testStart < strlen($region));
  504. if($fail) {
  505. if($verbose) {
  506. $verboseRegion['error'] = true;
  507. $verboseRegion['details'] = "Failed to find closing tag $tagInfo[close] after $doCnt iterations";
  508. } else {
  509. $region = 'error';
  510. }
  511. } else if($doCnt >= $maxDoCnt) {
  512. if($verbose) {
  513. $verboseRegion['error'] = true;
  514. $verboseRegion['details'] = "Failed region match after $doCnt tests for <$tagInfo[name]> tag(s)";
  515. } else {
  516. $region = 'error';
  517. }
  518. } else if($verbose) {
  519. $verboseRegion['details'] = "Matched region after testing $doCnt <$tagInfo[name]> tag(s)";
  520. }
  521. }
  522. // region with wrapping tag
  523. $wrapRegion = $selfClose ? "$tagInfo[src]" : "$tagInfo[src]$region$tagInfo[close]";
  524. if($verbose) {
  525. $verboseRegion['region'] = $region;
  526. $verboseRegion['html'] = $wrapRegion;
  527. /*
  528. if(self::debug) {
  529. $debugNote = (isset($options['debugNote']) ? "$options[debugNote] => " : "") .
  530. "getTagRegion() => $verboseRegion[name]#$verboseRegion[pwid] " .
  531. "[sm]$verboseRegion[details][/sm]";
  532. $this->debugNotes[] = $debugNote;
  533. }
  534. */
  535. return $verboseRegion;
  536. }
  537. // include wrapping markup if asked for
  538. if($wrap) $region = $wrapRegion;
  539. return $region;
  540. }
  541. /**
  542. * Given HTML tag like “<div id='foo' class='bar baz'>” return associative array of info about it
  543. *
  544. * Returned info includes:
  545. * - `name` (string): Tag name
  546. * - `id` (string): Value of id attribute
  547. * - `pwid` (string): PW region ID from 'pw-id' or 'data-pw-id', or if not present, then same as 'id'
  548. * - `action` (string): Action for this region, without “pw-” prefix.
  549. * - `actionTarget` (string): Target id for the action, if applicable.
  550. * - `actionType` (string): "class" if action specified as class name, "attr" if specified as a pw- or data-pw attribute.
  551. * - `classes` (array): Array of class names (from class attribute).
  552. * - `attrs` (array): Associative array of all attributes, all values are strings.
  553. * - `attrStr` (string): All attributes in a string
  554. * - `tag` (string): The entire tag as given
  555. * - `close` (string): The HTML string that would close this tag
  556. *
  557. * @param string $tag Must be a tag in format “<tag attrs>”
  558. * @return array
  559. *
  560. */
  561. public function getTagInfo($tag) {
  562. $attrs = array();
  563. $attrStr = '';
  564. $name = '';
  565. $tagName = '';
  566. $val = '';
  567. $inVal = false;
  568. $inTagName = true;
  569. $inQuote = '';
  570. $originalTag = $tag;
  571. // normalize tag to include only what's between "<" and ">" and remove unnecessary whitespace
  572. $tag = str_replace(array("\r", "\n", "\t"), ' ', $tag);
  573. $tag = trim($tag, '</> ');
  574. $pos = strpos($tag, '>');
  575. if($pos) $tag = substr($tag, 0, $pos);
  576. while(strpos($tag, ' ') !== false) $tag = str_replace(' ', ' ', $tag);
  577. $tag = str_replace(array(' =', '= '), '=', $tag);
  578. $tag .= ' '; // extra space for loop below
  579. // iterate through each character in the tag
  580. for($n = 0; $n < strlen($tag); $n++) {
  581. $c = $tag[$n];
  582. if($c == '"' || $c == "'") {
  583. if($inVal) {
  584. // building a value
  585. if($inQuote === $c) {
  586. // end of value, populate to attrs and reset val
  587. $attrs[$name] = $val;
  588. $inQuote = false;
  589. $inVal = false;
  590. $name = '';
  591. $val = '';
  592. } else if(!strlen($val)) {
  593. // starting a value
  594. $inQuote = $c;
  595. } else {
  596. // continue appending value
  597. $val .= $c;
  598. }
  599. } else {
  600. // not building a value, but found a quote, not sure what it's for, so skip it
  601. }
  602. } else if($c === ' ') {
  603. // space can either separate attributes or be part of a quoted value
  604. if($inVal) {
  605. if($inQuote) {
  606. // quoted space is part of value
  607. $val .= $c;
  608. } else {
  609. // unquoted space ends the attribute value
  610. if($name) $attrs[$name] = $val;
  611. $inVal = false;
  612. $name = '';
  613. $val = '';
  614. }
  615. } else {
  616. if($name && !isset($attrs[$name])) {
  617. // attribute without a value
  618. $attrs[$name] = true;
  619. }
  620. // start of a new attribute name
  621. $name = '';
  622. $inTagName = false;
  623. }
  624. } else if($c === '=') {
  625. // equals separates attribute names from values, or can appear in an attribute value
  626. if($inVal && $inQuote) {
  627. // part of a value
  628. $val .= $c;
  629. } else {
  630. // start new value
  631. $inVal = true;
  632. }
  633. } else if($inVal) {
  634. // append attribute value
  635. $val .= $c;
  636. } else if(trim($c)) {
  637. // tag name or attribute name
  638. if($inTagName) {
  639. $tagName .= $c;
  640. } else {
  641. $name .= $c;
  642. }
  643. }
  644. if(!$inTagName) $attrStr .= $c;
  645. }
  646. if($name && !isset($attrs[$name])) $attrs[$name] = $val;
  647. $tag = rtrim($tag); // remove extra space we added
  648. $tagName = strtolower($tagName);
  649. $selfClosing = in_array($tagName, $this->selfClosingTags);
  650. $classes = isset($attrs['class']) ? explode(' ', $attrs['class']) : array();
  651. $id = isset($attrs['id']) ? $attrs['id'] : '';
  652. $pwid = '';
  653. $action = '';
  654. $actionTarget = '';
  655. $actionType = '';
  656. // determine action and action target from attributes
  657. foreach($attrs as $name => $value) {
  658. $pwpos = strpos($name, 'pw-');
  659. if($pwpos === false) continue;
  660. if($name === 'pw-id' || $name === 'data-pw-id') {
  661. // id attribute
  662. if(!$pwid) $pwid = $value;
  663. unset($attrs[$name]);
  664. } else if($pwpos === 0) {
  665. // action attribute
  666. list($prefix, $action) = explode('-', $name, 2);
  667. if($prefix) {} // ignore
  668. } else if(strpos($name, 'data-pw-') === 0) {
  669. // action data attribute
  670. list($ignore, $prefix, $action) = explode('-', $name, 3);
  671. if($ignore && $prefix) {} // ignore
  672. }
  673. if($action && !$actionTarget) {
  674. if(strpos($action, '-')) {
  675. list($action, $actionTarget) = explode('-', $action, 2);
  676. } else {
  677. $actionTarget = $value;
  678. }
  679. if($actionTarget && in_array($action, $this->actions)) {
  680. // found a valid action and target
  681. unset($attrs[$name]);
  682. $actionType = $actionTarget === true ? 'bool' : 'attr';
  683. } else {
  684. // unrecognized action
  685. $action = '';
  686. $actionTarget = '';
  687. }
  688. }
  689. }
  690. // if action was not specified as an attribute, see if action is specified as a class name
  691. if(!$action) foreach($classes as $key => $class) {
  692. if(strpos($class, 'pw-') !== 0) continue;
  693. list($prefix, $action) = explode('-', $class, 2);
  694. if(strpos($action, '-')) list($action, $actionTarget) = explode('-', $action, 2);
  695. if($prefix && $actionTarget) {} // ignore
  696. if(in_array($action, $this->actions)) {
  697. // valid action, remove action from classes and class attribute
  698. unset($classes[$key]);
  699. $attrs['class'] = implode(' ', $classes);
  700. $actionType = 'class';
  701. break;
  702. } else {
  703. // unrecognized action
  704. $action = '';
  705. $actionTarget = '';
  706. }
  707. }
  708. if(!$pwid) $pwid = $id;
  709. // if there's an action, but no target, the target is assumed to be the pw-id or id
  710. if($action && (!$actionTarget || $actionTarget === true)) $actionTarget = $pwid;
  711. $info = array(
  712. 'id' => $id,
  713. 'pwid' => $pwid ? $pwid : $id,
  714. 'name' => $tagName,
  715. 'classes' => $classes,
  716. 'attrs' => $attrs,
  717. 'attrStr' => $attrStr,
  718. 'src' => $originalTag,
  719. 'tag' => "<$tag>",
  720. 'close' => $selfClosing ? "" : "</$tagName>",
  721. 'action' => $action,
  722. 'actionTarget' => $actionTarget,
  723. 'actionType' => $actionType,
  724. );
  725. return $info;
  726. }
  727. /**
  728. * Strip the given region non-nested tags from the document
  729. *
  730. * Note that this only works on non-nested tags like HTML comments, script or style tags.
  731. *
  732. * @param string $tag Specify "<!--" to remove comments or "<script" to remove scripts, or "<tag" for any other tags.
  733. * @param string $markup Markup to remove the tags from
  734. * @param bool $getRegions Specify true to return array of the strip regions rather than the updated markup
  735. * @return string|array
  736. *
  737. */
  738. public function stripRegions($tag, $markup, $getRegions = false) {
  739. $startPos = 0;
  740. $regions = array();
  741. $open = strpos($tag, '<') === 0 ? $tag : "<$tag";
  742. $close = $tag == '<!--' ? '-->' : '</' . trim($tag, '<>') . '>';
  743. // keep comments that start with <!--#
  744. if($tag == "<!--" && strpos($markup, "<!--#") !== false) {
  745. $hasHints = true;
  746. $markup = str_replace("<!--#", "<!~~#", $markup);
  747. $markup = preg_replace('/<!--#([-_.a-zA-Z0-9]+)-->/', '<!~~$1~~>', $markup);
  748. } else {
  749. $hasHints = false;
  750. }
  751. do {
  752. $pos = stripos($markup, $open, $startPos);
  753. if($pos === false) break;
  754. $endPos = stripos($markup, $close, $pos);
  755. if($endPos === false) {
  756. $endPos = strlen($markup);
  757. } else {
  758. $endPos += strlen($close);
  759. }
  760. $regions[] = substr($markup, $pos, $endPos - $pos);
  761. $startPos = $endPos;
  762. } while(1);
  763. if($getRegions) return $regions;
  764. if(count($regions)) $markup = str_replace($regions, '', $markup);
  765. if($hasHints) {
  766. // keep comments that start with <!--#
  767. $markup = str_replace(array("<!~~#", "~~>"), array("<!--#", "-->"), $markup);
  768. }
  769. return $markup;
  770. }
  771. /**
  772. * Merge attributes from one HTML tag to another
  773. *
  774. * - Attributes (except class) that appear in $mergeTag replace those in $htmlTag.
  775. * - Attributes in $mergeTag not already present in $htmlTag are added to it.
  776. * - Class attribute is combined with all classes from $htmlTag and $mergeTag.
  777. * - The tag name from $htmlTag is used, and the one from $mergeTag is ignored.
  778. *
  779. * @param string $htmlTag HTML tag string, optionally containing attributes
  780. * @param array|string $mergeTag HTML tag to merge (or attributes array)
  781. * @return string Updated HTML tag string with merged attributes
  782. *
  783. */
  784. public function mergeTags($htmlTag, $mergeTag) {
  785. if(is_string($mergeTag)) {
  786. $mergeTagInfo = $this->getTagInfo($mergeTag);
  787. $mergeAttrs = $mergeTagInfo['attrs'];
  788. } else {
  789. $mergeAttrs = $mergeTag;
  790. }
  791. $tagInfo = $this->getTagInfo($htmlTag);
  792. $attrs = $tagInfo['attrs'];
  793. $changes = 0;
  794. foreach($mergeAttrs as $name => $value) {
  795. if(isset($attrs[$name])) {
  796. // attribute is already present
  797. if($attrs[$name] === $value) continue;
  798. if($name === 'class') {
  799. // merge classes
  800. $classes = explode(' ', $value);
  801. $classes = array_merge($tagInfo['classes'], $classes);
  802. $classes = array_unique($classes);
  803. // identify remove classes
  804. foreach($classes as $key => $class) {
  805. if(strpos($class, '-') !== 0) continue;
  806. $removeClass = ltrim($class, '-');
  807. unset($classes[$key]);
  808. while(false !== ($k = array_search($removeClass, $classes))) unset($classes[$k]);
  809. }
  810. $attrs['class'] = implode(' ', $classes);
  811. } else {
  812. // replace
  813. $attrs[$name] = $value;
  814. }
  815. } else {
  816. // add attribute not already present
  817. $attrs[$name] = $value;
  818. }
  819. $changes++;
  820. }
  821. if($changes) {
  822. $htmlTag = "<$tagInfo[name] " . $this->renderAttributes($attrs, false);
  823. $htmlTag = trim($htmlTag) . '>';
  824. }
  825. return $htmlTag;
  826. }
  827. /**
  828. * Given an associative array of “key=value” attributes, render an HTML attribute string of them
  829. *
  830. * - For boolean attributes without value (like "checked" or "selected") specify boolean true as the value.
  831. * - If value of any attribute is an array, it will be converted to a space-separated string.
  832. * - Values get entity encoded, unless you specify false for the second argument.
  833. *
  834. * @param array $attrs Associative array of attributes.
  835. * @param bool $encode Entity encode attribute values? Default is true, so if they are already encoded, specify false.
  836. * @param string $quote Quote style, specify double quotes, single quotes, or blank for none except when required (default=")
  837. * @return string
  838. *
  839. */
  840. public function renderAttributes(array $attrs, $encode = true, $quote = '"') {
  841. $str = '';
  842. foreach($attrs as $name => $value) {
  843. if(!ctype_alnum($name)) {
  844. // only allow [-_a-zA-Z] attribute names
  845. $name = $this->wire('sanitizer')->name($name);
  846. }
  847. // convert arrays to space separated string
  848. if(is_array($value)) $value = implode(' ', $value);
  849. if($value === true) {
  850. // attribute without value, i.e. "checked" or "selected" or "data-uk-grid", etc.
  851. $str .= "$name ";
  852. continue;
  853. } else if($name == 'class' && !strlen($value)) {
  854. continue;
  855. }
  856. $q = $quote;
  857. if(!$q && !ctype_alnum($value)) $q = '"';
  858. if($encode) {
  859. // entity encode value
  860. $value = $this->wire('sanitizer')->entities($value);
  861. } else if(strpos($value, '"') !== false && strpos($value, "'") === false) {
  862. // if value has a quote in it, use single quotes rather than double quotes
  863. $q = "'";
  864. }
  865. $str .= "$name=$q$value$q ";
  866. }
  867. return trim($str);
  868. }
  869. /**
  870. * Does the given attribute name and value appear somewhere in the given html?
  871. *
  872. * @param string $name
  873. * @param string|bool $value Value to find, or specify boolean true for boolean attribute without value
  874. * @param string $html
  875. * @return bool Returns false if it doesn't appear, true if it does
  876. *
  877. */
  878. public function hasAttribute($name, $value, &$html) {
  879. $pos = null;
  880. if($value === true) {
  881. $tests = array(
  882. " $name ",
  883. " $name>",
  884. " $name=",
  885. " $name/",
  886. );
  887. } else {
  888. $tests = array(
  889. " $name=\"$value\"",
  890. " $name='$value'",
  891. );
  892. // if there's no space in value, we also check non-quoted values
  893. if(strpos($value, ' ') === false) {
  894. $tests[] = " $name=$value ";
  895. $tests[] = " $name=$value>";
  896. }
  897. }
  898. if($name == 'id') {
  899. foreach($tests as $test) {
  900. $test = ltrim($test);
  901. $tests[] = " pw-id-$test";
  902. $tests[] = " data-pw-id-$test";
  903. }
  904. }
  905. foreach($tests as $test) {
  906. $pos = stripos($html, $test);
  907. if($pos === false) continue;
  908. // if another tag starts before the one in the attribute closes
  909. // then the matched attribute is apparently not part of an HTML tag
  910. $close = strpos($html, '>', $pos);
  911. $open = strpos($html, '<', $pos);
  912. if($close > $open) $pos = false;
  913. if($pos !== false) break;
  914. }
  915. if($pos === false && stripos($html, $name) !== false && stripos($html, $value) !== false) {
  916. // maybe doesn't appear due to some whitespace difference, check again using a regex
  917. if($name == 'id') {
  918. $names = '(id|pw-id|data-pw-id)';
  919. } else {
  920. $names = preg_quote($name);
  921. }
  922. if($value === true) {
  923. $regex = '!<[^<>]*\s' . $names . '[=\s/>]!i';
  924. } else {
  925. $regex = '/<[^<>]*\s' . $names . '\s*=\s*["\']?' . preg_quote($value) . '(?:["\']|[\s>])/i';
  926. }
  927. if(preg_match($regex, $html)) $pos = true;
  928. }
  929. return $pos !== false;
  930. }
  931. /**
  932. * Update the region(s) that match the given $selector with the given $content (markup/text)
  933. *
  934. * @param string $selector Specify one of the following:
  935. * - Name of an attribute that must be present, i.e. "data-region", or "attribute=value" or "tag[attribute=value]".
  936. * - Specify `#name` to match a specific `id='name'` attribute.
  937. * - Specify `.name` or `tag.name` to match a specific `class='name'` attribute (class can appear anywhere in class attribute).
  938. * - Specify `<tag>` to match all of those HTML tags (i.e. `<head>`, `<body>`, `<title>`, `<footer>`, etc.)
  939. * @param string $content Markup/text to update with
  940. * @param string $markup Document markup where region(s) exist
  941. * @param array $options Specify any of the following:
  942. * - `action` (string): May be 'replace', 'append', 'prepend', 'before', 'after', 'remove', or 'auto'.
  943. * - `mergeAttr` (array): Array of attrs to add/merge to the wrapping element, or HTML tag with attrs to merge.
  944. * @return string
  945. *
  946. */
  947. public function update($selector, $content, $markup, array $options = array()) {
  948. $defaults = array(
  949. 'action' => 'auto',
  950. 'mergeAttr' => array(),
  951. );
  952. $options = array_merge($defaults, $options);
  953. $findOptions = array(
  954. 'verbose' => true,
  955. 'exact' => true,
  956. );
  957. if(self::debug) {
  958. $findOptions['debugNote'] = "update.$options[action]($selector)";
  959. }
  960. $findRegions = $this->find($selector, $markup, $findOptions);
  961. foreach($findRegions as $region) {
  962. $action = $options['action'];
  963. if(count($options['mergeAttr'])) {
  964. $region['open'] = $this->mergeTags($region['open'], $options['mergeAttr']);
  965. }
  966. if($action == 'auto') {
  967. // auto mode delegates to the region action
  968. $action = '';
  969. if(in_array($region['action'], $this->actions)) $action = $region['action'];
  970. }
  971. switch($action) {
  972. case 'append':
  973. $replacement = $region['open'] . $region['region'] . $content . $region['close'];
  974. break;
  975. case 'prepend':
  976. $replacement = $region['open'] . $content . $region['region'] . $region['close'];
  977. break;
  978. case 'before':
  979. $replacement = $content . $region['html'];
  980. break;
  981. case 'after':
  982. $replacement = $region['html'] . $content;
  983. break;
  984. case 'remove':
  985. $replacement = '';
  986. break;
  987. default:
  988. // replace
  989. $replacement = $region['open'] . $content . $region['close'];
  990. }
  991. $markup = str_replace($region['html'], $replacement, $markup);
  992. }
  993. return $markup;
  994. }
  995. /**
  996. * Replace the region(s) that match the given $selector with the given $replace markup
  997. *
  998. * @param string $selector See the update() method $selector argument for supported formats
  999. * @param string $replace Markup to replace with
  1000. * @param string $markup Document markup where region(s) exist
  1001. * @param array $options See $options argument for update() method
  1002. * @return string
  1003. *
  1004. */
  1005. public function replace($selector, $replace, $markup, array $options = array()) {
  1006. $options['action'] = 'replace';
  1007. return $this->replace($selector, $replace, $markup, $options);
  1008. }
  1009. /**
  1010. * Append the region(s) that match the given $selector with the given $content markup
  1011. *
  1012. * @param string $selector See the update() method $selector argument for details
  1013. * @param string $content Markup to append
  1014. * @param string $markup Document markup where region(s) exist
  1015. * @param array $options See the update() method $options argument for details
  1016. * @return string
  1017. *
  1018. */
  1019. public function append($selector, $content, $markup, array $options = array()) {
  1020. $options['action'] = 'append';
  1021. return $this->replace($selector, $content, $markup, $options);
  1022. }
  1023. /**
  1024. * Prepend the region(s) that match the given $selector with the given $content markup
  1025. *
  1026. * @param string $selector See the update() method for details
  1027. * @param string $content Markup to prepend
  1028. * @param string $markup Document markup where region(s) exist
  1029. * @param array $options See the update() method for details
  1030. * @return string
  1031. *
  1032. */
  1033. public function prepend($selector, $content, $markup, array $options = array()) {
  1034. $options['action'] = 'prepend';
  1035. return $this->replace($selector, $content, $markup, $options);
  1036. }
  1037. /**
  1038. * Insert region(s) that match the given $selector before the given $content markup
  1039. *
  1040. * @param string $selector See the update() method for details
  1041. * @param string $content Markup to prepend
  1042. * @param string $markup Document markup where region(s) exist
  1043. * @param array $options See the update() method for details
  1044. * @return string
  1045. *
  1046. */
  1047. public function before($selector, $content, $markup, array $options = array()) {
  1048. $options['action'] = 'before';
  1049. return $this->replace($selector, $content, $markup, $options);
  1050. }
  1051. /**
  1052. * Insert the region(s) that match the given $selector after the given $content markup
  1053. *
  1054. * @param string $selector See the update() method for details
  1055. * @param string $content Markup to prepend
  1056. * @param string $markup Document markup where region(s) exist
  1057. * @param array $options See the update() method for details
  1058. * @return string
  1059. *
  1060. */
  1061. public function after($selector, $content, $markup, array $options = array()) {
  1062. $options['action'] = 'after';
  1063. return $this->replace($selector, $content, $markup, $options);
  1064. }
  1065. /**
  1066. * Remove the region(s) that match the given $selector
  1067. *
  1068. * @param string $selector See the update() method for details
  1069. * @param string $markup Document markup where region(s) exist
  1070. * @param array $options See the update() method for details
  1071. * @return string
  1072. *
  1073. */
  1074. public function remove($selector, $markup, array $options = array()) {
  1075. $options['action'] = 'after'; // after intended
  1076. return $this->replace($selector, '', $markup, $options);
  1077. }
  1078. /**
  1079. * Identify and populate markup regions in given HTML
  1080. *
  1081. * To use this, you must set `$config->useMarkupRegions = true;` in your /site/config.php file.
  1082. * In the future it may be enabled by default for any templates with text/html content-type.
  1083. *
  1084. * This takes anything output before the opening `<!DOCTYPE` and connects it to the right places
  1085. * within the `<html>` that comes after it. For instance, if there's a `<div id='content'>` in the
  1086. * document, then a #content element output prior to the doctype will replace it during page render.
  1087. * This enables one to use delayed output as if it’s direct output. It also makes every HTML element
  1088. * in the output with an “id” attribute a region that can be populated from any template file. It’s
  1089. * a good pairing with a `$config->appendTemplateFile` that contains the main markup and region
  1090. * definitions, though can be used with or without it.
  1091. *
  1092. * Beyond replacement of elements, append, prepend, insert before, insert after, and remove are also
  1093. * supported via “pw-” prefix attributes that you can add. The attributes do not appear in the final output
  1094. * markup. When performing replacements or modifications to elements, PW will merge the attributes
  1095. * so that attributes present in the final output are present, plus any that were added by the markup
  1096. * regions. See the examples for more details.
  1097. *
  1098. * Examples
  1099. * ========
  1100. * Below are some examples. Note that “main” is used as an example “id” attribute of an element that
  1101. * appears in the main document markup, and the examples below focus on manipulating it. The examples
  1102. * assume there is a `<div id=main>` in the _main.php file (appendTemplateFile), and the lines in the
  1103. * examples would be output from a template file, which manipulates what would ultimately be output
  1104. * when the page is rendered.
  1105. *
  1106. * In the examples, a “pw-id” or “data-pw-id” attribute may be used instead of an “id” attribute, when
  1107. * or if preferred. In addition, any “pw-” attribute may be specified as a “data-pw-” attribute if you
  1108. * prefer it.
  1109. * ~~~~~~
  1110. * Replacing and removing elements
  1111. *
  1112. * <div id='main'>This replaces the #main div and merges any attributes</div>
  1113. * <div pw-replace='main'>This does the same as above</div>
  1114. * <div id='main' pw-replace>This does the same as above</div>
  1115. * <div pw-remove='main'>This removes the #main div</div>
  1116. * <div id='main' pw-remove>This removes the #main div (same as above)</div>
  1117. *
  1118. * Prepending and appending elements
  1119. *
  1120. * <div id='main' class='pw-prepend'><p>This prepends #main with this p tag</p></div>
  1121. * <p pw-prepend='main'>This does the same as above</p>
  1122. * <div id='main' pw-append><p>This appends #main with this p tag</p></div>
  1123. * <p pw-append='main'>Removes the #main div</p>
  1124. *
  1125. * Modifying attributes on an existing element
  1126. *
  1127. * <div id='main' class='bar' pw-prepend><p>This prepends #main and adds "bar" class to main</p></div>
  1128. * <div id='main' class='foo' pw-append><p>This appends #main and adds a "foo" class to #main</p></div>
  1129. * <div id='main' title='hello' pw-append>Appends #main with this text + adds title attribute to #main</div>
  1130. * <div id='main' class='-baz' pw-append>Appends #main with this text + removes class “baz” from #main</div>
  1131. *
  1132. * Inserting new elements
  1133. *
  1134. * <h2 pw-before='main'>This adds an h2 headline with this text before #main</h2>
  1135. * <footer pw-after='main'><p>This adds a footer element with this text after #main</p></footer>
  1136. * <div pw-append='main' class='foo'>This appends a div.foo to #main with this text</div>
  1137. * <div pw-prepend='main' class='bar'>This prepends a div.bar to #main with this text</div>
  1138. *
  1139. * ~~~~~~
  1140. *
  1141. * @param string $htmlDocument Document to populate regions to
  1142. * @param string|array $htmlRegions Markup containing regions (or regions array from a find call)
  1143. * @param array $options Options to modify behavior:
  1144. * - `useClassActions` (bool): Allow "pw-*" actions to be specified in class names? Per original/legacy spec. (default=false)
  1145. * @return int Number of updates made to $htmlDocument
  1146. *
  1147. */
  1148. public function populate(&$htmlDocument, $htmlRegions, array $options = array()) {
  1149. static $recursionLevel = 0;
  1150. static $callQty = 0;
  1151. $recursionLevel++;
  1152. $callQty++;
  1153. $defaults = array(
  1154. 'useClassActions' => false // allow use of "pw-*" class actions? (legacy)
  1155. );
  1156. $options = array_merge($defaults, $options);
  1157. $leftoverMarkup = '';
  1158. $debugLandmark = "<!--PW-REGION-DEBUG-->";
  1159. $hasDebugLandmark = strpos($htmlDocument, $debugLandmark) !== false;
  1160. $debug = $hasDebugLandmark && $this->wire('config')->debug;
  1161. $debugTimer = $debug ? Debug::timer() : 0;
  1162. if(is_array($htmlRegions)) {
  1163. $regions = $htmlRegions;
  1164. $leftoverMarkup = '';
  1165. } else if($this->hasRegions($htmlRegions)) {
  1166. $htmlRegions = $this->stripRegions('<!--', $htmlRegions);
  1167. $selector = $options['useClassActions'] ? ".pw-*, id=" : "[pw-action], id=";
  1168. $regions = $this->find($selector, $htmlRegions, array(
  1169. 'verbose' => true,
  1170. 'leftover' => true,
  1171. 'debugNote' => (isset($options['debugNote']) ? "$options[debugNote] => " : "") . "populate($callQty)",
  1172. ));
  1173. $leftoverMarkup = trim($regions["leftover"]);
  1174. unset($regions["leftover"]);
  1175. } else {
  1176. $regions = array();
  1177. }
  1178. if(!count($regions)) {
  1179. if($debug) $htmlDocument = str_replace($debugLandmark, "<pre>No regions</pre>$debugLandmark", $htmlDocument);
  1180. $recursionLevel--;
  1181. if(!$recursionLevel) $this->removeRegionTags($htmlDocument);
  1182. return 0;
  1183. }
  1184. $xregions = array(); // regions that weren't populated
  1185. $populatedNotes = array();
  1186. $rejectedNotes = array();
  1187. $updates = array();
  1188. $numUpdates = 0;
  1189. foreach($regions as $regionKey => $region) {
  1190. if(empty($region['action'])) $region['action'] = 'auto'; // replace
  1191. if(empty($region['actionTarget'])) $region['actionTarget'] = $region['pwid']; // replace
  1192. // $xregion = $region;
  1193. $action = $region['action'];
  1194. $actionTarget = $region['actionTarget'];
  1195. $regionHTML = $region['region'];
  1196. $mergeAttr = $region['attrs'];
  1197. unset($mergeAttr['id']);
  1198. $documentHasTarget = $this->hasAttribute('id', $actionTarget, $htmlDocument);
  1199. $isNew = ($region['actionType'] == 'attr' && $region['action'] != 'replace');
  1200. if(!$isNew) $isNew = $action == 'before' || $action == 'after';
  1201. if($isNew) {
  1202. // element is newly added element not already present
  1203. $mergeAttr = array();
  1204. $regionHTML = $region['html'];
  1205. $attrs = $region['attrs'];
  1206. $attrStr = count($attrs) ? ' ' . $this->renderAttributes($attrs, false) : '';
  1207. if(!strlen(trim($attrStr))) $attrStr = '';
  1208. if($region['actionType'] == 'bool') {
  1209. $regionHTML = $region['region'];
  1210. } else {
  1211. $regionHTML = str_replace($region['open'], "<$region[name]$attrStr>", $regionHTML);
  1212. }
  1213. }
  1214. if($debug) {
  1215. $debugAction = $region['action'];
  1216. if($debugAction == 'auto') $debugAction = $isNew ? 'insert' : 'replace';
  1217. if($debugAction == 'replace' && empty($region['close'])) $debugAction = 'attr-update';
  1218. $pwid = empty($region['pwid']) ? $region['actionTarget'] : $region['pwid'];
  1219. $open = $region['open'];
  1220. $openLen = strlen($open);
  1221. if($openLen > 50) $open = substr($open, 0, 30) . '[sm]... +' . ($openLen - 30) . ' bytes[/sm]>';
  1222. $debugRegionStart = "[sm]" . trim(substr($region['region'], 0, 80));
  1223. $pos = strrpos($debugRegionStart, '>');
  1224. if($pos) $debugRegionStart = substr($debugRegionStart, 0, $pos+1);
  1225. $debugRegionStart .= " … [b]" . strlen($region['region']) . " bytes[/b][/sm]";
  1226. //$debugRegionEnd = substr($region['region'], -30);
  1227. //$pos = strpos($debugRegionEnd, '</');
  1228. //if($pos !== false) $debugRegionEnd = substr($debugRegionEnd, $pos);
  1229. $region['note'] = strtoupper($debugAction) . " [b]#{$pwid}[/b] " .
  1230. ($region['actionTarget'] != $pwid ? "(target=$region[actionTarget])" : "") .
  1231. "[sm]with[/sm] $open";
  1232. if($region['close']) {
  1233. $region['note'] .= $this->debugNoteStr($debugRegionStart) . $region['close'];
  1234. }
  1235. $regionNote = $region['note']; // [sm](position=$regionKey)[/sm]";
  1236. } else {
  1237. $regionNote = '';
  1238. }
  1239. if(!$documentHasTarget) {
  1240. // if the id attribute doesn't appear in the html, skip it
  1241. // $xregions[$regionKey] = $xregion;
  1242. if(self::debug) $rejectedNotes[] = $regionNote;
  1243. } else {
  1244. // update the markup
  1245. $updates[] = array(
  1246. 'actionTarget' => "#$actionTarget",
  1247. 'regionHTML' => $regionHTML,
  1248. 'action' => $action,
  1249. 'mergeAttr' => $mergeAttr,
  1250. );
  1251. if(is_string($htmlRegions)) {
  1252. // remove region markup from $htmlRegions so we can later determine what’s left
  1253. $htmlRegions = str_replace($region['html'], '', $htmlRegions);
  1254. }
  1255. $populatedNotes[] = $regionNote;
  1256. $numUpdates++;
  1257. }
  1258. }
  1259. foreach($updates as $u) {
  1260. $htmlDocument = $this->update($u['actionTarget'], $u['regionHTML'], $htmlDocument, array(
  1261. 'action' => $u['action'],
  1262. 'mergeAttr' => $u['mergeAttr'],
  1263. ));
  1264. }
  1265. $htmlRegions = trim($htmlRegions);
  1266. if($debug) {
  1267. $leftoverBytes = strlen($leftoverMarkup);
  1268. $htmlRegionsLen = strlen($htmlRegions);
  1269. $debugNotes = array();
  1270. if($recursionLevel > 1) $debugNotes[] = "PASS: $recursionLevel";
  1271. if(count($populatedNotes)) $debugNotes = array_merge($debugNotes, $populatedNotes); // implode($bull, $populatedNotes);
  1272. if(count($rejectedNotes)) foreach($rejectedNotes as $note) $debugNotes[] = "SKIPPED: $note";
  1273. if($leftoverBytes) $debugNotes[] = "$leftoverBytes non-region bytes skipped: [sm]{$leftoverMarkup}[/sm]";
  1274. if($htmlRegionsLen) {
  1275. if($recursionLevel > 1) {
  1276. $debugNotes[] = "$htmlRegionsLen HTML-region bytes found no home after 2nd pass: [sm]{$htmlRegions}[/sm]";
  1277. } else if($this->hasRegions($htmlRegions)) {
  1278. $debugNotes[] = "$htmlRegionsLen HTML-region bytes remaining for 2nd pass: [sm]{$htmlRegions}[/sm]";
  1279. } else {
  1280. $debugNotes[] = "$htmlRegionsLen HTML bytes remaining, but no regions present: [sm]{$htmlRegions}[/sm]";
  1281. }
  1282. }
  1283. if(count($this->debugNotes)) {
  1284. $this->debugNotes = array_unique($this->debugNotes);
  1285. $debugNotes[] = "---------------";
  1286. foreach($this->debugNotes as $n => $s) {
  1287. $debugNotes[] = $this->debugNoteStr($s);
  1288. }
  1289. }
  1290. if(!count($debugNotes)) $debugNotes[] = "Nothing found";
  1291. $debugNotes = "• " . implode("\n• ", $debugNotes);
  1292. $debugNotes = $this->wire('sanitizer')->entities($debugNotes);
  1293. $debugNotes .= "\n[sm]" . Debug::timer($debugTimer) . " seconds[/sm]";
  1294. $debugNotes = str_replace(array('[sm]', '[/sm]'), array('<small style="opacity:0.7">', '</small>'), $debugNotes);
  1295. $debugNotes = str_replace(array('[b]', '[/b]'), array('<strong>', '</strong>'), $debugNotes);
  1296. $debugNotes = "<pre class='pw-debug pw-region-debug'>$debugNotes</pre>$debugLandmark";
  1297. $htmlDocument = str_replace($debugLandmark, $debugNotes, $htmlDocument);
  1298. } else if($hasDebugLandmark) {
  1299. $htmlDocument = str_replace($debugLandmark, '', $htmlDocument);
  1300. }
  1301. if(count($xregions) && $recursionLevel < 3) {
  1302. // see if they can be populated now
  1303. $numUpdates += $this->populate($htmlDocument, $xregions, $options);
  1304. }
  1305. if($recursionLevel === 1 && strlen($htmlRegions) && $this->hasRegions($htmlRegions)) {
  1306. // see if more regions can be pulled from leftover $htmlRegions
  1307. $numUpdates += $this->populate($htmlDocument, $htmlRegions, $options);
  1308. }
  1309. // remove region tags and pw-id attributes
  1310. if($recursionLevel === 1 && $this->removeRegionTags($htmlDocument)) $numUpdates++;
  1311. // if there is any leftover markup, place it above the HTML where it would usually go
  1312. if(strlen($leftoverMarkup)) {
  1313. $htmlDocument = $leftoverMarkup . $htmlDocument;
  1314. $numUpdates++;
  1315. }
  1316. $recursionLevel--;
  1317. return $numUpdates;
  1318. }
  1319. /**
  1320. * Remove any <region> or <pw-region> tags present in the markup, leaving their innerHTML contents
  1321. *
  1322. * Also removes data-pw-id and pw-id attributes
  1323. *
  1324. * @param string $html
  1325. * @return bool True if tags or attributes were removed, false if not
  1326. *
  1327. */
  1328. public function removeRegionTags(&$html) {
  1329. $updated = false;
  1330. if(stripos($html, '</region>') !== false || strpos($html, '</pw-region>') !== false) {
  1331. $html = preg_replace('!</?(?:region|pw-region)(?:\s[^>]*>|>)!i', '', $html);
  1332. $updated = true;
  1333. }
  1334. if(stripos($html, ' data-pw-id=') || stripos($html, ' pw-id=…

Large files files are truncated, but you can click here to view the full file