PluginProbe
Friends / 4.3.2
Friends v4.3.2
4.3.2 4.3.1 4.3.0 4.2.2 4.2.1 4.2.0 4.1.0 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.1 All 88 releases
friends / libs / Mf2 / Parser.php

Parser.php in Friends 4.3.2, at libs/Mf2/Parser.php

2,465 lines 72.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Friends\Mf2;
4
5 use DOMDocument;
6 use DOMElement;
7 use DOMXPath;
8 use DOMNode;
9 use DOMNodeList;
10 use Exception;
11 use SplObjectStorage;
12 use stdClass;
13
14 /**
15 * Parse Microformats2
16 *
17 * Functional shortcut for the commonest cases of parsing microformats2 from HTML.
18 *
19 * Example usage:
20 *
21 * use Mf2;
22 * $output = Mf2\parse('<span class="h-card">Barnaby Walters</span>');
23 * echo json_encode($output, JSON_PRETTY_PRINT);
24 *
25 * Produces:
26 *
27 * {
28 * "items": [
29 * {
30 * "type": ["h-card"],
31 * "properties": {
32 * "name": ["Barnaby Walters"]
33 * }
34 * }
35 * ],
36 * "rels": {}
37 * }
38 *
39 * @param string|DOMDocument $input The HTML string or DOMDocument object to parse
40 * @param string $url The URL the input document was found at, for relative URL resolution
41 * @param bool $convertClassic whether or not to convert classic microformats
42 * @return array Canonical MF2 array structure
43 */
44 function parse($input, $url = null, $convertClassic = true) {
45 $parser = new Parser($input, $url);
46 return $parser->parse($convertClassic);
47 }
48
49 /**
50 * Fetch microformats2
51 *
52 * Given a URL, fetches it (following up to 5 redirects) and, if the content-type appears to be HTML, returns the parsed
53 * microformats2 array structure.
54 *
55 * Not that even if the response code was a 4XX or 5XX error, if the content-type is HTML-like then it will be parsed
56 * all the same, as there are legitimate cases where error pages might contain useful microformats (for example a deleted
57 * h-entry resulting in a 410 Gone page with a stub h-entry explaining the reason for deletion). Look in $curlInfo['http_code']
58 * for the actual value.
59 *
60 * @param string $url The URL to fetch
61 * @param bool $convertClassic (optional, default true) whether or not to convert classic microformats
62 * @param &array $curlInfo (optional) the results of curl_getinfo will be placed in this variable for debugging
63 * @return array|null canonical microformats2 array structure on success, null on failure
64 */
65 function fetch($url, $convertClassic = true, &$curlInfo=null) {
66 $ch = curl_init();
67 curl_setopt($ch, CURLOPT_URL, $url);
68 curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
69 curl_setopt($ch, CURLOPT_HEADER, 0);
70 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
71 curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
72 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
73 'Accept: text/html'
74 ));
75 $html = curl_exec($ch);
76 $info = $curlInfo = curl_getinfo($ch);
77 curl_close($ch);
78
79 if (strpos(strtolower($info['content_type']), 'html') === false) {
80 // The content was not delivered as HTML, do not attempt to parse it.
81 return null;
82 }
83
84 # ensure the final URL is used to resolve relative URLs
85 $url = $info['url'];
86
87 return parse($html, $url, $convertClassic);
88 }
89
90 /**
91 * Unicode to HTML Entities
92 * @param string $input String containing characters to convert into HTML entities
93 * @return string
94 */
95 function unicodeToHtmlEntities($input) {
96 return mb_convert_encoding($input, 'HTML-ENTITIES', mb_detect_encoding($input));
97 }
98
99 /**
100 * Collapse Whitespace
101 *
102 * Collapses any sequences of whitespace within a string into a single space
103 * character.
104 *
105 * @deprecated since v0.2.3
106 * @param string $str
107 * @return string
108 */
109 function collapseWhitespace($str) {
110 return preg_replace('/[\s|\n]+/', ' ', $str);
111 }
112
113 function unicodeTrim($str) {
114 // this is cheating. TODO: find a better way if this causes any problems
115 $str = str_replace(mb_convert_encoding('&nbsp;', 'UTF-8', 'HTML-ENTITIES'), ' ', $str);
116 $str = preg_replace('/^\s+/', '', $str);
117 return preg_replace('/\s+$/', '', $str);
118 }
119
120 /**
121 * Microformat Name From Class string
122 *
123 * Given the value of @class, get the relevant mf classnames (e.g. h-card,
124 * p-name).
125 *
126 * @param string $class A space delimited list of classnames
127 * @param string $prefix The prefix to look for
128 * @return string|array The prefixed name of the first microfomats class found or false
129 */
130 function mfNamesFromClass($class, $prefix='h-') {
131 $class = str_replace(array(' ', ' ', "\n"), ' ', $class);
132 $classes = explode(' ', $class);
133 $classes = preg_grep('#^(h|p|u|dt|e)-([a-z0-9]+-)?[a-z]+(-[a-z]+)*$#', $classes);
134 $matches = array();
135
136 foreach ($classes as $classname) {
137 $compare_classname = ' ' . $classname;
138 $compare_prefix = ' ' . $prefix;
139 if (strstr($compare_classname, $compare_prefix) !== false && ($compare_classname != $compare_prefix)) {
140 $matches[] = ($prefix === 'h-') ? $classname : substr($classname, strlen($prefix));
141 }
142 }
143
144 return $matches;
145 }
146
147 /**
148 * Registered with the XPath object and used within XPaths for finding root elements.
149 * @param string $class
150 * @return bool
151 */
152 function classHasMf2RootClassname($class) {
153 return count(mfNamesFromClass($class, 'h-')) > 0;
154 }
155
156 /**
157 * Get Nested µf Property Name From Class
158 *
159 * Returns all the p-, u-, dt- or e- prefixed classnames it finds in a
160 * space-separated string.
161 *
162 * @param string $class
163 * @return array
164 */
165 function nestedMfPropertyNamesFromClass($class) {
166 $prefixes = array('p-', 'u-', 'dt-', 'e-');
167 $propertyNames = array();
168
169 foreach ($prefixes as $prefix) {
170 $classes = mfNamesFromClass($class, $prefix);
171 foreach ($classes as $property) {
172 $propertyNames[$property][] = $prefix;
173 }
174 }
175
176 foreach ($propertyNames as $property => $prefixes) {
177 $propertyNames[$property] = array_unique($prefixes);
178 }
179
180 return $propertyNames;
181 }
182
183 /**
184 * Wraps mfNamesFromClass to handle an element as input (common)
185 *
186 * @param DOMElement $e The element to get the classname for
187 * @param string $prefix The prefix to look for
188 * @return mixed See return value of mf2\Parser::mfNameFromClass()
189 */
190 function mfNamesFromElement(\DOMElement $e, $prefix = 'h-') {
191 $class = $e->getAttribute('class');
192 return mfNamesFromClass($class, $prefix);
193 }
194
195 /**
196 * Wraps nestedMfPropertyNamesFromClass to handle an element as input
197 */
198 function nestedMfPropertyNamesFromElement(\DOMElement $e) {
199 $class = $e->getAttribute('class');
200 return nestedMfPropertyNamesFromClass($class);
201 }
202
203 /**
204 * Converts various time formats to HH:MM
205 * @param string $time The time to convert
206 * @return string
207 */
208 function convertTimeFormat($time) {
209 $hh = $mm = $ss = '';
210 preg_match('/(\d{1,2}):?(\d{2})?:?(\d{2})?(a\.?m\.?|p\.?m\.?)?/i', $time, $matches);
211
212 // If no am/pm is specified:
213 if (empty($matches[4])) {
214 return $time;
215 } else {
216 // Otherwise, am/pm is specified.
217 $meridiem = strtolower(str_replace('.', '', $matches[4]));
218
219 // Hours.
220 $hh = $matches[1];
221
222 // Add 12 to hours if pm applies.
223 if ($meridiem == 'pm' && ($hh < 12)) {
224 $hh += 12;
225 }
226
227 $hh = str_pad($hh, 2, '0', STR_PAD_LEFT);
228
229 // Minutes.
230 $mm = (empty($matches[2]) ) ? '00' : $matches[2];
231
232 // Seconds, only if supplied.
233 if (!empty($matches[3])) {
234 $ss = $matches[3];
235 }
236
237 if (empty($ss)) {
238 return sprintf('%s:%s', $hh, $mm);
239 }
240 else {
241 return sprintf('%s:%s:%s', $hh, $mm, $ss);
242 }
243 }
244 }
245
246 /**
247 * Normalize an ordinal date to YYYY-MM-DD
248 * This function should only be called after validating the $dtValue
249 * matches regex \d{4}-\d{2}
250 * @param string $dtValue
251 * @return string
252 */
253 function normalizeOrdinalDate($dtValue) {
254 list($year, $day) = explode('-', $dtValue, 2);
255 $day = intval($day);
256 if ($day < 367 && $day > 0) {
257 $date = \DateTime::createFromFormat('Y-z', $dtValue);
258 $date->modify('-1 day'); # 'z' format is zero-based so need to adjust
259 if ($date->format('Y') === $year) {
260 return $date->format('Y-m-d');
261 }
262 }
263 return '';
264 }
265
266 /**
267 * If a date value has a timezone offset, normalize it.
268 * @param string $dtValue
269 * @return string isolated, normalized TZ offset for implied TZ for other dt- properties
270 */
271 function normalizeTimezoneOffset(&$dtValue) {
272 preg_match('/Z|[+-]\d{1,2}:?(\d{2})?$/i', $dtValue, $matches);
273
274 if (empty($matches)) {
275 return null;
276 }
277
278 $timezoneOffset = null;
279
280 if ( $matches[0] != 'Z' ) {
281 $timezoneString = str_replace(':', '', $matches[0]);
282 $plus_minus = substr($timezoneString, 0, 1);
283 $timezoneOffset = substr($timezoneString, 1);
284 if ( strlen($timezoneOffset) <= 2 ) {
285 $timezoneOffset .= '00';
286 }
287 $timezoneOffset = str_pad($timezoneOffset, 4, 0, STR_PAD_LEFT);
288 $timezoneOffset = $plus_minus . $timezoneOffset;
289 $dtValue = preg_replace('/Z?[+-]\d{1,2}:?(\d{2})?$/i', $timezoneOffset, $dtValue);
290 }
291
292 return $timezoneOffset;
293 }
294
295 function applySrcsetUrlTransformation($srcset, $transformation) {
296 return implode(', ', array_filter(array_map(function ($srcsetPart) use ($transformation) {
297 $parts = explode(" \t\n\r\0\x0B", trim($srcsetPart), 2);
298 $parts[0] = rtrim($parts[0]);
299
300 if (empty($parts[0])) { return false; }
301
302 $parts[0] = call_user_func($transformation, $parts[0]);
303
304 return $parts[0] . (empty($parts[1]) ? '' : ' ' . $parts[1]);
305 }, explode(',', trim($srcset)))));
306 }
307
308 /**
309 * Microformats2 Parser
310 *
311 * A class which holds state for parsing microformats2 from HTML.
312 *
313 * Example usage:
314 *
315 * use Mf2;
316 * $parser = new Mf2\Parser('<p class="h-card">Barnaby Walters</p>');
317 * $output = $parser->parse();
318 */
319 class Parser {
320 /** @var string The baseurl (if any) to use for this parse */
321 public $baseurl;
322
323 /** @var DOMXPath object which can be used to query over any fragment*/
324 public $xpath;
325
326 /** @var DOMDocument */
327 public $doc;
328
329 /** @var SplObjectStorage */
330 protected $parsed;
331
332 /**
333 * @var bool
334 */
335 public $jsonMode;
336
337 /** @var boolean Whether to include experimental language parsing in the result */
338 public $lang = false;
339
340 /** @var bool Whether to include alternates object (dropped from spec in favor of rel-urls) */
341 public $enableAlternates = false;
342
343 /**
344 * Elements upgraded to mf2 during backcompat
345 * @var SplObjectStorage
346 */
347 protected $upgraded;
348
349 /**
350 * Whether to convert classic microformats
351 * @var bool
352 */
353 public $convertClassic;
354
355 /**
356 * Constructor
357 *
358 * @param DOMDocument|string $input The data to parse. A string of HTML or a DOMDocument
359 * @param string $url The URL of the parsed document, for relative URL resolution
360 * @param boolean $jsonMode Whether or not to use a stdClass instance for an empty `rels` dictionary. This breaks PHP looping over rels, but allows the output to be correctly serialized as JSON.
361 */
362 public function __construct($input, $url = null, $jsonMode = false) {
363 $emptyDocDefault = '<html><body></body></html>';
364 libxml_use_internal_errors(true);
365 if (is_string($input)) {
366 if (empty($input)) {
367 $input = $emptyDocDefault;
368 }
369
370 if (class_exists('Masterminds\\HTML5')) {
371 $doc = new \Masterminds\HTML5(array('disable_html_ns' => true));
372 $doc = $doc->loadHTML($input);
373 } else {
374 $doc = new DOMDocument();
375 @$doc->loadHTML(unicodeToHtmlEntities($input), \LIBXML_NOWARNING);
376 }
377 } elseif (is_a($input, 'DOMDocument')) {
378 $doc = clone $input;
379 } else {
380 $doc = new DOMDocument();
381 @$doc->loadHTML($emptyDocDefault);
382 }
383
384 // Create an XPath object and allow some PHP functions to be used within XPath queries.
385 $this->xpath = new DOMXPath($doc);
386 $this->xpath->registerNamespace('php', 'http://php.net/xpath');
387 $this->xpath->registerPhpFunctions('\\Friends\\Mf2\\classHasMf2RootClassname');
388
389 $baseurl = $url;
390 foreach ($this->xpath->query('//base[@href]') as $base) {
391 $baseElementUrl = $base->getAttribute('href');
392
393 if (parse_url($baseElementUrl, PHP_URL_SCHEME) === null) {
394 /* The base element URL is relative to the document URL.
395 *
396 * :/
397 *
398 * Perhaps the author was high? */
399
400 $baseurl = resolveUrl($url, $baseElementUrl);
401 } else {
402 $baseurl = $baseElementUrl;
403 }
404 break;
405 }
406
407 // Ignore <template> elements as per the HTML5 spec
408 foreach ($this->xpath->query('//template') as $templateEl) {
409 $templateEl->parentNode->removeChild($templateEl);
410 }
411
412 $this->baseurl = $baseurl;
413 $this->doc = $doc;
414 $this->parsed = new SplObjectStorage();
415 $this->upgraded = new SplObjectStorage();
416 $this->jsonMode = $jsonMode;
417 }
418
419 private function elementPrefixParsed(\DOMElement $e, $prefix) {
420 if (!$this->parsed->offsetExists($e))
421 $this->parsed->offsetSet($e, array());
422
423 $prefixes = $this->parsed[$e];
424 $prefixes[] = $prefix;
425 $this->parsed[$e] = $prefixes;
426 }
427
428 /**
429 * Determine if the element has already been parsed
430 * @param DOMElement $e
431 * @param string $prefix
432 * @return bool
433 */
434 private function isElementParsed(\DOMElement $e, $prefix) {
435 if (!$this->parsed->offsetExists($e)) {
436 return false;
437 }
438
439 $prefixes = $this->parsed[$e];
440
441 if (!in_array($prefix, $prefixes)) {
442 return false;
443 }
444
445 return true;
446 }
447
448 /**
449 * Determine if the element's specified property has already been upgraded during backcompat
450 * @param DOMElement $el
451 * @param string $property
452 * @return bool
453 */
454 private function isElementUpgraded(\DOMElement $el, $property) {
455 if ( $this->upgraded->offsetExists($el) ) {
456 if ( in_array($property, $this->upgraded[$el]) ) {
457 return true;
458 }
459 }
460
461 return false;
462 }
463
464 private function resolveChildUrls(DOMElement $el) {
465 $hyperlinkChildren = $this->xpath->query('.//*[@src or @href or @data]', $el);
466
467 foreach ($hyperlinkChildren as $child) {
468 if ($child->hasAttribute('href'))
469 $child->setAttribute('href', $this->resolveUrl($child->getAttribute('href')));
470 if ($child->hasAttribute('src'))
471 $child->setAttribute('src', $this->resolveUrl($child->getAttribute('src')));
472 if ($child->hasAttribute('srcset'))
473 $child->setAttribute('srcset', applySrcsetUrlTransformation($child->getAttribute('href'), array($this, 'resolveUrl')));
474 if ($child->hasAttribute('data'))
475 $child->setAttribute('data', $this->resolveUrl($child->getAttribute('data')));
476 }
477 }
478
479 /**
480 * The following two methods implements plain text parsing.
481 * @param DOMElement $element
482 * @param bool $implied
483 * @see https://wiki.zegnat.net/media/textparsing.html
484 **/
485 public function textContent(DOMElement $element, $implied=false)
486 {
487 return preg_replace(
488 '/(^[\t\n\f\r ]+| +(?=\n)|(?<=\n) +| +(?= )|[\t\n\f\r ]+$)/',
489 '',
490 $this->elementToString($element, $implied)
491 );
492 }
493 private function elementToString(DOMElement $input, $implied=false)
494 {
495 $output = '';
496 foreach ($input->childNodes as $child) {
497 if ($child->nodeType === XML_TEXT_NODE) {
498 $output .= str_replace(array("\t", "\n", "\r") , ' ', $child->textContent);
499 } else if ($child->nodeType === XML_ELEMENT_NODE) {
500 $tagName = strtoupper($child->tagName);
501 if (in_array($tagName, array('SCRIPT', 'STYLE'))) {
502 continue;
503 } else if ($tagName === 'IMG') {
504 if ($child->hasAttribute('alt')) {
505 $output .= ' ' . trim($child->getAttribute('alt'), "\t\n\f\r ") . ' ';
506 } else if (!$implied && $child->hasAttribute('src')) {
507 $output .= ' ' . $this->resolveUrl(trim($child->getAttribute('src'), "\t\n\f\r ")) . ' ';
508 }
509 } else if ($tagName === 'BR') {
510 $output .= "\n";
511 } else if ($tagName === 'P') {
512 $output .= "\n" . $this->elementToString($child);
513 } else {
514 $output .= $this->elementToString($child);
515 }
516 }
517 }
518 return $output;
519 }
520
521 /**
522 * Given an img property, parse its value and/or alt text
523 * @param DOMElement $el
524 * @access public
525 * @return string|array
526 */
527 public function parseImg(DOMElement $el)
528 {
529 if ($el->hasAttribute('alt')) {
530 return [
531 'value' => $this->resolveUrl( $el->getAttribute('src') ),
532 'alt' => $el->getAttribute('alt')
533 ];
534 }
535 return $el->getAttribute('src');
536 }
537 /**
538 * This method parses the language of an element
539 * @param DOMElement $el
540 * @access public
541 * @return string
542 */
543 public function language(DOMElement $el)
544 {
545 // element has a lang attribute; use it
546 if ($el->hasAttribute('lang')) {
547 return unicodeTrim($el->getAttribute('lang'));
548 }
549
550 if ($el->tagName == 'html') {
551 // we're at the <html> element and no lang; check <meta> http-equiv Content-Language
552 foreach ( $this->xpath->query('.//meta[@http-equiv]') as $node )
553 {
554 if ($node->hasAttribute('http-equiv') && $node->hasAttribute('content') && strtolower($node->getAttribute('http-equiv')) == 'content-language') {
555 return unicodeTrim($node->getAttribute('content'));
556 }
557 }
558 } elseif ($el->parentNode instanceof DOMElement) {
559 // check the parent node
560 return $this->language($el->parentNode);
561 }
562
563 return '';
564 } # end method language()
565
566 // TODO: figure out if this has problems with sms: and geo: URLs
567 public function resolveUrl($url) {
568 // If not a string then return.
569 if (!is_string($url)){
570 return $url;
571 }
572 // If the URL is seriously malformed it’s probably beyond the scope of this
573 // parser to try to do anything with it.
574 if (parse_url($url) === false) {
575 return $url;
576 }
577
578 // per issue #40 valid URLs could have a space on either side
579 $url = trim($url);
580
581 $scheme = parse_url($url, PHP_URL_SCHEME);
582
583 if (empty($scheme) and !empty($this->baseurl)) {
584 return resolveUrl($this->baseurl, $url);
585 } else {
586 return $url;
587 }
588 }
589
590 // Parsing Functions
591
592 /**
593 * Parse value-class/value-title on an element, joining with $separator if
594 * there are multiple.
595 *
596 * @param \DOMElement $e
597 * @param string $separator = '' if multiple value-title elements, join with this string
598 * @return string|null the parsed value or null if value-class or -title aren’t in use
599 */
600 public function parseValueClassTitle(\DOMElement $e, $separator = '') {
601 $valueClassElements = $this->xpath->query('./*[contains(concat(" ", normalize-space(@class), " "), " value ")]', $e);
602
603 if ($valueClassElements->length !== 0) {
604 // Process value-class stuff
605 $val = '';
606 foreach ($valueClassElements as $el) {
607 $val .= $this->textContent($el);
608 }
609
610 return unicodeTrim($val);
611 }
612
613 $valueTitleElements = $this->xpath->query('./*[contains(concat(" ", normalize-space(@class), " "), " value-title ")]', $e);
614
615 if ($valueTitleElements->length !== 0) {
616 // Process value-title stuff
617 $val = '';
618 foreach ($valueTitleElements as $el) {
619 $val .= $el->getAttribute('title');
620 }
621
622 return unicodeTrim($val);
623 }
624
625 // No value-title or -class in this element
626 return null;
627 }
628
629 /**
630 * Given an element with class="p-*", get its value
631 *
632 * @param DOMElement $p The element to parse
633 * @return string The plaintext value of $p, dependant on type
634 * @todo Make this adhere to value-class
635 */
636 public function parseP(\DOMElement $p) {
637 $classTitle = $this->parseValueClassTitle($p, ' ');
638
639 if ($classTitle !== null) {
640 return $classTitle;
641 }
642
643 $this->resolveChildUrls($p);
644
645 if ($p->tagName == 'img' and $p->hasAttribute('alt')) {
646 $pValue = $p->getAttribute('alt');
647 } elseif ($p->tagName == 'area' and $p->hasAttribute('alt')) {
648 $pValue = $p->getAttribute('alt');
649 } elseif (($p->tagName == 'abbr' or $p->tagName == 'link') and $p->hasAttribute('title')) {
650 $pValue = $p->getAttribute('title');
651 } elseif (in_array($p->tagName, array('data', 'input')) and $p->hasAttribute('value')) {
652 $pValue = $p->getAttribute('value');
653 } else {
654 $pValue = $this->textContent($p);
655 }
656
657 return $pValue;
658 }
659
660 /**
661 * Given an element with class="u-*", get the value of the URL
662 *
663 * @param DOMElement $u The element to parse
664 * @return string The plaintext value of $u, dependant on type
665 * @todo make this adhere to value-class
666 */
667 public function parseU(\DOMElement $u) {
668 if (($u->tagName == 'a' or $u->tagName == 'area' or $u->tagName == 'link') and $u->hasAttribute('href')) {
669 $uValue = $u->getAttribute('href');
670 } elseif ( $u->tagName == 'img' and $u->hasAttribute('src') ) {
671 $uValue = $this->parseImg($u);
672 if ( is_array( $uValue ) && isset( $uValue['value'] ) ) {
673 $uValue = $uValue['value'];
674 }
675 } elseif (in_array($u->tagName, array('audio', 'video', 'source', 'iframe')) and $u->hasAttribute('src')) {
676 $uValue = $u->getAttribute('src');
677 } elseif ($u->tagName == 'video' and !$u->hasAttribute('src') and $u->hasAttribute('poster')) {
678 $uValue = $u->getAttribute('poster');
679 } elseif ($u->tagName == 'object' and $u->hasAttribute('data')) {
680 $uValue = $u->getAttribute('data');
681 } elseif (($classTitle = $this->parseValueClassTitle($u)) !== null) {
682 $uValue = $classTitle;
683 } elseif (($u->tagName == 'abbr' or $u->tagName == 'link') and $u->hasAttribute('title')) {
684 $uValue = $u->getAttribute('title');
685 } elseif (in_array($u->tagName, array('data', 'input')) and $u->hasAttribute('value')) {
686 $uValue = $u->getAttribute('value');
687 } else {
688 $uValue = $this->textContent($u);
689 }
690
691 return $this->resolveUrl($uValue);
692 }
693
694 /**
695 * Given an element with class="dt-*", get the value of the datetime as a php date object
696 *
697 * @param DOMElement $dt The element to parse
698 * @param array $dates Array of dates processed so far
699 * @param string $impliedTimezone
700 * @return string The datetime string found
701 */
702 public function parseDT(\DOMElement $dt, &$dates = array(), &$impliedTimezone = null) {
703 // Check for value-class pattern
704 $valueClassChildren = $this->xpath->query('./*[contains(concat(" ", normalize-space(@class), " "), " value ") or contains(concat(" ", normalize-space(@class), " "), " value-title ")]', $dt);
705 $dtValue = false;
706
707 if ($valueClassChildren->length > 0) {
708 // They’re using value-class
709 $dateParts = array();
710
711 foreach ($valueClassChildren as $e) {
712 if (strstr(' ' . $e->getAttribute('class') . ' ', ' value-title ')) {
713 $title = $e->getAttribute('title');
714 if (!empty($title)) {
715 $dateParts[] = $title;
716 }
717 }
718 elseif ($e->tagName == 'img' or $e->tagName == 'area') {
719 // Use @alt
720 $alt = $e->getAttribute('alt');
721 if (!empty($alt)) {
722 $dateParts[] = $alt;
723 }
724 }
725 elseif ($e->tagName == 'data') {
726 // Use @value, otherwise innertext
727 $value = $e->hasAttribute('value') ? $e->getAttribute('value') : unicodeTrim($e->nodeValue);
728 if (!empty($value)) {
729 $dateParts[] = $value;
730 }
731 }
732 elseif ($e->tagName == 'abbr') {
733 // Use @title, otherwise innertext
734 $title = $e->hasAttribute('title') ? $e->getAttribute('title') : unicodeTrim($e->nodeValue);
735 if (!empty($title)) {
736 $dateParts[] = $title;
737 }
738 }
739 elseif ($e->tagName == 'del' or $e->tagName == 'ins' or $e->tagName == 'time') {
740 // Use @datetime if available, otherwise innertext
741 $dtAttr = ($e->hasAttribute('datetime')) ? $e->getAttribute('datetime') : unicodeTrim($e->nodeValue);
742 if (!empty($dtAttr)) {
743 $dateParts[] = $dtAttr;
744 }
745 }
746 else {
747 if (!empty($e->nodeValue)) {
748 $dateParts[] = unicodeTrim($e->nodeValue);
749 }
750 }
751 }
752
753 // Look through dateParts
754 $datePart = '';
755 $timePart = '';
756 $timezonePart = '';
757 foreach ($dateParts as $part) {
758 // Is this part a full ISO8601 datetime?
759 if (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:?\d{2})?$/', $part)) {
760 // Break completely, we’ve got our value.
761 $dtValue = $part;
762 break;
763 } else {
764 // Is the current part a valid time(+TZ?) AND no other time representation has been found?
765 if ((preg_match('/^\d{1,2}:\d{2}(:\d{2})?(Z|[+-]\d{1,2}:?\d{2})?$/', $part) or preg_match('/^\d{1,2}(:\d{2})?(:\d{2})?[ap]\.?m\.?$/i', $part)) and empty($timePart)) {
766 $timePart = $part;
767
768 $timezoneOffset = normalizeTimezoneOffset($timePart);
769 if (!$impliedTimezone && $timezoneOffset) {
770 $impliedTimezone = $timezoneOffset;
771 }
772 // Is the current part a valid date AND no other date representation has been found?
773 } elseif (preg_match('/^\d{4}-\d{2}-\d{2}$/', $part) and empty($datePart)) {
774 $datePart = $part;
775 // Is the current part a valid ordinal date AND no other date representation has been found?
776 } elseif (preg_match('/^\d{4}-\d{3}$/', $part) and empty($datePart)) {
777 $datePart = normalizeOrdinalDate($part);
778 // Is the current part a valid timezone offset AND no other timezone part has been found?
779 } elseif (preg_match('/^(Z|[+-]\d{1,2}:?(\d{2})?)$/', $part) and empty($timezonePart)) {
780 $timezonePart = $part;
781
782 $timezoneOffset = normalizeTimezoneOffset($timezonePart);
783 if (!$impliedTimezone && $timezoneOffset) {
784 $impliedTimezone = $timezoneOffset;
785 }
786 // Current part already represented by other VCP parts; do nothing with it
787 } else {
788 continue;
789 }
790
791 if ( !empty($datePart) && !in_array($datePart, $dates) ) {
792 $dates[] = $datePart;
793 }
794
795 if (!empty($timezonePart) && !empty($timePart)) {
796 $timePart .= $timezonePart;
797 }
798
799 $dtValue = '';
800
801 if ( empty($datePart) && !empty($timePart) ) {
802 $timePart = convertTimeFormat($timePart);
803 $dtValue = unicodeTrim($timePart);
804 }
805 else if ( !empty($datePart) && empty($timePart) ) {
806 $dtValue = rtrim($datePart, 'T');
807 }
808 else {
809 $timePart = convertTimeFormat($timePart);
810 $dtValue = rtrim($datePart, 'T') . ' ' . unicodeTrim($timePart);
811 }
812 }
813 }
814 } else {
815 // Not using value-class (phew).
816 if ($dt->tagName == 'img' or $dt->tagName == 'area') {
817 // Use @alt
818 // Is it an entire dt?
819 $alt = $dt->getAttribute('alt');
820 if (!empty($alt)) {
821 $dtValue = $alt;
822 }
823 } elseif (in_array($dt->tagName, array('data'))) {
824 // Use @value, otherwise innertext
825 // Is it an entire dt?
826 $value = $dt->getAttribute('value');
827 if (!empty($value)) {
828 $dtValue = $value;
829 }
830 else {
831 $dtValue = $this->textContent($dt);
832 }
833 } elseif ($dt->tagName == 'abbr') {
834 // Use @title, otherwise innertext
835 // Is it an entire dt?
836 $title = $dt->getAttribute('title');
837 if (!empty($title)) {
838 $dtValue = $title;
839 }
840 else {
841 $dtValue = $this->textContent($dt);
842 }
843 } elseif ($dt->tagName == 'del' or $dt->tagName == 'ins' or $dt->tagName == 'time') {
844 // Use @datetime if available, otherwise innertext
845 // Is it an entire dt?
846 $dtAttr = $dt->getAttribute('datetime');
847 if (!empty($dtAttr)) {
848 $dtValue = $dtAttr;
849 }
850 else {
851 $dtValue = $this->textContent($dt);
852 }
853
854 } else {
855 $dtValue = $this->textContent($dt);
856 }
857
858 // if the dtValue is not just YYYY-MM-DD
859 if (!preg_match('/^(\d{4}-\d{2}-\d{2})$/', $dtValue)) {
860 // no implied timezone set and dtValue has a TZ offset, use un-normalized TZ offset
861 preg_match('/Z|[+-]\d{1,2}:?(\d{2})?$/i', $dtValue, $matches);
862 if (!$impliedTimezone && !empty($matches[0])) {
863 $impliedTimezone = $matches[0];
864 }
865 }
866
867 $dtValue = unicodeTrim($dtValue);
868
869 // Store the date part so that we can use it when assembling the final timestamp if the next one is missing a date part
870 if (preg_match('/(\d{4}-\d{2}-\d{2})/', $dtValue, $matches)) {
871 $dates[] = $matches[0];
872 }
873 }
874
875 /**
876 * if $dtValue is only a time and there are recently parsed dates,
877 * form the full date-time using the most recently parsed dt- value
878 */
879 if ((preg_match('/^\d{1,2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:?\d{2}?)?$/', $dtValue) or preg_match('/^\d{1,2}(:\d{2})?(:\d{2})?[ap]\.?m\.?$/i', $dtValue)) && !empty($dates)) {
880 $timezoneOffset = normalizeTimezoneOffset($dtValue);
881 if (!$impliedTimezone && $timezoneOffset) {
882 $impliedTimezone = $timezoneOffset;
883 }
884
885 $dtValue = convertTimeFormat($dtValue);
886 $dtValue = end($dates) . ' ' . unicodeTrim($dtValue);
887 }
888
889 return $dtValue;
890 }
891
892 /**
893 * Given the root element of some embedded markup, return a string representing that markup
894 *
895 * @param DOMElement $e The element to parse
896 * @return string $e’s innerHTML
897 *
898 * @todo need to mark this element as e- parsed so it doesn’t get parsed as it’s parent’s e-* too
899 */
900 public function parseE(\DOMElement $e) {
901 $classTitle = $this->parseValueClassTitle($e);
902
903 if ($classTitle !== null)
904 return $classTitle;
905
906 // Expand relative URLs within children of this element
907 // TODO: as it is this is not relative to only children, make this .// and rerun tests
908 $this->resolveChildUrls($e);
909
910 // Temporarily move all descendants into a separate DocumentFragment.
911 // This way we can DOMDocument::saveHTML on the entire collection at once.
912 // Running DOMDocument::saveHTML per node may add whitespace that isn't in source.
913 // See https://stackoverflow.com/q/38317903
914 if ($innerNodes = $e->ownerDocument->createDocumentFragment()) {
915 while ($e->hasChildNodes()) {
916 $innerNodes->appendChild($e->firstChild);
917 }
918 $html = $e->ownerDocument->saveHtml($innerNodes);
919 // Put the nodes back in place.
920 if ($innerNodes->hasChildNodes()) {
921 $e->appendChild($innerNodes);
922 }
923 }
924
925 $return = array(
926 'html' => unicodeTrim($html),
927 'value' => $this->textContent($e),
928 );
929
930 if($this->lang) {
931 // Language
932 if ( $html_lang = $this->language($e) ) {
933 $return['lang'] = $html_lang;
934 }
935 }
936
937 return $return;
938 }
939
940 private function removeTags(\DOMElement &$e, $tagName) {
941 while(($r = $e->getElementsByTagName($tagName)) && $r->length) {
942 $r->item(0)->parentNode->removeChild($r->item(0));
943 }
944 }
945
946 /**
947 * Recursively parse microformats
948 *
949 * @param DOMElement $e The element to parse
950 * @param bool $is_backcompat Whether using backcompat parsing or not
951 * @param bool $has_nested_mf Whether this microformat has a nested microformat
952 * @return array A representation of the values contained within microformat $e
953 */
954 public function parseH(\DOMElement $e, $is_backcompat = false, $has_nested_mf = false) {
955 // If it’s already been parsed (e.g. is a child mf), skip
956 if ($this->parsed->offsetExists($e)) {
957 return null;
958 }
959
960 // Get current µf name
961 $mfTypes = mfNamesFromElement($e, 'h-');
962
963 if (!$mfTypes) {
964 return null;
965 }
966
967 // Initalise var to store the representation in
968 $return = array();
969 $children = array();
970 $dates = array();
971 $prefixes = array();
972 $impliedTimezone = null;
973
974 if($e->tagName == 'area') {
975 $coords = $e->getAttribute('coords');
976 $shape = $e->getAttribute('shape');
977 }
978
979 // Handle p-*
980 foreach ($this->xpath->query('.//*[contains(concat(" ", normalize-space(@class)) ," p-")]', $e) as $p) {
981 // element is already parsed
982 if ($this->isElementParsed($p, 'p')) {
983 continue;
984 // backcompat parsing and element was not upgraded; skip it
985 } else if ( $is_backcompat && empty($this->upgraded[$p]) ) {
986 $this->elementPrefixParsed($p, 'p');
987 continue;
988 }
989
990 $prefixes[] = 'p-';
991 $pValue = $this->parseP($p);
992
993 // Add the value to the array for it’s p- properties
994 foreach (mfNamesFromElement($p, 'p-') as $propName) {
995 if (!empty($propName)) {
996 $return[$propName][] = $pValue;
997 }
998 }
999
1000 // Make sure this sub-mf won’t get parsed as a top level mf
1001 $this->elementPrefixParsed($p, 'p');
1002 }
1003
1004 // Handle u-*
1005 foreach ($this->xpath->query('.//*[contains(concat(" ", normalize-space(@class))," u-")]', $e) as $u) {
1006 // element is already parsed
1007 if ($this->isElementParsed($u, 'u')) {
1008 continue;
1009 // backcompat parsing and element was not upgraded; skip it
1010 } else if ( $is_backcompat && empty($this->upgraded[$u]) ) {
1011 $this->elementPrefixParsed($u, 'u');
1012 continue;
1013 }
1014
1015 $prefixes[] = 'u-';
1016 $uValue = $this->parseU($u);
1017
1018 // Add the value to the array for it’s property types
1019 foreach (mfNamesFromElement($u, 'u-') as $propName) {
1020 $return[$propName][] = $uValue;
1021 }
1022
1023 // Make sure this sub-mf won’t get parsed as a top level mf
1024 $this->elementPrefixParsed($u, 'u');
1025 }
1026
1027 $temp_dates = array();
1028
1029 // Handle dt-*
1030 foreach ($this->xpath->query('.//*[contains(concat(" ", normalize-space(@class)), " dt-")]', $e) as $dt) {
1031 // element is already parsed
1032 if ($this->isElementParsed($dt, 'dt')) {
1033 continue;
1034 // backcompat parsing and element was not upgraded; skip it
1035 } else if ( $is_backcompat && empty($this->upgraded[$dt]) ) {
1036 $this->elementPrefixParsed($dt, 'dt');
1037 continue;
1038 }
1039
1040 $prefixes[] = 'dt-';
1041 $dtValue = $this->parseDT($dt, $dates, $impliedTimezone);
1042
1043 if ($dtValue) {
1044 // Add the value to the array for dt- properties
1045 foreach (mfNamesFromElement($dt, 'dt-') as $propName) {
1046 $temp_dates[$propName][] = $dtValue;
1047 }
1048 }
1049 // Make sure this sub-mf won’t get parsed as a top level mf
1050 $this->elementPrefixParsed($dt, 'dt');
1051 }
1052
1053 foreach ($temp_dates as $propName => $data) {
1054 foreach ( $data as $dtValue ) {
1055 // var_dump(preg_match('/[+-]\d{2}(\d{2})?$/i', $dtValue));
1056 if ( $impliedTimezone && preg_match('/(Z|[+-]\d{2}:?(\d{2})?)$/i', $dtValue, $matches) == 0 ) {
1057 $dtValue .= $impliedTimezone;
1058 }
1059
1060 $return[$propName][] = $dtValue;
1061 }
1062 }
1063
1064 // Handle e-*
1065 foreach ($this->xpath->query('.//*[contains(concat(" ", normalize-space(@class))," e-")]', $e) as $em) {
1066 // element is already parsed
1067 if ($this->isElementParsed($em, 'e')) {
1068 continue;
1069 // backcompat parsing and element was not upgraded; skip it
1070 } else if ( $is_backcompat && empty($this->upgraded[$em]) ) {
1071 $this->elementPrefixParsed($em, 'e');
1072 continue;
1073 }
1074
1075 $prefixes[] = 'e-';
1076 $eValue = $this->parseE($em);
1077
1078 if ($eValue) {
1079 // Add the value to the array for e- properties
1080 foreach (mfNamesFromElement($em, 'e-') as $propName) {
1081 $return[$propName][] = $eValue;
1082 }
1083 }
1084 // Make sure this sub-mf won’t get parsed as a top level mf
1085 $this->elementPrefixParsed($em, 'e');
1086 }
1087
1088 // Do we need to imply a name property?
1089 // if no explicit "name" property, and no other p-* or e-* properties, and no nested microformats,
1090 if (!array_key_exists('name', $return) && !in_array('p-', $prefixes)
1091 && !in_array('e-', $prefixes) && !$has_nested_mf
1092 && !$is_backcompat && empty($this->upgraded[$e])) {
1093 $name = false;
1094 // img.h-x[alt] or area.h-x[alt]
1095 if (($e->tagName === 'img' || $e->tagName === 'area') && $e->hasAttribute('alt')) {
1096 $name = $e->getAttribute('alt');
1097 // abbr.h-x[title]
1098 } elseif ($e->tagName === 'abbr' && $e->hasAttribute('title')) {
1099 $name = $e->getAttribute('title');
1100 } else {
1101 $xpaths = array(
1102 // .h-x>img:only-child[alt]:not([alt=""]):not[.h-*]
1103 './img[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and @alt and string-length(@alt) != 0]',
1104 // .h-x>area:only-child[alt]:not([alt=""]):not[.h-*]
1105 './area[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and @alt and string-length(@alt) != 0]',
1106 // .h-x>abbr:only-child[title]:not([title=""]):not[.h-*]
1107 './abbr[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and @title and string-length(@title) != 0]',
1108 // .h-x>:only-child:not[.h-*]>img:only-child[alt]:not([alt=""]):not[.h-*]
1109 './*[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and count(*) = 1]/img[not(contains(concat(" ", @class), " h-")) and @alt and string-length(@alt) != 0]',
1110 // .h-x>:only-child:not[.h-*]>area:only-child[alt]:not([alt=""]):not[.h-*]
1111 './*[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and count(*) = 1]/area[not(contains(concat(" ", @class), " h-")) and @alt and string-length(@alt) != 0]',
1112 // .h-x>:only-child:not[.h-*]>abbr:only-child[title]:not([title=""]):not[.h-*]
1113 './*[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and count(*) = 1]/abbr[not(contains(concat(" ", @class), " h-")) and @title and string-length(@title) != 0]'
1114 );
1115 foreach ($xpaths as $xpath) {
1116 $nameElement = $this->xpath->query($xpath, $e);
1117 if ($nameElement !== false && $nameElement->length === 1) {
1118 $nameElement = $nameElement->item(0);
1119 if ($nameElement->tagName === 'img' || $nameElement->tagName === 'area') {
1120 $name = $nameElement->getAttribute('alt');
1121 } else {
1122 $name = $nameElement->getAttribute('title');
1123 }
1124 break;
1125 }
1126 }
1127 }
1128 if ($name === false) {
1129 $name = $this->textContent($e, true);
1130 }
1131 $return['name'][] = unicodeTrim($name);
1132 }
1133
1134 // Check for u-photo
1135 if (!array_key_exists('photo', $return) && !in_array('u-', $prefixes) && !$has_nested_mf && !$is_backcompat) {
1136 $photo = $this->parseImpliedPhoto($e);
1137 if ($photo !== false) {
1138 $return['photo'][] = $photo;
1139 }
1140 }
1141
1142 // Do we need to imply a url property?
1143 // if no explicit "url" property, and no other explicit u-* properties, and no nested microformats
1144 if (!array_key_exists('url', $return) && !in_array('u-', $prefixes) && !$has_nested_mf && !$is_backcompat) {
1145 // a.h-x[href] or area.h-x[href]
1146 if (($e->tagName === 'a' || $e->tagName === 'area') && $e->hasAttribute('href')) {
1147 $return['url'][] = $this->resolveUrl($e->getAttribute('href'));
1148 } else {
1149 $xpaths = array(
1150 // .h-x>a[href]:only-of-type:not[.h-*]
1151 './a[not(contains(concat(" ", @class), " h-")) and count(../a) = 1 and @href]',
1152 // .h-x>area[href]:only-of-type:not[.h-*]
1153 './area[not(contains(concat(" ", @class), " h-")) and count(../area) = 1 and @href]',
1154 // .h-x>:only-child:not[.h-*]>a[href]:only-of-type:not[.h-*]
1155 './*[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and count(a) = 1]/a[not(contains(concat(" ", @class), " h-")) and @href]',
1156 // .h-x>:only-child:not[.h-*]>area[href]:only-of-type:not[.h-*]
1157 './*[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and count(area) = 1]/area[not(contains(concat(" ", @class), " h-")) and @href]'
1158 );
1159 foreach ($xpaths as $xpath) {
1160 $url = $this->xpath->query($xpath, $e);
1161 if ($url !== false && $url->length === 1) {
1162 $return['url'][] = $this->resolveUrl($url->item(0)->getAttribute('href'));
1163 break;
1164 }
1165 }
1166 }
1167 }
1168
1169 // Make sure things are unique and in alphabetical order
1170 $mfTypes = array_unique($mfTypes);
1171 sort($mfTypes);
1172
1173 // Properties should be an object when JSON serialised
1174 if (empty($return) and $this->jsonMode) {
1175 $return = new stdClass();
1176 }
1177
1178 // Phew. Return the final result.
1179 $parsed = array(
1180 'type' => $mfTypes,
1181 'properties' => $return
1182 );
1183
1184 if(trim($e->getAttribute('id')) !== '') {
1185 $parsed['id'] = trim($e->getAttribute("id"));
1186 }
1187
1188 if($this->lang) {
1189 // Language
1190 if ( $html_lang = $this->language($e) ) {
1191 $parsed['lang'] = $html_lang;
1192 }
1193 }
1194
1195 if (!empty($shape)) {
1196 $parsed['shape'] = $shape;
1197 }
1198
1199 if (!empty($coords)) {
1200 $parsed['coords'] = $coords;
1201 }
1202
1203 if (!empty($children)) {
1204 $parsed['children'] = array_values(array_filter($children));
1205 }
1206 return $parsed;
1207 }
1208
1209 /**
1210 * @see http://microformats.org/wiki/microformats2-parsing#parsing_for_implied_properties
1211 */
1212 public function parseImpliedPhoto(\DOMElement $e) {
1213
1214 // img.h-x[src]
1215 if ($e->tagName == 'img') {
1216 return $this->resolveUrl($this->parseImg($e));
1217 }
1218
1219 // object.h-x[data]
1220 if ($e->tagName == 'object' && $e->hasAttribute('data')) {
1221 return $this->resolveUrl($e->getAttribute('data'));
1222 }
1223
1224 $xpaths = array(
1225 // .h-x>img[src]:only-of-type:not[.h-*]
1226 './img[not(contains(concat(" ", @class), " h-")) and count(../img) = 1 and @src]',
1227 // .h-x>object[data]:only-of-type:not[.h-*]
1228 './object[not(contains(concat(" ", @class), " h-")) and count(../object) = 1 and @data]',
1229 // .h-x>:only-child:not[.h-*]>img[src]:only-of-type:not[.h-*]
1230 './*[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and count(img) = 1]/img[not(contains(concat(" ", @class), " h-")) and @src]',
1231 // .h-x>:only-child:not[.h-*]>object[data]:only-of-type:not[.h-*]
1232 './*[not(contains(concat(" ", @class), " h-")) and count(../*) = 1 and count(object) = 1]/object[not(contains(concat(" ", @class), " h-")) and @data]',
1233 );
1234
1235 foreach ($xpaths as $path) {
1236 $els = $this->xpath->query($path, $e);
1237
1238 if ($els !== false && $els->length === 1) {
1239 $el = $els->item(0);
1240 if ($el->tagName == 'img') {
1241 $return = $this->parseImg($el);
1242 return $this->resolveUrl($return);
1243 } else if ($el->tagName == 'object') {
1244 return $this->resolveUrl($el->getAttribute('data'));
1245 }
1246 }
1247 }
1248
1249 // no implied photo
1250 return false;
1251 }
1252
1253 /**
1254 * Parse rels and alternates
1255 *
1256 * Returns [$rels, $rel_urls, $alternates].
1257 * For $rels and $rel_urls, if they are empty and $this->jsonMode = true, they will be returned as stdClass,
1258 * optimizing for JSON serialization. Otherwise they will be returned as an empty array.
1259 * Note that $alternates is deprecated in the microformats spec in favor of $rel_urls. $alternates only appears
1260 * in parsed results if $this->enableAlternates = true.
1261 * @return array|stdClass
1262 */
1263 public function parseRelsAndAlternates() {
1264 $rels = array();
1265 $rel_urls = array();
1266 $alternates = array();
1267
1268 // Iterate through all a, area and link elements with rel attributes
1269 foreach ($this->xpath->query('//a[@rel and @href] | //link[@rel and @href] | //area[@rel and @href]') as $hyperlink) {
1270 // Parse the set of rels for the current link
1271 $linkRels = array_unique(array_filter(preg_split('/[\t\n\f\r ]/', $hyperlink->getAttribute('rel'))));
1272 if (count($linkRels) === 0) {
1273 continue;
1274 }
1275
1276 // Resolve the href
1277 $href = $this->resolveUrl($hyperlink->getAttribute('href'));
1278
1279 $rel_attributes = array();
1280
1281 if ($hyperlink->hasAttribute('media')) {
1282 $rel_attributes['media'] = $hyperlink->getAttribute('media');
1283 }
1284
1285 if ($hyperlink->hasAttribute('hreflang')) {
1286 $rel_attributes['hreflang'] = $hyperlink->getAttribute('hreflang');
1287 }
1288
1289 if ($hyperlink->hasAttribute('title')) {
1290 $rel_attributes['title'] = $hyperlink->getAttribute('title');
1291 }
1292
1293 if ($hyperlink->hasAttribute('type')) {
1294 $rel_attributes['type'] = $hyperlink->getAttribute('type');
1295 }
1296
1297 if (strlen($hyperlink->textContent) > 0) {
1298 $rel_attributes['text'] = $hyperlink->textContent;
1299 }
1300
1301 if ($this->enableAlternates) {
1302 // If 'alternate' in rels, create 'alternates' structure, append
1303 if (in_array('alternate', $linkRels)) {
1304 $alternates[] = array_merge(
1305 $rel_attributes,
1306 array(
1307 'url' => $href,
1308 'rel' => implode(' ', array_diff($linkRels, array('alternate')))
1309 )
1310 );
1311 }
1312 }
1313
1314 foreach ($linkRels as $rel) {
1315 if (!array_key_exists($rel, $rels)) {
1316 $rels[$rel] = array($href);
1317 } elseif (!in_array($href, $rels[$rel])) {
1318 $rels[$rel][] = $href;
1319 }
1320 }
1321
1322 if (!array_key_exists($href, $rel_urls)) {
1323 $rel_urls[$href] = array('rels' => array());
1324 }
1325
1326 // Add the attributes collected only if they were not already set
1327 $rel_urls[$href] = array_merge(
1328 $rel_attributes,
1329 $rel_urls[$href]
1330 );
1331
1332 // Merge current rels with those already set
1333 $rel_urls[$href]['rels'] = array_merge(
1334 $rel_urls[$href]['rels'],
1335 $linkRels
1336 );
1337 }
1338
1339 // Alphabetically sort the rels arrays after removing duplicates
1340 foreach ($rel_urls as $href => $object) {
1341 $rel_urls[$href]['rels'] = array_unique($rel_urls[$href]['rels']);
1342 sort($rel_urls[$href]['rels']);
1343 }
1344
1345 if (empty($rels) and $this->jsonMode) {
1346 $rels = new stdClass();
1347 }
1348
1349 if (empty($rel_urls) and $this->jsonMode) {
1350 $rel_urls = new stdClass();
1351 }
1352
1353 return array($rels, $rel_urls, $alternates);
1354 }
1355
1356 /**
1357 * Find rel=tag elements that don't have class=category and have an href.
1358 * For each element, get the last non-empty URL segment. Append a <data>
1359 * element with that value as the category. Uses the mf1 class 'category'
1360 * which will then be upgraded to p-category during backcompat.
1361 * @param DOMElement $el
1362 */
1363 public function upgradeRelTagToCategory(DOMElement $el) {
1364 $rel_tag = $this->xpath->query('.//a[contains(concat(" ",normalize-space(@rel)," ")," tag ") and not(contains(concat(" ", normalize-space(@class), " "), " category ")) and @href]', $el);
1365
1366 if ( $rel_tag->length ) {
1367 foreach ( $rel_tag as $tempEl ) {
1368 $path = trim(parse_url($tempEl->getAttribute('href'), PHP_URL_PATH), ' /');
1369 $segments = explode('/', $path);
1370 $value = array_pop($segments);
1371
1372 # build the <data> element
1373 $dataEl = $tempEl->ownerDocument->createElement('data');
1374 $dataEl->setAttribute('class', 'category');
1375 $dataEl->setAttribute('value', $value);
1376
1377 # append as child of input element. this should ensure added element does get parsed inside e-*
1378 $el->appendChild($dataEl);
1379 }
1380 }
1381 }
1382
1383 /**
1384 * Kicks off the parsing routine
1385 * @param bool $convertClassic whether to do backcompat parsing on microformats1. Defaults to true.
1386 * @param DOMElement $context optionally specify an element from which to parse microformats
1387 * @return array An array containing all the microformats found in the current document
1388 */
1389 public function parse($convertClassic = true, ?DOMElement $context = null) {
1390 $this->convertClassic = $convertClassic;
1391 $mfs = $this->parse_recursive($context);
1392
1393 // Parse rels
1394 list($rels, $rel_urls, $alternates) = $this->parseRelsAndAlternates();
1395
1396 $top = array(
1397 'items' => array_values(array_filter($mfs)),
1398 'rels' => $rels,
1399 'rel-urls' => $rel_urls,
1400 );
1401
1402 if ($this->enableAlternates && count($alternates)) {
1403 $top['alternates'] = $alternates;
1404 }
1405
1406 $top['title'] = '';
1407 $x_title = $this->xpath->query( '//title' );
1408 if ( $x_title && $x_title->item( 0 ) ) {
1409 $top['title'] = $x_title->item( 0 )->textContent;
1410 }
1411
1412 return $top;
1413 }
1414
1415
1416 /**
1417 * Parse microformats recursively
1418 * Keeps track of whether inside a backcompat root or not
1419 * @param DOMElement $context: node to start with
1420 * @param int $depth: recursion depth
1421 * @return array
1422 */
1423 public function parse_recursive(?DOMElement $context = null, $depth = 0) {
1424 if ( $depth > 2 ) {
1425 return array();
1426 }
1427
1428 $mfs = array();
1429 $mfElements = $this->getRootMF($context);
1430
1431 foreach ($mfElements as $node) {
1432 $is_backcompat = !$this->hasRootMf2($node);
1433
1434 if ($this->convertClassic && $is_backcompat) {
1435 $this->backcompat($node);
1436 }
1437
1438 $recurse = $this->parse_recursive($node, $depth + 1);
1439
1440 // set bool flag for nested mf
1441 $has_nested_mf = (bool) $recurse;
1442
1443 // parse for root mf
1444 $result = $this->parseH($node, $is_backcompat, $has_nested_mf);
1445
1446 // TODO: Determine if clearing this is required?
1447 $this->elementPrefixParsed($node, 'h');
1448 $this->elementPrefixParsed($node, 'p');
1449 $this->elementPrefixParsed($node, 'u');
1450 $this->elementPrefixParsed($node, 'dt');
1451 $this->elementPrefixParsed($node, 'e');
1452
1453 // parseH returned a parsed result
1454 if ($result) {
1455
1456 // merge recursive results into current results
1457 if ($recurse) {
1458 $result = array_merge_recursive($result, $recurse);
1459 }
1460
1461 // currently a nested mf; check if node is an mf property of parent
1462 if ($depth > 0) {
1463 $temp_properties = nestedMfPropertyNamesFromElement($node);
1464
1465 // properties found; set up parsed result in 'properties'
1466 if (!empty($temp_properties)) {
1467
1468 foreach ($temp_properties as $property => $prefixes) {
1469 // Note: handling microformat nesting under multiple conflicting prefixes is not currently specified by the mf2 parsing spec.
1470 $prefixSpecificResult = $result;
1471 if (in_array('p-', $prefixes)) {
1472 $prefixSpecificResult['value'] = (!is_array($prefixSpecificResult['properties']) || empty($prefixSpecificResult['properties']['name'][0])) ? $this->parseP($node) : $prefixSpecificResult['properties']['name'][0];
1473 } elseif (in_array('e-', $prefixes)) {
1474 $eParsedResult = $this->parseE($node);
1475 $prefixSpecificResult['html'] = $eParsedResult['html'];
1476 $prefixSpecificResult['value'] = $eParsedResult['value'];
1477 } elseif (in_array('u-', $prefixes)) {
1478 $prefixSpecificResult['value'] = (!is_array($result['properties']) || empty($result['properties']['url'])) ? $this->parseU($node) : reset($result['properties']['url']);
1479 } elseif (in_array('dt-', $prefixes)) {
1480 $parsed_property = $this->parseDT($node);
1481 $prefixSpecificResult['value'] = ($parsed_property) ? $parsed_property : '';
1482 }
1483 $prefixSpecificResult['value'] = is_array($prefixSpecificResult['value']) ? $prefixSpecificResult['value']['value'] : $prefixSpecificResult['value'];
1484
1485 $mfs['properties'][$property][] = $prefixSpecificResult;
1486 }
1487
1488 // otherwise, set up in 'children'
1489 } else {
1490 $mfs['children'][] = $result;
1491 }
1492 // otherwise, top-level mf
1493 } else {
1494 $mfs[] = $result;
1495 }
1496 }
1497 }
1498
1499 return $mfs;
1500 }
1501
1502
1503 /**
1504 * Parse From ID
1505 *
1506 * Given an ID, parse all microformats which are children of the element with
1507 * that ID.
1508 *
1509 * Note that rel values are still document-wide.
1510 *
1511 * If an element with the ID is not found, an empty skeleton mf2 array structure
1512 * will be returned.
1513 *
1514 * @param string $id
1515 * @param bool $htmlSafe = false whether or not to HTML-encode angle brackets in non e-* properties
1516 * @return array
1517 */
1518 public function parseFromId($id, $convertClassic=true) {
1519 $matches = $this->xpath->query("//*[@id='{$id}']");
1520
1521 if (empty($matches))
1522 return array('items' => array(), 'rels' => array(), 'alternates' => array());
1523
1524 return $this->parse($convertClassic, $matches->item(0));
1525 }
1526
1527 /**
1528 * Get the root microformat elements
1529 * @param DOMElement $context
1530 * @return DOMNodeList
1531 */
1532 public function getRootMF(?DOMElement $context = null) {
1533 // start with mf2 root class name xpath
1534 $xpaths = array(
1535 '(php:function("\\Friends\\Mf2\\classHasMf2RootClassname", normalize-space(@class)))'
1536 );
1537
1538 // add mf1 root class names
1539 foreach ( $this->classicRootMap as $old => $new ) {
1540 $xpaths[] = '( contains(concat(" ",normalize-space(@class), " "), " ' . $old . ' ") )';
1541 }
1542
1543 // final xpath with OR
1544 $xpath = '//*[' . implode(' or ', $xpaths) . ']';
1545
1546 $mfElements = (null === $context)
1547 ? $this->xpath->query($xpath)
1548 : $this->xpath->query('.' . $xpath, $context);
1549
1550 return $mfElements;
1551 }
1552
1553 /**
1554 * Apply the backcompat algorithm to upgrade mf1 classes to mf2.
1555 * This method is called recursively.
1556 * @param DOMElement $el
1557 * @param string $context
1558 * @param bool $isParentMf2
1559 * @see http://microformats.org/wiki/microformats2-parsing#algorithm
1560 */
1561 public function backcompat(DOMElement $el, $context = '', $isParentMf2 = false) {
1562
1563 if ( $context ) {
1564 $mf1Classes = array($context);
1565 } else {
1566 $class = str_replace(array("\t", "\n"), ' ', $el->getAttribute('class'));
1567 $classes = array_filter(explode(' ', $class));
1568 $mf1Classes = array_intersect($classes, array_keys($this->classicRootMap));
1569 }
1570
1571 $elHasMf2 = $this->hasRootMf2($el);
1572
1573 foreach ($mf1Classes as $classname) {
1574 // special handling for specific properties
1575 switch ( $classname )
1576 {
1577 case 'hentry':
1578 $this->upgradeRelTagToCategory($el);
1579
1580 $rel_bookmark = $this->xpath->query('.//a[contains(concat(" ",normalize-space(@rel)," ")," bookmark ") and @href]', $el);
1581
1582 if ( $rel_bookmark->length ) {
1583 foreach ( $rel_bookmark as $tempEl ) {
1584 $this->addMfClasses($tempEl, 'u-url');
1585 $this->addUpgraded($tempEl, array('bookmark'));
1586 }
1587 }
1588 break;
1589
1590 case 'hfeed':
1591 $this->upgradeRelTagToCategory($el);
1592 break;
1593
1594 case 'hproduct':
1595 $review_and_hreview_aggregate = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " review ") and contains(concat(" ", normalize-space(@class), " "), " hreview-aggregate ")]', $el);
1596
1597 if ( $review_and_hreview_aggregate->length ) {
1598 foreach ( $review_and_hreview_aggregate as $tempEl ) {
1599 if ( !$this->hasRootMf2($tempEl) ) {
1600 $this->backcompat($tempEl, 'hreview-aggregate');
1601 $this->addMfClasses($tempEl, 'p-review h-review-aggregate');
1602 $this->addUpgraded($tempEl, array('review hreview-aggregate'));
1603 }
1604 }
1605 }
1606
1607 $review_and_hreview = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " review ") and contains(concat(" ", normalize-space(@class), " "), " hreview ")]', $el);
1608
1609 if ( $review_and_hreview->length ) {
1610 foreach ( $review_and_hreview as $tempEl ) {
1611 if ( !$this->hasRootMf2($tempEl) ) {
1612 $this->backcompat($tempEl, 'hreview');
1613 $this->addMfClasses($tempEl, 'p-review h-review');
1614 $this->addUpgraded($tempEl, array('review hreview'));
1615 }
1616 }
1617 }
1618
1619 break;
1620
1621 case 'hreview-aggregate':
1622 case 'hreview':
1623 $item_and_vcard = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " item ") and contains(concat(" ", normalize-space(@class), " "), " vcard ")]', $el);
1624
1625 if ( $item_and_vcard->length ) {
1626 foreach ( $item_and_vcard as $tempEl ) {
1627 if ( !$this->hasRootMf2($tempEl) ) {
1628 $this->backcompat($tempEl, 'vcard');
1629 $this->addMfClasses($tempEl, 'p-item h-card');
1630 $this->addUpgraded($tempEl, array('item', 'vcard'));
1631 }
1632 }
1633 }
1634
1635 $item_and_vevent = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " item ") and contains(concat(" ", normalize-space(@class), " "), " vevent ")]', $el);
1636
1637 if ( $item_and_vevent->length ) {
1638 foreach ( $item_and_vevent as $tempEl ) {
1639 if ( !$this->hasRootMf2($tempEl) ) {
1640 $this->addMfClasses($tempEl, 'p-item h-event');
1641 $this->backcompat($tempEl, 'vevent');
1642 $this->addUpgraded($tempEl, array('item', 'vevent'));
1643 }
1644 }
1645 }
1646
1647 $item_and_hproduct = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " item ") and contains(concat(" ", normalize-space(@class), " "), " hproduct ")]', $el);
1648
1649 if ( $item_and_hproduct->length ) {
1650 foreach ( $item_and_hproduct as $tempEl ) {
1651 if ( !$this->hasRootMf2($tempEl) ) {
1652 $this->addMfClasses($tempEl, 'p-item h-product');
1653 $this->backcompat($tempEl, 'hproduct');
1654 $this->addUpgraded($tempEl, array('item', 'hproduct'));
1655 }
1656 }
1657 }
1658
1659 $rel_self_bookmark = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@rel), " "), " self ") and contains(concat(" ", normalize-space(@rel), " "), " bookmark ")]', $el);
1660
1661 if ( $rel_self_bookmark->length ) {
1662 foreach ( $rel_self_bookmark as $tempEl ) {
1663 $this->addMfClasses($tempEl, 'u-url');
1664 $this->addUpgraded($tempEl, array('self', 'bookmark'));
1665 }
1666 }
1667
1668 $reviewer_nodes = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " reviewer ")]', $el);
1669
1670 if ( $reviewer_nodes->length ) {
1671 foreach ( $reviewer_nodes as $tempEl ) {
1672 if ( !$this->hasRootMf2($tempEl) ) {
1673 $this->addMfClasses($tempEl, 'p-author h-card');
1674 }
1675 }
1676 }
1677
1678 $this->upgradeRelTagToCategory($el);
1679 break;
1680
1681 case 'vevent':
1682 $location_and_vcard = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " location ") and contains(concat(" ", normalize-space(@class), " "), " vcard ")]', $el);
1683
1684 if ( $location_and_vcard->length ) {
1685 foreach ( $location_and_vcard as $tempEl ) {
1686 if ( !$this->hasRootMf2($tempEl) ) {
1687 $this->addMfClasses($tempEl, 'p-location h-card');
1688 $this->backcompat($tempEl, 'vcard');
1689 $this->addUpgraded($tempEl, array('location', 'vcard'));
1690 }
1691 }
1692 }
1693 break;
1694 }
1695
1696 // root class has mf1 properties to be upgraded
1697 if ( isset($this->classicPropertyMap[$classname]) ) {
1698 // loop through each property of the mf1 root
1699 foreach ( $this->classicPropertyMap[$classname] as $property => $data ) {
1700 $propertyElements = $this->xpath->query('.//*[contains(concat(" ", normalize-space(@class), " "), " ' . $property . ' ")]', $el);
1701
1702 // loop through each element with the property
1703 foreach ( $propertyElements as $propertyEl ) {
1704 $hasRootMf2 = $this->hasRootMf2($propertyEl);
1705
1706 // if the element has not been upgraded and we're not inside an mf2 root, recurse
1707 if ( !$this->isElementUpgraded($propertyEl, $property) && !$isParentMf2 )
1708 {
1709 $temp_context = ( isset($data['context']) ) ? $data['context'] : null;
1710 $this->backcompat($propertyEl, $temp_context, $hasRootMf2);
1711 $this->addMfClasses($propertyEl, $data['replace']);
1712 }
1713
1714 $this->addUpgraded($propertyEl, $property);
1715 }
1716 }
1717 }
1718
1719 if ( empty($context) && isset($this->classicRootMap[$classname]) && !$elHasMf2 ) {
1720 $this->addMfClasses($el, $this->classicRootMap[$classname]);
1721 }
1722 }
1723
1724 return;
1725 }
1726
1727 /**
1728 * Add element + property as upgraded during backcompat
1729 * @param DOMElement $el
1730 * @param string|array $property
1731 */
1732 public function addUpgraded(DOMElement $el, $property) {
1733 if ( !is_array($property) ) {
1734 $property = array($property);
1735 }
1736
1737 // add element to list of upgraded elements
1738 if ( !$this->upgraded->offsetExists($el) ) {
1739 $this->upgraded->offsetSet($el, $property);
1740 } else {
1741 $this->upgraded[$el] = array_merge($this->upgraded[$el], $property);
1742 }
1743 }
1744
1745 /**
1746 * Add the provided classes to an element.
1747 * Does not add duplicate if class name already exists.
1748 * @param DOMElement $el
1749 * @param string $classes
1750 */
1751 public function addMfClasses(DOMElement $el, $classes) {
1752 $existingClasses = str_replace(array("\t", "\n"), ' ', $el->getAttribute('class'));
1753 $existingClasses = array_filter(explode(' ', $existingClasses));
1754
1755 $addClasses = array_diff(explode(' ', $classes), $existingClasses);
1756
1757 if ( $addClasses ) {
1758 $el->setAttribute('class', $el->getAttribute('class') . ' ' . implode(' ', $addClasses));
1759 }
1760 }
1761
1762 /**
1763 * Check an element for mf2 h-* class, typically to determine if backcompat should be used
1764 * @param DOMElement $el
1765 */
1766 public function hasRootMf2(\DOMElement $el) {
1767 $class = str_replace(array("\t", "\n"), ' ', $el->getAttribute('class'));
1768
1769 // Check for valid mf2 root classnames, not just any classname with a h- prefix.
1770 return count(mfNamesFromClass($class, 'h-')) > 0;
1771 }
1772
1773 /**
1774 * Convert Legacy Classnames
1775 *
1776 * Adds microformats2 classnames into a document containing only legacy
1777 * semantic classnames.
1778 *
1779 * @return Parser $this
1780 */
1781 public function convertLegacy() {
1782 $doc = $this->doc;
1783 $xp = new DOMXPath($doc);
1784
1785 // replace all roots
1786 foreach ($this->classicRootMap as $old => $new) {
1787 foreach ($xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " ' . $old . ' ") and not(contains(concat(" ", normalize-space(@class), " "), " ' . $new . ' "))]') as $el) {
1788 $el->setAttribute('class', $el->getAttribute('class') . ' ' . $new);
1789 }
1790 }
1791
1792 foreach ($this->classicPropertyMap as $oldRoot => $properties) {
1793 $newRoot = $this->classicRootMap[$oldRoot];
1794 foreach ($properties as $old => $data) {
1795 foreach ($xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " ' . $oldRoot . ' ")]//*[contains(concat(" ", normalize-space(@class), " "), " ' . $old . ' ") and not(contains(concat(" ", normalize-space(@class), " "), " ' . $data['replace'] . ' "))]') as $el) {
1796 $el->setAttribute('class', $el->getAttribute('class') . ' ' . $data['replace']);
1797 }
1798 }
1799 }
1800
1801 return $this;
1802 }
1803
1804 /**
1805 * XPath Query
1806 *
1807 * Runs an XPath query over the current document. Works in exactly the same
1808 * way as DOMXPath::query.
1809 *
1810 * @param string $expression
1811 * @param DOMNode $context
1812 * @return DOMNodeList
1813 */
1814 public function query($expression, $context = null) {
1815 return $this->xpath->query($expression, $context);
1816 }
1817
1818 /**
1819 * Classic Root Classname map
1820 * @var array
1821 */
1822 public $classicRootMap = array(
1823 'vcard' => 'h-card',
1824 'hfeed' => 'h-feed',
1825 'hentry' => 'h-entry',
1826 'hrecipe' => 'h-recipe',
1827 'hresume' => 'h-resume',
1828 'vevent' => 'h-event',
1829 'hreview' => 'h-review',
1830 'hreview-aggregate' => 'h-review-aggregate',
1831 'hproduct' => 'h-product',
1832 'adr' => 'h-adr',
1833 'geo' => 'h-geo'
1834 );
1835
1836 /**
1837 * Mapping of mf1 properties to mf2 and the context they're parsed with
1838 * @var array
1839 */
1840 public $classicPropertyMap = array(
1841 'vcard' => array(
1842 'fn' => array(
1843 'replace' => 'p-name'
1844 ),
1845 'honorific-prefix' => array(
1846 'replace' => 'p-honorific-prefix'
1847 ),
1848 'given-name' => array(
1849 'replace' => 'p-given-name'
1850 ),
1851 'additional-name' => array(
1852 'replace' => 'p-additional-name'
1853 ),
1854 'family-name' => array(
1855 'replace' => 'p-family-name'
1856 ),
1857 'honorific-suffix' => array(
1858 'replace' => 'p-honorific-suffix'
1859 ),
1860 'nickname' => array(
1861 'replace' => 'p-nickname'
1862 ),
1863 'email' => array(
1864 'replace' => 'u-email'
1865 ),
1866 'logo' => array(
1867 'replace' => 'u-logo'
1868 ),
1869 'photo' => array(
1870 'replace' => 'u-photo'
1871 ),
1872 'url' => array(
1873 'replace' => 'u-url'
1874 ),
1875 'uid' => array(
1876 'replace' => 'u-uid'
1877 ),
1878 'category' => array(
1879 'replace' => 'p-category'
1880 ),
1881 'adr' => array(
1882 'replace' => 'p-adr',
1883 ),
1884 'extended-address' => array(
1885 'replace' => 'p-extended-address'
1886 ),
1887 'street-address' => array(
1888 'replace' => 'p-street-address'
1889 ),
1890 'locality' => array(
1891 'replace' => 'p-locality'
1892 ),
1893 'region' => array(
1894 'replace' => 'p-region'
1895 ),
1896 'postal-code' => array(
1897 'replace' => 'p-postal-code'
1898 ),
1899 'country-name' => array(
1900 'replace' => 'p-country-name'
1901 ),
1902 'label' => array(
1903 'replace' => 'p-label'
1904 ),
1905 'geo' => array(
1906 'replace' => 'p-geo h-geo',
1907 'context' => 'geo'
1908 ),
1909 'latitude' => array(
1910 'replace' => 'p-latitude'
1911 ),
1912 'longitude' => array(
1913 'replace' => 'p-longitude'
1914 ),
1915 'tel' => array(
1916 'replace' => 'p-tel'
1917 ),
1918 'note' => array(
1919 'replace' => 'p-note'
1920 ),
1921 'bday' => array(
1922 'replace' => 'dt-bday'
1923 ),
1924 'key' => array(
1925 'replace' => 'u-key'
1926 ),
1927 'org' => array(
1928 'replace' => 'p-org'
1929 ),
1930 'organization-name' => array(
1931 'replace' => 'p-organization-name'
1932 ),
1933 'organization-unit' => array(
1934 'replace' => 'p-organization-unit'
1935 ),
1936 'title' => array(
1937 'replace' => 'p-job-title'
1938 ),
1939 'role' => array(
1940 'replace' => 'p-role'
1941 ),
1942 'tz' => array(
1943 'replace' => 'p-tz'
1944 ),
1945 'rev' => array(
1946 'replace' => 'dt-rev'
1947 ),
1948 ),
1949 'hfeed' => array(
1950 'author' => array(
1951 'replace' => 'p-author h-card',
1952 'context' => 'vcard'
1953 ),
1954 'url' => array(
1955 'replace' => 'u-url'
1956 ),
1957 'photo' => array(
1958 'replace' => 'u-photo'
1959 ),
1960 'category' => array(
1961 'replace' => 'p-category'
1962 ),
1963 ),
1964 'hentry' => array(
1965 'entry-title' => array(
1966 'replace' => 'p-name'
1967 ),
1968 'entry-summary' => array(
1969 'replace' => 'p-summary'
1970 ),
1971 'entry-content' => array(
1972 'replace' => 'e-content'
1973 ),
1974 'published' => array(
1975 'replace' => 'dt-published'
1976 ),
1977 'updated' => array(
1978 'replace' => 'dt-updated'
1979 ),
1980 'author' => array(
1981 'replace' => 'p-author h-card',
1982 'context' => 'vcard',
1983 ),
1984 'category' => array(
1985 'replace' => 'p-category'
1986 ),
1987 ),
1988 'hrecipe' => array(
1989 'fn' => array(
1990 'replace' => 'p-name'
1991 ),
1992 'ingredient' => array(
1993 'replace' => 'p-ingredient'
1994 /**
1995 * TODO: hRecipe 'value' and 'type' child mf not parsing correctly currently.
1996 * Per http://microformats.org/wiki/hRecipe#Property_details, they're experimental.
1997 */
1998 ),
1999 'yield' => array(
2000 'replace' => 'p-yield'
2001 ),
2002 'instructions' => array(
2003 'replace' => 'e-instructions'
2004 ),
2005 'duration' => array(
2006 'replace' => 'dt-duration'
2007 ),
2008 'photo' => array(
2009 'replace' => 'u-photo'
2010 ),
2011 'summary' => array(
2012 'replace' => 'p-summary'
2013 ),
2014 'author' => array(
2015 'replace' => 'p-author h-card',
2016 'context' => 'vcard',
2017 ),
2018 'nutrition' => array(
2019 'replace' => 'p-nutrition'
2020 ),
2021 'category' => array(
2022 'replace' => 'p-category'
2023 ),
2024 ),
2025 'hresume' => array(
2026 'summary' => array(
2027 'replace' => 'p-summary'
2028 ),
2029 'contact' => array(
2030 'replace' => 'p-contact h-card',
2031 'context' => 'vcard',
2032 ),
2033 'education' => array(
2034 'replace' => 'p-education h-event',
2035 'context' => 'vevent',
2036 ),
2037 'experience' => array(
2038 'replace' => 'p-experience h-event',
2039 'context' => 'vevent',
2040 ),
2041 'skill' => array(
2042 'replace' => 'p-skill'
2043 ),
2044 'affiliation' => array(
2045 'replace' => 'p-affiliation h-card',
2046 'context' => 'vcard',
2047 ),
2048 ),
2049 'vevent' => array(
2050 'summary' => array(
2051 'replace' => 'p-name'
2052 ),
2053 'dtstart' => array(
2054 'replace' => 'dt-start'
2055 ),
2056 'dtend' => array(
2057 'replace' => 'dt-end'
2058 ),
2059 'duration' => array(
2060 'replace' => 'dt-duration'
2061 ),
2062 'description' => array(
2063 'replace' => 'p-description'
2064 ),
2065 'url' => array(
2066 'replace' => 'u-url'
2067 ),
2068 'category' => array(
2069 'replace' => 'p-category'
2070 ),
2071 'location' => array(
2072 'replace' => 'p-location',
2073 ),
2074 'geo' => array(
2075 'replace' => 'p-location h-geo'
2076 ),
2077 'attendee' => array(
2078 'replace' => 'p-attendee h-card',
2079 'context' => 'vcard'
2080 )
2081 ),
2082 'hreview' => array(
2083 'summary' => array(
2084 'replace' => 'p-name'
2085 ),
2086 # fn: see item.fn below
2087 # photo: see item.photo below
2088 # url: see item.url below
2089 'item' => array(
2090 'replace' => 'p-item h-item',
2091 'context' => 'item'
2092 ),
2093 # reviewer: see backcompat()
2094 'dtreviewed' => array(
2095 'replace' => 'dt-published'
2096 ),
2097 'rating' => array(
2098 'replace' => 'p-rating'
2099 ),
2100 'best' => array(
2101 'replace' => 'p-best'
2102 ),
2103 'worst' => array(
2104 'replace' => 'p-worst'
2105 ),
2106 'description' => array(
2107 'replace' => 'e-content'
2108 ),
2109 'category' => array(
2110 'replace' => 'p-category'
2111 ),
2112 ),
2113 'hreview-aggregate' => array(
2114 'summary' => array(
2115 'replace' => 'p-name'
2116 ),
2117 # fn: see item.fn below
2118 # photo: see item.photo below
2119 # url: see item.url below
2120 'item' => array(
2121 'replace' => 'p-item h-item',
2122 'context' => 'item'
2123 ),
2124 'rating' => array(
2125 'replace' => 'p-rating'
2126 ),
2127 'best' => array(
2128 'replace' => 'p-best'
2129 ),
2130 'worst' => array(
2131 'replace' => 'p-worst'
2132 ),
2133 'average' => array(
2134 'replace' => 'p-average'
2135 ),
2136 'count' => array(
2137 'replace' => 'p-count'
2138 ),
2139 'votes' => array(
2140 'replace' => 'p-votes'
2141 ),
2142 ),
2143 'hproduct' => array(
2144 'fn' => array(
2145 'replace' => 'p-name',
2146 ),
2147 'photo' => array(
2148 'replace' => 'u-photo',
2149 ),
2150 'brand' => array(
2151 'replace' => 'p-brand',
2152 ),
2153 'category' => array(
2154 'replace' => 'p-category',
2155 ),
2156 'description' => array(
2157 'replace' => 'p-description',
2158 ),
2159 'identifier' => array(
2160 'replace' => 'u-identifier',
2161 ),
2162 'url' => array(
2163 'replace' => 'u-url',
2164 ),
2165 // review is handled in the special processing section to allow for 'review hreview-aggregate'
2166 'price' => array(
2167 'replace' => 'p-price'
2168 ),
2169 ),
2170 'item' => array(
2171 'fn' => array(
2172 'replace' => 'p-name'
2173 ),
2174 'url' => array(
2175 'replace' => 'u-url'
2176 ),
2177 'photo' => array(
2178 'replace' => 'u-photo'
2179 ),
2180 ),
2181 'adr' => array(
2182 'post-office-box' => array(
2183 'replace' => 'p-post-office-box'
2184 ),
2185 'extended-address' => array(
2186 'replace' => 'p-extended-address'
2187 ),
2188 'street-address' => array(
2189 'replace' => 'p-street-address'
2190 ),
2191 'locality' => array(
2192 'replace' => 'p-locality'
2193 ),
2194 'region' => array(
2195 'replace' => 'p-region'
2196 ),
2197 'postal-code' => array(
2198 'replace' => 'p-postal-code'
2199 ),
2200 'country-name' => array(
2201 'replace' => 'p-country-name'
2202 ),
2203 ),
2204 'geo' => array(
2205 'latitude' => array(
2206 'replace' => 'p-latitude'
2207 ),
2208 'longitude' => array(
2209 'replace' => 'p-longitude'
2210 ),
2211 ),
2212 );
2213 }
2214
2215 function parseUriToComponents($uri) {
2216 $result = array(
2217 'scheme' => null,
2218 'authority' => null,
2219 'path' => null,
2220 'query' => null,
2221 'fragment' => null
2222 );
2223
2224 if ( ! is_string( $uri ) ) {
2225 return $result;
2226 }
2227
2228 $u = parse_url($uri);
2229 if ( ! $u ) {
2230 return $result;
2231 }
2232
2233 if(array_key_exists('scheme', $u))
2234 $result['scheme'] = $u['scheme'];
2235
2236 if(array_key_exists('host', $u)) {
2237 if(array_key_exists('user', $u))
2238 $result['authority'] = $u['user'];
2239 if(array_key_exists('pass', $u))
2240 $result['authority'] .= ':' . $u['pass'];
2241 if(array_key_exists('user', $u) || array_key_exists('pass', $u))
2242 $result['authority'] .= '@';
2243 $result['authority'] .= $u['host'];
2244 if(array_key_exists('port', $u))
2245 $result['authority'] .= ':' . $u['port'];
2246 }
2247
2248 if(array_key_exists('path', $u))
2249 $result['path'] = $u['path'];
2250
2251 if(array_key_exists('query', $u))
2252 $result['query'] = $u['query'];
2253
2254 if(array_key_exists('fragment', $u))
2255 $result['fragment'] = $u['fragment'];
2256
2257 return $result;
2258 }
2259
2260 function resolveUrl($baseURI, $referenceURI) {
2261 $target = array(
2262 'scheme' => null,
2263 'authority' => null,
2264 'path' => null,
2265 'query' => null,
2266 'fragment' => null
2267 );
2268
2269 # 5.2.1 Pre-parse the Base URI
2270 # The base URI (Base) is established according to the procedure of
2271 # Section 5.1 and parsed into the five main components described in
2272 # Section 3
2273 $base = parseUriToComponents($baseURI);
2274
2275 # If base path is blank (http://example.com) then set it to /
2276 # (I can't tell if this is actually in the RFC or not, but seems like it makes sense)
2277 if($base['path'] == null)
2278 $base['path'] = '/';
2279
2280 # 5.2.2. Transform References
2281
2282 # The URI reference is parsed into the five URI components
2283 # (R.scheme, R.authority, R.path, R.query, R.fragment) = parse(R);
2284 $reference = parseUriToComponents($referenceURI);
2285
2286 # A non-strict parser may ignore a scheme in the reference
2287 # if it is identical to the base URI's scheme.
2288 # TODO
2289
2290 if($reference['scheme']) {
2291 $target['scheme'] = $reference['scheme'];
2292 $target['authority'] = $reference['authority'];
2293 $target['path'] = removeDotSegments($reference['path']);
2294 $target['query'] = $reference['query'];
2295 } else {
2296 if($reference['authority']) {
2297 $target['authority'] = $reference['authority'];
2298 $target['path'] = removeDotSegments($reference['path']);
2299 $target['query'] = $reference['query'];
2300 } else {
2301 if($reference['path'] == '') {
2302 $target['path'] = $base['path'];
2303 if($reference['query']) {
2304 $target['query'] = $reference['query'];
2305 } else {
2306 $target['query'] = $base['query'];
2307 }
2308 } else {
2309 if(substr($reference['path'], 0, 1) == '/') {
2310 $target['path'] = removeDotSegments($reference['path']);
2311 } else {
2312 $target['path'] = mergePaths($base, $reference);
2313 $target['path'] = removeDotSegments($target['path']);
2314 }
2315 $target['query'] = $reference['query'];
2316 }
2317 $target['authority'] = $base['authority'];
2318 }
2319 $target['scheme'] = $base['scheme'];
2320 }
2321 $target['fragment'] = $reference['fragment'];
2322
2323 # 5.3 Component Recomposition
2324 $result = '';
2325 if($target['scheme']) {
2326 $result .= $target['scheme'] . ':';
2327 }
2328 if($target['authority']) {
2329 $result .= '//' . $target['authority'];
2330 }
2331 $result .= $target['path'];
2332 if($target['query']) {
2333 $result .= '?' . $target['query'];
2334 }
2335 if($target['fragment']) {
2336 $result .= '#' . $target['fragment'];
2337 } elseif($referenceURI == '#') {
2338 $result .= '#';
2339 }
2340 return $result;
2341 }
2342
2343 # 5.2.3 Merge Paths
2344 function mergePaths($base, $reference) {
2345 # If the base URI has a defined authority component and an empty
2346 # path,
2347 if($base['authority'] && $base['path'] == null) {
2348 # then return a string consisting of "/" concatenated with the
2349 # reference's path; otherwise,
2350 $merged = '/' . $reference['path'];
2351 } else {
2352 if(($pos=strrpos($base['path'], '/')) !== false) {
2353 # return a string consisting of the reference's path component
2354 # appended to all but the last segment of the base URI's path (i.e.,
2355 # excluding any characters after the right-most "/" in the base URI
2356 # path,
2357 $merged = substr($base['path'], 0, $pos + 1) . $reference['path'];
2358 } else {
2359 # or excluding the entire base URI path if it does not contain
2360 # any "/" characters).
2361 $merged = $base['path'];
2362 }
2363 }
2364 return $merged;
2365 }
2366
2367 # 5.2.4.A Remove leading ../ or ./
2368 function removeLeadingDotSlash(&$input) {
2369 if(substr($input, 0, 3) == '../') {
2370 $input = substr($input, 3);
2371 } elseif(substr($input, 0, 2) == './') {
2372 $input = substr($input, 2);
2373 }
2374 }
2375
2376 # 5.2.4.B Replace leading /. with /
2377 function removeLeadingSlashDot(&$input) {
2378 if(substr($input, 0, 3) == '/./') {
2379 $input = '/' . substr($input, 3);
2380 } else {
2381 $input = '/' . substr($input, 2);
2382 }
2383 }
2384
2385 # 5.2.4.C Given leading /../ remove component from output buffer
2386 function removeOneDirLevel(&$input, &$output) {
2387 if(substr($input, 0, 4) == '/../') {
2388 $input = '/' . substr($input, 4);
2389 } else {
2390 $input = '/' . substr($input, 3);
2391 }
2392 $output = substr($output, 0, strrpos($output, '/'));
2393 }
2394
2395 # 5.2.4.D Remove . and .. if it's the only thing in the input
2396 function removeLoneDotDot(&$input) {
2397 if($input == '.') {
2398 $input = substr($input, 1);
2399 } else {
2400 $input = substr($input, 2);
2401 }
2402 }
2403
2404 # 5.2.4.E Move one segment from input to output
2405 function moveOneSegmentFromInput(&$input, &$output) {
2406 if(substr($input, 0, 1) != '/') {
2407 $pos = strpos($input, '/');
2408 } else {
2409 $pos = strpos($input, '/', 1);
2410 }
2411
2412 if($pos === false) {
2413 $output .= $input;
2414 $input = '';
2415 } else {
2416 $output .= substr($input, 0, $pos);
2417 $input = substr($input, $pos);
2418 }
2419 }
2420
2421 # 5.2.4 Remove Dot Segments
2422 function removeDotSegments($path) {
2423 # 1. The input buffer is initialized with the now-appended path
2424 # components and the output buffer is initialized to the empty
2425 # string.
2426 $input = $path;
2427 $output = '';
2428
2429 $step = 0;
2430
2431 # 2. While the input buffer is not empty, loop as follows:
2432 while($input) {
2433 $step++;
2434
2435 if(substr($input, 0, 3) == '../' || substr($input, 0, 2) == './') {
2436 # A. If the input buffer begins with a prefix of "../" or "./",
2437 # then remove that prefix from the input buffer; otherwise,
2438 removeLeadingDotSlash($input);
2439 } elseif(substr($input, 0, 3) == '/./' || $input == '/.') {
2440 # B. if the input buffer begins with a prefix of "/./" or "/.",
2441 # where "." is a complete path segment, then replace that
2442 # prefix with "/" in the input buffer; otherwise,
2443 removeLeadingSlashDot($input);
2444 } elseif(substr($input, 0, 4) == '/../' || $input == '/..') {
2445 # C. if the input buffer begins with a prefix of "/../" or "/..",
2446 # where ".." is a complete path segment, then replace that
2447 # prefix with "/" in the input buffer and remove the last
2448 # segment and its preceding "/" (if any) from the output
2449 # buffer; otherwise,
2450 removeOneDirLevel($input, $output);
2451 } elseif($input == '.' || $input == '..') {
2452 # D. if the input buffer consists only of "." or "..", then remove
2453 # that from the input buffer; otherwise,
2454 removeLoneDotDot($input);
2455 } else {
2456 # E. move the first path segment in the input buffer to the end of
2457 # the output buffer and any subsequent characters up to, but not including,
2458 # the next "/" character or the end of the input buffer
2459 moveOneSegmentFromInput($input, $output);
2460 }
2461 }
2462
2463 return $output;
2464 }
2465