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

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