PluginProbe
Friends / 2.9.1
Friends v2.9.1
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 2.9.1, at libs/Mf2/Parser.php

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