]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - extlib/Mf2/Parser.php
Merge branch 'nightly' of git.gnu.io:gnu/gnu-social into nightly
[quix0rs-gnu-social.git] / extlib / Mf2 / Parser.php
1 <?php
2
3 namespace Mf2;
4
5 use DOMDocument;
6 use DOMElement;
7 use DOMXPath;
8 use DOMNode;
9 use DOMNodeList;
10 use Exception;
11 use SplObjectStorage;
12 use stdClass;
13
14 /**
15  * Parse Microformats2
16  * 
17  * Functional shortcut for the commonest cases of parsing microformats2 from HTML.
18  * 
19  * Example usage:
20  * 
21  *     use Mf2;
22  *     $output = Mf2\parse('<span class="h-card">Barnaby Walters</span>');
23  *     echo json_encode($output, JSON_PRETTY_PRINT);
24  * 
25  * Produces:
26  * 
27  *     {
28  *      "items": [
29  *       {
30  *        "type": ["h-card"],
31  *        "properties": {
32  *         "name": ["Barnaby Walters"]
33  *        }
34  *       }
35  *      ],
36  *      "rels": {}
37  *     }
38  * 
39  * @param string|DOMDocument $input The HTML string or DOMDocument object to parse
40  * @param string $url The URL the input document was found at, for relative URL resolution
41  * @param bool $convertClassic whether or not to convert classic microformats
42  * @return array Canonical MF2 array structure
43  */
44 function parse($input, $url = null, $convertClassic = true) {
45         $parser = new Parser($input, $url);
46         return $parser->parse($convertClassic);
47 }
48
49 /**
50  * Fetch microformats2
51  *
52  * Given a URL, fetches it (following up to 5 redirects) and, if the content-type appears to be HTML, returns the parsed
53  * microformats2 array structure.
54  *
55  * Not that even if the response code was a 4XX or 5XX error, if the content-type is HTML-like then it will be parsed
56  * all the same, as there are legitimate cases where error pages might contain useful microformats (for example a deleted
57  * h-entry resulting in a 410 Gone page with a stub h-entry explaining the reason for deletion). Look in $curlInfo['http_code']
58  * for the actual value.
59  *
60  * @param string $url The URL to fetch
61  * @param bool $convertClassic (optional, default true) whether or not to convert classic microformats
62  * @param &array $curlInfo (optional) the results of curl_getinfo will be placed in this variable for debugging
63  * @return array|null canonical microformats2 array structure on success, null on failure
64  */
65 function fetch($url, $convertClassic = true, &$curlInfo=null) {
66         $ch = curl_init();
67         curl_setopt($ch, CURLOPT_URL, $url);
68         curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
69         curl_setopt($ch, CURLOPT_HEADER, 0);
70         curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
71         curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
72         $html = curl_exec($ch);
73         $info = $curlInfo = curl_getinfo($ch);
74         curl_close($ch);
75
76         if (strpos(strtolower($info['content_type']), 'html') === false) {
77                 // The content was not delivered as HTML, do not attempt to parse it.
78                 return null;
79         }
80
81         return parse($html, $url, $convertClassic);
82 }
83
84 /**
85  * Unicode to HTML Entities
86  * @param string $input String containing characters to convert into HTML entities
87  * @return string 
88  */
89 function unicodeToHtmlEntities($input) {
90         return mb_convert_encoding($input, 'HTML-ENTITIES', mb_detect_encoding($input));
91 }
92
93 /**
94  * Collapse Whitespace
95  * 
96  * Collapses any sequences of whitespace within a string into a single space
97  * character.
98  * 
99  * @deprecated since v0.2.3
100  * @param string $str
101  * @return string
102  */
103 function collapseWhitespace($str) {
104         return preg_replace('/[\s|\n]+/', ' ', $str);
105 }
106
107 function unicodeTrim($str) {
108         // this is cheating. TODO: find a better way if this causes any problems
109         $str = str_replace(mb_convert_encoding('&nbsp;', 'UTF-8', 'HTML-ENTITIES'), ' ', $str);
110         $str = preg_replace('/^\s+/', '', $str);
111         return preg_replace('/\s+$/', '', $str);
112 }
113
114 /**
115  * Microformat Name From Class string
116  * 
117  * Given the value of @class, get the relevant mf classnames (e.g. h-card, 
118  * p-name).
119  * 
120  * @param string $class A space delimited list of classnames
121  * @param string $prefix The prefix to look for
122  * @return string|array The prefixed name of the first microfomats class found or false
123  */
124 function mfNamesFromClass($class, $prefix='h-') {
125         $class = str_replace(array(' ', '       ', "\n"), ' ', $class);
126         $classes = explode(' ', $class);
127         $matches = array();
128
129         foreach ($classes as $classname) {
130                 $compare_classname = strtolower(' ' . $classname);
131                 $compare_prefix = strtolower(' ' . $prefix);
132                 if (stristr($compare_classname, $compare_prefix) !== false && ($compare_classname != $compare_prefix)) {
133                         $matches[] = ($prefix === 'h-') ? $classname : substr($classname, strlen($prefix));
134                 }
135         }
136
137         return $matches;
138 }
139
140 /**
141  * Get Nested µf Property Name From Class
142  * 
143  * Returns all the p-, u-, dt- or e- prefixed classnames it finds in a 
144  * space-separated string.
145  * 
146  * @param string $class
147  * @return array
148  */
149 function nestedMfPropertyNamesFromClass($class) {
150         $prefixes = array('p-', 'u-', 'dt-', 'e-');
151         $propertyNames = array();
152
153         $class = str_replace(array(' ', '       ', "\n"), ' ', $class);
154         foreach (explode(' ', $class) as $classname) {
155                 foreach ($prefixes as $prefix) {
156                         $compare_classname = strtolower(' ' . $classname);
157                         if (stristr($compare_classname, $prefix) && ($compare_classname != $prefix)) {
158                                 $propertyNames = array_merge($propertyNames, mfNamesFromClass($classname, ltrim($prefix)));
159                         }
160                 }
161         }
162
163         return $propertyNames;
164 }
165
166 /**
167  * Wraps mfNamesFromClass to handle an element as input (common)
168  * 
169  * @param DOMElement $e The element to get the classname for
170  * @param string $prefix The prefix to look for
171  * @return mixed See return value of mf2\Parser::mfNameFromClass()
172  */
173 function mfNamesFromElement(\DOMElement $e, $prefix = 'h-') {
174         $class = $e->getAttribute('class');
175         return mfNamesFromClass($class, $prefix);
176 }
177
178 /**
179  * Wraps nestedMfPropertyNamesFromClass to handle an element as input
180  */
181 function nestedMfPropertyNamesFromElement(\DOMElement $e) {
182         $class = $e->getAttribute('class');
183         return nestedMfPropertyNamesFromClass($class);
184 }
185
186 /**
187  * Converts various time formats to HH:MM
188  * @param string $time The time to convert
189  * @return string
190  */
191 function convertTimeFormat($time) {
192         $hh = $mm = $ss = '';
193         preg_match('/(\d{1,2}):?(\d{2})?:?(\d{2})?(a\.?m\.?|p\.?m\.?)?/i', $time, $matches);
194
195         // if no am/pm specified
196         if (empty($matches[4])) {
197                 return $time;
198         }
199         // else am/pm specified
200         else {
201                 $meridiem = strtolower(str_replace('.', '', $matches[4]));
202
203                 // hours
204                 $hh = $matches[1];
205
206                 // add 12 to the pm hours
207                 if ($meridiem == 'pm' && ($hh < 12)) {
208                         $hh += 12;
209                 }
210
211                 $hh = str_pad($hh, 2, '0', STR_PAD_LEFT);
212
213                 // minutes
214                 $mm = (empty($matches[2]) ) ? '00' : $matches[2];
215
216                 // seconds, only if supplied
217                 if (!empty($matches[3])) {
218                         $ss = $matches[3];
219                 }
220
221                 if (empty($ss)) {
222                         return sprintf('%s:%s', $hh, $mm);
223                 }
224                 else {
225                         return sprintf('%s:%s:%s', $hh, $mm, $ss);
226                 }
227         }
228 }
229
230 /**
231  * Microformats2 Parser
232  * 
233  * A class which holds state for parsing microformats2 from HTML.
234  * 
235  * Example usage:
236  * 
237  *     use Mf2;
238  *     $parser = new Mf2\Parser('<p class="h-card">Barnaby Walters</p>');
239  *     $output = $parser->parse();
240  */
241 class Parser {
242         /** @var string The baseurl (if any) to use for this parse */
243         public $baseurl;
244
245         /** @var DOMXPath object which can be used to query over any fragment*/
246         public $xpath;
247         
248         /** @var DOMDocument */
249         public $doc;
250         
251         /** @var SplObjectStorage */
252         protected $parsed;
253         
254         public $jsonMode;
255
256         /**
257          * Constructor
258          * 
259          * @param DOMDocument|string $input The data to parse. A string of HTML or a DOMDocument
260          * @param string $url The URL of the parsed document, for relative URL resolution
261          * @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.
262          */
263         public function __construct($input, $url = null, $jsonMode = false) {
264                 libxml_use_internal_errors(true);
265                 if (is_string($input)) {
266                         $doc = new DOMDocument();
267                         @$doc->loadHTML(unicodeToHtmlEntities($input));
268                 } elseif (is_a($input, 'DOMDocument')) {
269                         $doc = $input;
270                 } else {
271                         $doc = new DOMDocument();
272                         @$doc->loadHTML('');
273                 }
274                 
275                 $this->xpath = new DOMXPath($doc);
276                 
277                 $baseurl = $url;
278                 foreach ($this->xpath->query('//base[@href]') as $base) {
279                         $baseElementUrl = $base->getAttribute('href');
280                         
281                         if (parse_url($baseElementUrl, PHP_URL_SCHEME) === null) {
282                                 /* The base element URL is relative to the document URL.
283                                  *
284                                  * :/
285                                  *
286                                  * Perhaps the author was high? */
287                                 
288                                 $baseurl = resolveUrl($url, $baseElementUrl);
289                         } else {
290                                 $baseurl = $baseElementUrl;
291                         }
292                         break;
293                 }
294
295                 // Ignore <template> elements as per the HTML5 spec
296                 foreach ($this->xpath->query('//template') as $templateEl) {
297                         $templateEl->parentNode->removeChild($templateEl);
298                 }
299                 
300                 $this->baseurl = $baseurl;
301                 $this->doc = $doc;
302                 $this->parsed = new SplObjectStorage();
303                 $this->jsonMode = $jsonMode;
304         }
305         
306         private function elementPrefixParsed(\DOMElement $e, $prefix) {
307                 if (!$this->parsed->contains($e))
308                         $this->parsed->attach($e, array());
309                 
310                 $prefixes = $this->parsed[$e];
311                 $prefixes[] = $prefix;
312                 $this->parsed[$e] = $prefixes;
313         }
314         
315         private function isElementParsed(\DOMElement $e, $prefix) {
316                 if (!$this->parsed->contains($e))
317                         return false;
318                 
319                 $prefixes = $this->parsed[$e];
320                 
321                 if (!in_array($prefix, $prefixes))
322                         return false;
323                 
324                 return true;
325         }
326
327         private function resolveChildUrls(DOMElement $el) {
328                 $hyperlinkChildren = $this->xpath->query('.//*[@src or @href or @data]', $el);
329
330                 foreach ($hyperlinkChildren as $child) {
331                         if ($child->hasAttribute('href'))
332                                 $child->setAttribute('href', $this->resolveUrl($child->getAttribute('href')));
333                         if ($child->hasAttribute('src'))
334                                 $child->setAttribute('src', $this->resolveUrl($child->getAttribute('src')));
335                         if ($child->hasAttribute('data'))
336                                 $child->setAttribute('data', $this->resolveUrl($child->getAttribute('data')));
337                 }
338         }
339
340         public function textContent(DOMElement $el) {
341                 $this->resolveChildUrls($el);
342
343                 $clonedEl = $el->cloneNode(true);
344
345                 foreach ($this->xpath->query('.//img', $clonedEl) as $imgEl) {
346                         $newNode = $this->doc->createTextNode($imgEl->getAttribute($imgEl->hasAttribute('alt') ? 'alt' : 'src'));
347                         $imgEl->parentNode->replaceChild($newNode, $imgEl);
348                 }
349
350                 return $clonedEl->textContent;
351         }
352
353         // TODO: figure out if this has problems with sms: and geo: URLs
354         public function resolveUrl($url) {
355                 // If the URL is seriously malformed it’s probably beyond the scope of this 
356                 // parser to try to do anything with it.
357                 if (parse_url($url) === false)
358                         return $url;
359                 
360                 $scheme = parse_url($url, PHP_URL_SCHEME);
361                 
362                 if (empty($scheme) and !empty($this->baseurl)) {
363                         return resolveUrl($this->baseurl, $url);
364                 } else {
365                         return $url;
366                 }
367         }
368         
369         // Parsing Functions
370         
371         /**
372          * Parse value-class/value-title on an element, joining with $separator if 
373          * there are multiple.
374          * 
375          * @param \DOMElement $e
376          * @param string $separator = '' if multiple value-title elements, join with this string
377          * @return string|null the parsed value or null if value-class or -title aren’t in use
378          */
379         public function parseValueClassTitle(\DOMElement $e, $separator = '') {
380                 $valueClassElements = $this->xpath->query('./*[contains(concat(" ", @class, " "), " value ")]', $e);
381                 
382                 if ($valueClassElements->length !== 0) {
383                         // Process value-class stuff
384                         $val = '';
385                         foreach ($valueClassElements as $el) {
386                                 $val .= $this->textContent($el);
387                         }
388                         
389                         return unicodeTrim($val);
390                 }
391                 
392                 $valueTitleElements = $this->xpath->query('./*[contains(concat(" ", @class, " "), " value-title ")]', $e);
393                 
394                 if ($valueTitleElements->length !== 0) {
395                         // Process value-title stuff
396                         $val = '';
397                         foreach ($valueTitleElements as $el) {
398                                 $val .= $el->getAttribute('title');
399                         }
400                         
401                         return unicodeTrim($val);
402                 }
403                 
404                 // No value-title or -class in this element
405                 return null;
406         }
407         
408         /**
409          * Given an element with class="p-*", get it’s value
410          * 
411          * @param DOMElement $p The element to parse
412          * @return string The plaintext value of $p, dependant on type
413          * @todo Make this adhere to value-class
414          */
415         public function parseP(\DOMElement $p) {
416                 $classTitle = $this->parseValueClassTitle($p, ' ');
417                 
418                 if ($classTitle !== null)
419                         return $classTitle;
420                 
421                 if ($p->tagName == 'img' and $p->getAttribute('alt') !== '') {
422                         $pValue = $p->getAttribute('alt');
423                 } elseif ($p->tagName == 'area' and $p->getAttribute('alt') !== '') {
424                         $pValue = $p->getAttribute('alt');
425                 } elseif ($p->tagName == 'abbr' and $p->getAttribute('title') !== '') {
426                         $pValue = $p->getAttribute('title');
427                 } elseif (in_array($p->tagName, array('data', 'input')) and $p->getAttribute('value') !== '') {
428                         $pValue = $p->getAttribute('value');
429                 } else {
430                         $pValue = unicodeTrim($this->textContent($p));
431                 }
432                 
433                 return $pValue;
434         }
435
436         /**
437          * Given an element with class="u-*", get the value of the URL
438          * 
439          * @param DOMElement $u The element to parse
440          * @return string The plaintext value of $u, dependant on type
441          * @todo make this adhere to value-class
442          */
443         public function parseU(\DOMElement $u) {
444                 if (($u->tagName == 'a' or $u->tagName == 'area') and $u->getAttribute('href') !== null) {
445                         $uValue = $u->getAttribute('href');
446                 } elseif ($u->tagName == 'img' and $u->getAttribute('src') !== null) {
447                         $uValue = $u->getAttribute('src');
448                 } elseif ($u->tagName == 'object' and $u->getAttribute('data') !== null) {
449                         $uValue = $u->getAttribute('data');
450                 }
451                 
452                 if (isset($uValue)) {
453                         return $this->resolveUrl($uValue);
454                 }
455                 
456                 $classTitle = $this->parseValueClassTitle($u);
457                 
458                 if ($classTitle !== null) {
459                         return $classTitle;
460                 } elseif ($u->tagName == 'abbr' and $u->getAttribute('title') !== null) {
461                         return $u->getAttribute('title');
462                 } elseif (in_array($u->tagName, array('data', 'input')) and $u->getAttribute('value') !== null) {
463                         return $u->getAttribute('value');
464                 } else {
465                         return unicodeTrim($this->textContent($u));
466                 }
467         }
468
469         /**
470          * Given an element with class="dt-*", get the value of the datetime as a php date object
471          * 
472          * @param DOMElement $dt The element to parse
473          * @param array $dates Array of dates processed so far
474          * @return string The datetime string found
475          */
476         public function parseDT(\DOMElement $dt, &$dates = array()) {
477                 // Check for value-class pattern
478                 $valueClassChildren = $this->xpath->query('./*[contains(concat(" ", @class, " "), " value ") or contains(concat(" ", @class, " "), " value-title ")]', $dt);
479                 $dtValue = false;
480                 
481                 if ($valueClassChildren->length > 0) {
482                         // They’re using value-class
483                         $dateParts = array();
484                         
485                         foreach ($valueClassChildren as $e) {
486                                 if (strstr(' ' . $e->getAttribute('class') . ' ', ' value-title ')) {
487                                         $title = $e->getAttribute('title');
488                                         if (!empty($title))
489                                                 $dateParts[] = $title;
490                                 }
491                                 elseif ($e->tagName == 'img' or $e->tagName == 'area') {
492                                         // Use @alt
493                                         $alt = $e->getAttribute('alt');
494                                         if (!empty($alt))
495                                                 $dateParts[] = $alt;
496                                 }
497                                 elseif ($e->tagName == 'data') {
498                                         // Use @value, otherwise innertext
499                                         $value = $e->hasAttribute('value') ? $e->getAttribute('value') : unicodeTrim($e->nodeValue);
500                                         if (!empty($value))
501                                                 $dateParts[] = $value;
502                                 }
503                                 elseif ($e->tagName == 'abbr') {
504                                         // Use @title, otherwise innertext
505                                         $title = $e->hasAttribute('title') ? $e->getAttribute('title') : unicodeTrim($e->nodeValue);
506                                         if (!empty($title))
507                                                 $dateParts[] = $title;
508                                 }
509                                 elseif ($e->tagName == 'del' or $e->tagName == 'ins' or $e->tagName == 'time') {
510                                         // Use @datetime if available, otherwise innertext
511                                         $dtAttr = ($e->hasAttribute('datetime')) ? $e->getAttribute('datetime') : unicodeTrim($e->nodeValue);
512                                         if (!empty($dtAttr))
513                                                 $dateParts[] = $dtAttr;
514                                 }
515                                 else {
516                                         if (!empty($e->nodeValue))
517                                                 $dateParts[] = unicodeTrim($e->nodeValue);
518                                 }
519                         }
520
521                         // Look through dateParts
522                         $datePart = '';
523                         $timePart = '';
524                         foreach ($dateParts as $part) {
525                                 // Is this part a full ISO8601 datetime?
526                                 if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z?[+|-]\d{2}:?\d{2})?$/', $part)) {
527                                         // Break completely, we’ve got our value.
528                                         $dtValue = $part;
529                                         break;
530                                 } else {
531                                         // Is the current part a valid time(+TZ?) AND no other time representation has been found?
532                                         if ((preg_match('/\d{1,2}:\d{1,2}(Z?[+|-]\d{2}:?\d{2})?/', $part) or preg_match('/\d{1,2}[a|p]m/', $part)) and empty($timePart)) {
533                                                 $timePart = $part;
534                                         } elseif (preg_match('/\d{4}-\d{2}-\d{2}/', $part) and empty($datePart)) {
535                                                 // Is the current part a valid date AND no other date representation has been found?
536                                                 $datePart = $part;
537                                         }
538
539                                         if ( !empty($datePart) && !in_array($datePart, $dates) ) {
540                                                 $dates[] = $datePart;
541                                         }
542
543                                         $dtValue = '';
544
545                                         if ( empty($datePart) && !empty($timePart) ) {
546                                                 $timePart = convertTimeFormat($timePart);
547                                                 $dtValue = unicodeTrim($timePart, 'T');
548                                         }
549                                         else if ( !empty($datePart) && empty($timePart) ) {
550                                                 $dtValue = rtrim($datePart, 'T');
551                                         }
552                                         else {
553                                                 $timePart = convertTimeFormat($timePart);
554                                                 $dtValue = rtrim($datePart, 'T') . 'T' . unicodeTrim($timePart, 'T');
555                                         }
556                                 }
557                         }
558                 } else {
559                         // Not using value-class (phew).
560                         if ($dt->tagName == 'img' or $dt->tagName == 'area') {
561                                 // Use @alt
562                                 // Is it an entire dt?
563                                 $alt = $dt->getAttribute('alt');
564                                 if (!empty($alt))
565                                         $dtValue = $alt;
566                         } elseif (in_array($dt->tagName, array('data'))) {
567                                 // Use @value, otherwise innertext
568                                 // Is it an entire dt?
569                                 $value = $dt->getAttribute('value');
570                                 if (!empty($value))
571                                         $dtValue = $value;
572                                 else
573                                         $dtValue = $dt->nodeValue;
574                         } elseif ($dt->tagName == 'abbr') {
575                                 // Use @title, otherwise innertext
576                                 // Is it an entire dt?
577                                 $title = $dt->getAttribute('title');
578                                 if (!empty($title))
579                                         $dtValue = $title;
580                                 else
581                                         $dtValue = $dt->nodeValue;
582                         } elseif ($dt->tagName == 'del' or $dt->tagName == 'ins' or $dt->tagName == 'time') {
583                                 // Use @datetime if available, otherwise innertext
584                                 // Is it an entire dt?
585                                 $dtAttr = $dt->getAttribute('datetime');
586                                 if (!empty($dtAttr))
587                                         $dtValue = $dtAttr;
588                                 else
589                                         $dtValue = $dt->nodeValue;
590                         } else {
591                                 $dtValue = $dt->nodeValue;
592                         }
593
594                         if ( preg_match('/(\d{4}-\d{2}-\d{2})/', $dtValue, $matches) ) {
595                                 $dates[] = $matches[0];
596                         }
597                 }
598
599                 /**
600                  * if $dtValue is only a time and there are recently parsed dates, 
601                  * form the full date-time using the most recnetly parsed dt- value
602                  */
603                 if ( (preg_match('/^\d{1,2}:\d{1,2}(Z?[+|-]\d{2}:?\d{2})?/', $dtValue) or preg_match('/^\d{1,2}[a|p]m/', $dtValue)) && !empty($dates) ) {
604                         $dtValue = convertTimeFormat($dtValue);
605                         $dtValue = end($dates) . 'T' . unicodeTrim($dtValue, 'T');
606                 }
607
608                 return $dtValue;
609         }
610
611         /**
612          *      Given the root element of some embedded markup, return a string representing that markup
613          *
614          *      @param DOMElement $e The element to parse
615          *      @return string $e’s innerHTML
616          * 
617          * @todo need to mark this element as e- parsed so it doesn’t get parsed as it’s parent’s e-* too
618          */
619         public function parseE(\DOMElement $e) {
620                 $classTitle = $this->parseValueClassTitle($e);
621                 
622                 if ($classTitle !== null)
623                         return $classTitle;
624                 
625                 // Expand relative URLs within children of this element
626                 // TODO: as it is this is not relative to only children, make this .// and rerun tests
627                 $this->resolveChildUrls($e);
628
629                 $html = '';
630                 foreach ($e->childNodes as $node) {
631                         $html .= $node->C14N();
632                 }
633                 
634                 return array(
635                         'html' => $html,
636                         'value' => unicodeTrim($this->textContent($e))
637                 );
638         }
639
640         /**
641          * Recursively parse microformats
642          * 
643          * @param DOMElement $e The element to parse
644          * @return array A representation of the values contained within microformat $e
645          */
646         public function parseH(\DOMElement $e) {
647                 // If it’s already been parsed (e.g. is a child mf), skip
648                 if ($this->parsed->contains($e))
649                         return null;
650
651                 // Get current µf name
652                 $mfTypes = mfNamesFromElement($e, 'h-');
653
654                 // Initalise var to store the representation in
655                 $return = array();
656                 $children = array();
657                 $dates = array();
658
659                 // Handle nested microformats (h-*)
660                 foreach ($this->xpath->query('.//*[contains(concat(" ", @class)," h-")]', $e) as $subMF) {
661                         // Parse
662                         $result = $this->parseH($subMF);
663                         
664                         // If result was already parsed, skip it
665                         if (null === $result)
666                                 continue;
667                         
668                         $result['value'] = $this->parseP($subMF);
669
670                         // Does this µf have any property names other than h-*?
671                         $properties = nestedMfPropertyNamesFromElement($subMF);
672                         
673                         if (!empty($properties)) {
674                                 // Yes! It’s a nested property µf
675                                 foreach ($properties as $property) {
676                                         $return[$property][] = $result;
677                                 }
678                         } else {
679                                 // No, it’s a child µf
680                                 $children[] = $result;
681                         }
682                         
683                         // Make sure this sub-mf won’t get parsed as a µf or property
684                         // TODO: Determine if clearing this is required?
685                         $this->elementPrefixParsed($subMF, 'h');
686                         $this->elementPrefixParsed($subMF, 'p');
687                         $this->elementPrefixParsed($subMF, 'u');
688                         $this->elementPrefixParsed($subMF, 'dt');
689                         $this->elementPrefixParsed($subMF, 'e');
690                 }
691
692                 // Handle p-*
693                 foreach ($this->xpath->query('.//*[contains(concat(" ", @class) ," p-")]', $e) as $p) {
694                         if ($this->isElementParsed($p, 'p'))
695                                 continue;
696
697                         $pValue = $this->parseP($p);
698                         
699                         // Add the value to the array for it’s p- properties
700                         foreach (mfNamesFromElement($p, 'p-') as $propName) {
701                                 if (!empty($propName))
702                                         $return[$propName][] = $pValue;
703                         }
704                         
705                         // Make sure this sub-mf won’t get parsed as a top level mf
706                         $this->elementPrefixParsed($p, 'p');
707                 }
708
709                 // Handle u-*
710                 foreach ($this->xpath->query('.//*[contains(concat(" ",  @class)," u-")]', $e) as $u) {
711                         if ($this->isElementParsed($u, 'u'))
712                                 continue;
713                         
714                         $uValue = $this->parseU($u);
715                         
716                         // Add the value to the array for it’s property types
717                         foreach (mfNamesFromElement($u, 'u-') as $propName) {
718                                 $return[$propName][] = $uValue;
719                         }
720                         
721                         // Make sure this sub-mf won’t get parsed as a top level mf
722                         $this->elementPrefixParsed($u, 'u');
723                 }
724                 
725                 // Handle dt-*
726                 foreach ($this->xpath->query('.//*[contains(concat(" ", @class), " dt-")]', $e) as $dt) {
727                         if ($this->isElementParsed($dt, 'dt'))
728                                 continue;
729                         
730                         $dtValue = $this->parseDT($dt, $dates);
731                         
732                         if ($dtValue) {
733                                 // Add the value to the array for dt- properties
734                                 foreach (mfNamesFromElement($dt, 'dt-') as $propName) {
735                                         $return[$propName][] = $dtValue;
736                                 }
737                         }
738                         
739                         // Make sure this sub-mf won’t get parsed as a top level mf
740                         $this->elementPrefixParsed($dt, 'dt');
741                 }
742
743                 // Handle e-*
744                 foreach ($this->xpath->query('.//*[contains(concat(" ", @class)," e-")]', $e) as $em) {
745                         if ($this->isElementParsed($em, 'e'))
746                                 continue;
747
748                         $eValue = $this->parseE($em);
749
750                         if ($eValue) {
751                                 // Add the value to the array for e- properties
752                                 foreach (mfNamesFromElement($em, 'e-') as $propName) {
753                                         $return[$propName][] = $eValue;
754                                 }
755                         }
756                         // Make sure this sub-mf won’t get parsed as a top level mf
757                         $this->elementPrefixParsed($em, 'e');
758                 }
759
760                 // Implied Properties
761                 // Check for p-name
762                 if (!array_key_exists('name', $return)) {
763                         try {
764                                 // Look for img @alt
765                                 if ($e->tagName == 'img' and $e->getAttribute('alt') != '')
766                                         throw new Exception($e->getAttribute('alt'));
767                                 
768                                 if ($e->tagName == 'abbr' and $e->hasAttribute('title'))
769                                         throw new Exception($e->getAttribute('title'));
770                                 
771                                 // Look for nested img @alt
772                                 foreach ($this->xpath->query('./img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
773                                         if ($em->getAttribute('alt') != '')
774                                                 throw new Exception($em->getAttribute('alt'));
775                                 }
776
777                                 // Look for double nested img @alt
778                                 foreach ($this->xpath->query('./*[count(preceding-sibling::*)+count(following-sibling::*)=0]/img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
779                                         if ($em->getAttribute('alt') != '')
780                                                 throw new Exception($em->getAttribute('alt'));
781                                 }
782
783                                 throw new Exception($e->nodeValue);
784                         } catch (Exception $exc) {
785                                 $return['name'][] = unicodeTrim($exc->getMessage());
786                         }
787                 }
788
789                 // Check for u-photo
790                 if (!array_key_exists('photo', $return)) {
791                         // Look for img @src
792                         try {
793                                 if ($e->tagName == 'img')
794                                         throw new Exception($e->getAttribute('src'));
795
796                                 // Look for nested img @src
797                                 foreach ($this->xpath->query('./img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
798                                         if ($em->getAttribute('src') != '')
799                                                 throw new Exception($em->getAttribute('src'));
800                                 }
801
802                                 // Look for double nested img @src
803                                 foreach ($this->xpath->query('./*[count(preceding-sibling::*)+count(following-sibling::*)=0]/img[count(preceding-sibling::*)+count(following-sibling::*)=0]', $e) as $em) {
804                                         if ($em->getAttribute('src') != '')
805                                                 throw new Exception($em->getAttribute('src'));
806                                 }
807                         } catch (Exception $exc) {
808                                 $return['photo'][] = $this->resolveUrl($exc->getMessage());
809                         }
810                 }
811
812                 // Check for u-url
813                 if (!array_key_exists('url', $return)) {
814                         // Look for img @src
815                         if ($e->tagName == 'a')
816                                 $url = $e->getAttribute('href');
817                         
818                         // Look for nested img @src
819                         foreach ($this->xpath->query('./a[count(preceding-sibling::a)+count(following-sibling::a)=0]', $e) as $em) {
820                                 $url = $em->getAttribute('href');
821                                 break;
822                         }
823                         
824                         if (!empty($url))
825                                 $return['url'][] = $this->resolveUrl($url);
826                 }
827
828                 // Make sure things are in alphabetical order
829                 sort($mfTypes);
830                 
831                 // Phew. Return the final result.
832                 $parsed = array(
833                         'type' => $mfTypes,
834                         'properties' => $return
835                 );
836                 if (!empty($children))
837                         $parsed['children'] = array_values(array_filter($children));
838                 return $parsed;
839         }
840         
841         /**
842          * Parse Rels and Alternatives
843          * 
844          * Returns [$rels, $alternatives]. If the $rels value is to be empty, i.e. there are no links on the page 
845          * with a rel value *not* containing `alternate`, then the type of $rels depends on $this->jsonMode. If set
846          * to true, it will be a stdClass instance, optimising for JSON serialisation. Otherwise (the default case),
847          * it will be an empty array.
848          */
849         public function parseRelsAndAlternates() {
850                 $rels = array();
851                 $alternates = array();
852                 
853                 // Iterate through all a, area and link elements with rel attributes
854                 foreach ($this->xpath->query('//*[@rel and @href]') as $hyperlink) {
855                         if ($hyperlink->getAttribute('rel') == '')
856                                 continue;
857                         
858                         // Resolve the href
859                         $href = $this->resolveUrl($hyperlink->getAttribute('href'));
860                         
861                         // Split up the rel into space-separated values
862                         $linkRels = array_filter(explode(' ', $hyperlink->getAttribute('rel')));
863                         
864                         // If alternate in rels, create alternate structure, append
865                         if (in_array('alternate', $linkRels)) {
866                                 $alt = array(
867                                         'url' => $href,
868                                         'rel' => implode(' ', array_diff($linkRels, array('alternate')))
869                                 );
870                                 if ($hyperlink->hasAttribute('media'))
871                                         $alt['media'] = $hyperlink->getAttribute('media');
872                                 
873                                 if ($hyperlink->hasAttribute('hreflang'))
874                                         $alt['hreflang'] = $hyperlink->getAttribute('hreflang');
875                                 
876                                 $alternates[] = $alt;
877                         } else {
878                                 foreach ($linkRels as $rel) {
879                                         $rels[$rel][] = $href;
880                                 }
881                         }
882                 }
883                 
884                 if (empty($rels) and $this->jsonMode) {
885                         $rels = new stdClass();
886                 }
887                 
888                 return array($rels, $alternates);
889         }
890         
891         /**
892          * Kicks off the parsing routine
893          * 
894          * If `$htmlSafe` is set, any angle brackets in the results from non e-* properties
895          * will be HTML-encoded, bringing all output to the same level of encoding.
896          * 
897          * If a DOMElement is set as the $context, only descendants of that element will
898          * be parsed for microformats.
899          * 
900          * @param bool $htmlSafe whether or not to html-encode non e-* properties. Defaults to false
901          * @param DOMElement $context optionally an element from which to parse microformats
902          * @return array An array containing all the µfs found in the current document
903          */
904         public function parse($convertClassic = true, DOMElement $context = null) {
905                 $mfs = array();
906                 
907                 if ($convertClassic) {
908                         $this->convertLegacy();
909                 }
910                 
911                 $mfElements = null === $context
912                         ? $this->xpath->query('//*[contains(concat(" ", @class), " h-")]')
913                         : $this->xpath->query('.//*[contains(concat(" ",        @class), " h-")]', $context);
914                 
915                 // Parser microformats
916                 foreach ($mfElements as $node) {
917                         // For each microformat
918                         $result = $this->parseH($node);
919
920                         // Add the value to the array for this property type
921                         $mfs[] = $result;
922                 }
923                 
924                 // Parse rels
925                 list($rels, $alternates) = $this->parseRelsAndAlternates();
926                 
927                 $top = array(
928                         'items' => array_values(array_filter($mfs)),
929                         'rels' => $rels
930                 );
931                 
932                 if (count($alternates))
933                         $top['alternates'] = $alternates;
934                 
935                 return $top;
936         }
937         
938         /**
939          * Parse From ID
940          * 
941          * Given an ID, parse all microformats which are children of the element with
942          * that ID.
943          * 
944          * Note that rel values are still document-wide.
945          * 
946          * If an element with the ID is not found, an empty skeleton mf2 array structure 
947          * will be returned.
948          * 
949          * @param string $id
950          * @param bool $htmlSafe = false whether or not to HTML-encode angle brackets in non e-* properties
951          * @return array
952          */
953         public function parseFromId($id, $convertClassic=true) {
954                 $matches = $this->xpath->query("//*[@id='{$id}']");
955                 
956                 if (empty($matches))
957                         return array('items' => array(), 'rels' => array(), 'alternates' => array());
958                 
959                 return $this->parse($convertClassic, $matches->item(0));
960         }
961
962         /**
963          * Convert Legacy Classnames
964          * 
965          * Adds microformats2 classnames into a document containing only legacy
966          * semantic classnames.
967          * 
968          * @return Parser $this
969          */
970         public function convertLegacy() {
971                 $doc = $this->doc;
972                 $xp = new DOMXPath($doc);
973                 
974                 // replace all roots
975                 foreach ($this->classicRootMap as $old => $new) {
976                         foreach ($xp->query('//*[contains(concat(" ", @class, " "), " ' . $old . ' ") and not(contains(concat(" ", @class, " "), " ' . $new . ' "))]') as $el) {
977                                 $el->setAttribute('class', $el->getAttribute('class') . ' ' . $new);
978                         }
979                 }
980                 
981                 foreach ($this->classicPropertyMap as $oldRoot => $properties) {
982                         $newRoot = $this->classicRootMap[$oldRoot];
983                         foreach ($properties as $old => $new) {
984                                 foreach ($xp->query('//*[contains(concat(" ", @class, " "), " ' . $oldRoot . ' ")]//*[contains(concat(" ", @class, " "), " ' . $old . ' ") and not(contains(concat(" ", @class, " "), " ' . $new . ' "))]') as $el) {
985                                         $el->setAttribute('class', $el->getAttribute('class') . ' ' . $new);
986                                 }
987                         }
988                 }
989                 
990                 return $this;
991         }
992         
993         /**
994          * XPath Query
995          * 
996          * Runs an XPath query over the current document. Works in exactly the same
997          * way as DOMXPath::query.
998          * 
999          * @param string $expression
1000          * @param DOMNode $context
1001          * @return DOMNodeList
1002          */
1003         public function query($expression, $context = null) {
1004                 return $this->xpath->query($expression, $context);
1005         }
1006         
1007         /**
1008          * Classic Root Classname map
1009          */
1010         public $classicRootMap = array(
1011                 'vcard' => 'h-card',
1012                 'hfeed' => 'h-feed',
1013                 'hentry' => 'h-entry',
1014                 'hrecipe' => 'h-recipe',
1015                 'hresume' => 'h-resume',
1016                 'hevent' => 'h-event',
1017                 'hreview' => 'h-review',
1018                 'hproduct' => 'h-product'
1019         );
1020         
1021         public $classicPropertyMap = array(
1022                 'vcard' => array(
1023                         'fn' => 'p-name',
1024                         'url' => 'u-url',
1025                         'honorific-prefix' => 'p-honorific-prefix',
1026                         'given-name' => 'p-given-name',
1027                         'additional-name' => 'p-additional-name',
1028                         'family-name' => 'p-family-name',
1029                         'honorific-suffix' => 'p-honorific-suffix',
1030                         'nickname' => 'p-nickname',
1031                         'email' => 'u-email',
1032                         'logo' => 'u-logo',
1033                         'photo' => 'u-photo',
1034                         'url' => 'u-url',
1035                         'uid' => 'u-uid',
1036                         'category' => 'p-category',
1037                         'adr' => 'p-adr h-adr',
1038                         'extended-address' => 'p-extended-address',
1039                         'street-address' => 'p-street-address',
1040                         'locality' => 'p-locality',
1041                         'region' => 'p-region',
1042                         'postal-code' => 'p-postal-code',
1043                         'country-name' => 'p-country-name',
1044                         'label' => 'p-label',
1045                         'geo' => 'p-geo h-geo',
1046                         'latitude' => 'p-latitude',
1047                         'longitude' => 'p-longitude',
1048                         'tel' => 'p-tel',
1049                         'note' => 'p-note',
1050                         'bday' => 'dt-bday',
1051                         'key' => 'u-key',
1052                         'org' => 'p-org',
1053                         'organization-name' => 'p-organization-name',
1054                         'organization-unit' => 'p-organization-unit',
1055                 ),
1056                 'hentry' => array(
1057                         'entry-title' => 'p-name',
1058                         'entry-summary' => 'p-summary',
1059                         'entry-content' => 'e-content',
1060                         'published' => 'dt-published',
1061                         'updated' => 'dt-updated',
1062                         'author' => 'p-author h-card',
1063                         'category' => 'p-category',
1064                         'geo' => 'p-geo h-geo',
1065                         'latitude' => 'p-latitude',
1066                         'longitude' => 'p-longitude',
1067                 ),
1068                 'hrecipe' => array(
1069                         'fn' => 'p-name',
1070                         'ingredient' => 'p-ingredient',
1071                         'yield' => 'p-yield',
1072                         'instructions' => 'e-instructions',
1073                         'duration' => 'dt-duration',
1074                         'nutrition' => 'p-nutrition',
1075                         'photo' => 'u-photo',
1076                         'summary' => 'p-summary',
1077                         'author' => 'p-author h-card'
1078                 ),
1079                 'hresume' => array(
1080                         'summary' => 'p-summary',
1081                         'contact' => 'h-card p-contact',
1082                         'education' => 'h-event p-education',
1083                         'experience' => 'h-event p-experience',
1084                         'skill' => 'p-skill',
1085                         'affiliation' => 'p-affiliation h-card',
1086                 ),
1087                 'hevent' => array(
1088                         'dtstart' => 'dt-start',
1089                         'dtend' => 'dt-end',
1090                         'duration' => 'dt-duration',
1091                         'description' => 'p-description',
1092                         'summary' => 'p-summary',
1093                         'description' => 'p-description',
1094                         'url' => 'u-url',
1095                         'category' => 'p-category',
1096                         'location' => 'h-card',
1097                         'geo' => 'p-location h-geo'
1098                 ),
1099                 'hreview' => array(
1100                         'summary' => 'p-name',
1101                         'fn' => 'p-item h-item p-name', // doesn’t work properly, see spec
1102                         'photo' => 'u-photo', // of the item being reviewed (p-item h-item u-photo)
1103                         'url' => 'u-url', // of the item being reviewed (p-item h-item u-url)
1104                         'reviewer' => 'p-reviewer p-author h-card',
1105                         'dtreviewed' => 'dt-reviewed',
1106                         'rating' => 'p-rating',
1107                         'best' => 'p-best',
1108                         'worst' => 'p-worst',
1109                         'description' => 'p-description'
1110                 ),
1111                 'hproduct' => array(
1112                         'fn' => 'p-name',
1113                         'photo' => 'u-photo',
1114                         'brand' => 'p-brand',
1115                         'category' => 'p-category',
1116                         'description' => 'p-description',
1117                         'identifier' => 'u-identifier',
1118                         'url' => 'u-url',
1119                         'review' => 'p-review h-review',
1120                         'price' => 'p-price'
1121                 )
1122         );
1123 }
1124
1125 function parseUriToComponents($uri) {
1126         $result = array(
1127                 'scheme' => null,
1128                 'authority' => null,
1129                 'path' => null,
1130                 'query' => null,
1131                 'fragment' => null
1132         );
1133
1134         $u = @parse_url($uri);
1135
1136         if(array_key_exists('scheme', $u))
1137                 $result['scheme'] = $u['scheme'];
1138
1139         if(array_key_exists('host', $u)) {
1140                 if(array_key_exists('user', $u))
1141                         $result['authority'] = $u['user'];
1142                 if(array_key_exists('pass', $u))
1143                         $result['authority'] .= ':' . $u['pass'];
1144                 if(array_key_exists('user', $u) || array_key_exists('pass', $u))
1145                         $result['authority'] .= '@';
1146                 $result['authority'] .= $u['host'];
1147                 if(array_key_exists('port', $u))
1148                         $result['authority'] .= ':' . $u['port'];
1149         }
1150
1151         if(array_key_exists('path', $u))
1152                 $result['path'] = $u['path'];
1153
1154         if(array_key_exists('query', $u))
1155                 $result['query'] = $u['query'];
1156
1157         if(array_key_exists('fragment', $u))
1158                 $result['fragment'] = $u['fragment'];
1159
1160         return $result;
1161 }
1162
1163 function resolveUrl($baseURI, $referenceURI) {
1164         $target = array(
1165                 'scheme' => null,
1166                 'authority' => null,
1167                 'path' => null,
1168                 'query' => null,
1169                 'fragment' => null
1170         );
1171
1172         # 5.2.1 Pre-parse the Base URI
1173         # The base URI (Base) is established according to the procedure of
1174   # Section 5.1 and parsed into the five main components described in
1175   # Section 3
1176         $base = parseUriToComponents($baseURI);
1177
1178         # If base path is blank (http://example.com) then set it to /
1179         # (I can't tell if this is actually in the RFC or not, but seems like it makes sense)
1180         if($base['path'] == null)
1181                 $base['path'] = '/';
1182
1183         # 5.2.2. Transform References
1184
1185         # The URI reference is parsed into the five URI components
1186         # (R.scheme, R.authority, R.path, R.query, R.fragment) = parse(R);
1187         $reference = parseUriToComponents($referenceURI);
1188
1189         # A non-strict parser may ignore a scheme in the reference
1190         # if it is identical to the base URI's scheme.
1191         # TODO
1192
1193         if($reference['scheme']) {
1194                 $target['scheme'] = $reference['scheme'];
1195                 $target['authority'] = $reference['authority'];
1196                 $target['path'] = removeDotSegments($reference['path']);
1197                 $target['query'] = $reference['query'];
1198         } else {
1199                 if($reference['authority']) {
1200                         $target['authority'] = $reference['authority'];
1201                         $target['path'] = removeDotSegments($reference['path']);
1202                         $target['query'] = $reference['query'];
1203                 } else {
1204                         if($reference['path'] == '') {
1205                                 $target['path'] = $base['path'];
1206                                 if($reference['query']) {
1207                                         $target['query'] = $reference['query'];
1208                                 } else {
1209                                         $target['query'] = $base['query'];
1210                                 }
1211                         } else {
1212                                 if(substr($reference['path'], 0, 1) == '/') {
1213                                         $target['path'] = removeDotSegments($reference['path']);
1214                                 } else {
1215                                         $target['path'] = mergePaths($base, $reference);
1216                                         $target['path'] = removeDotSegments($target['path']);
1217                                 }
1218                                 $target['query'] = $reference['query'];
1219                         }
1220                         $target['authority'] = $base['authority'];
1221                 }
1222                 $target['scheme'] = $base['scheme'];
1223         }
1224         $target['fragment'] = $reference['fragment'];
1225
1226         # 5.3 Component Recomposition
1227         $result = '';
1228         if($target['scheme']) {
1229                 $result .= $target['scheme'] . ':';
1230         }
1231         if($target['authority']) {
1232                 $result .= '//' . $target['authority'];
1233         }
1234         $result .= $target['path'];
1235         if($target['query']) {
1236                 $result .= '?' . $target['query'];
1237         }
1238         if($target['fragment']) {
1239                 $result .= '#' . $target['fragment'];
1240         } elseif($referenceURI == '#') {
1241                 $result .= '#';
1242         }
1243         return $result;
1244 }
1245
1246 # 5.2.3 Merge Paths
1247 function mergePaths($base, $reference) {
1248         # If the base URI has a defined authority component and an empty
1249         #    path, 
1250         if($base['authority'] && $base['path'] == null) {
1251                 # then return a string consisting of "/" concatenated with the
1252                 # reference's path; otherwise,
1253                 $merged = '/' . $reference['path'];
1254         } else {
1255                 if(($pos=strrpos($base['path'], '/')) !== false) {
1256                         # return a string consisting of the reference's path component
1257                         #    appended to all but the last segment of the base URI's path (i.e.,
1258                         #    excluding any characters after the right-most "/" in the base URI
1259                         #    path,
1260                         $merged = substr($base['path'], 0, $pos + 1) . $reference['path'];
1261                 } else {
1262                         #    or excluding the entire base URI path if it does not contain
1263                         #    any "/" characters).
1264                         $merged = $base['path'];
1265                 }
1266         }
1267         return $merged;
1268 }
1269
1270 # 5.2.4.A Remove leading ../ or ./
1271 function removeLeadingDotSlash(&$input) {
1272         if(substr($input, 0, 3) == '../') {
1273                 $input = substr($input, 3);
1274         } elseif(substr($input, 0, 2) == './') {
1275                 $input = substr($input, 2);
1276         }
1277 }
1278
1279 # 5.2.4.B Replace leading /. with /
1280 function removeLeadingSlashDot(&$input) {
1281         if(substr($input, 0, 3) == '/./') {
1282                 $input = '/' . substr($input, 3);
1283         } else {
1284                 $input = '/' . substr($input, 2);
1285         }
1286 }
1287
1288 # 5.2.4.C Given leading /../ remove component from output buffer
1289 function removeOneDirLevel(&$input, &$output) {
1290         if(substr($input, 0, 4) == '/../') {
1291                 $input = '/' . substr($input, 4);
1292         } else {
1293                 $input = '/' . substr($input, 3);
1294         }
1295         $output = substr($output, 0, strrpos($output, '/'));
1296 }
1297
1298 # 5.2.4.D Remove . and .. if it's the only thing in the input
1299 function removeLoneDotDot(&$input) {
1300         if($input == '.') {
1301                 $input = substr($input, 1);
1302         } else {
1303                 $input = substr($input, 2);
1304         }
1305 }
1306
1307 # 5.2.4.E Move one segment from input to output
1308 function moveOneSegmentFromInput(&$input, &$output) {
1309         if(substr($input, 0, 1) != '/') {
1310                 $pos = strpos($input, '/');
1311         } else {
1312                 $pos = strpos($input, '/', 1);
1313         }
1314
1315         if($pos === false) {
1316                 $output .= $input;
1317                 $input = '';
1318         } else {
1319                 $output .= substr($input, 0, $pos);
1320                 $input = substr($input, $pos);
1321         }
1322 }
1323
1324 # 5.2.4 Remove Dot Segments
1325 function removeDotSegments($path) {
1326         # 1.  The input buffer is initialized with the now-appended path
1327         #     components and the output buffer is initialized to the empty
1328         #     string.
1329         $input = $path;
1330         $output = '';
1331
1332         $step = 0;
1333
1334         # 2.  While the input buffer is not empty, loop as follows:
1335         while($input) {
1336                 $step++;
1337
1338                 if(substr($input, 0, 3) == '../' || substr($input, 0, 2) == './') {
1339                         #     A.  If the input buffer begins with a prefix of "../" or "./",
1340                         #         then remove that prefix from the input buffer; otherwise,
1341                         removeLeadingDotSlash($input);
1342                 } elseif(substr($input, 0, 3) == '/./' || $input == '/.') {
1343                         #     B.  if the input buffer begins with a prefix of "/./" or "/.",
1344                         #         where "." is a complete path segment, then replace that
1345                         #         prefix with "/" in the input buffer; otherwise,
1346                         removeLeadingSlashDot($input);
1347                 } elseif(substr($input, 0, 4) == '/../' || $input == '/..') {
1348                         #     C.  if the input buffer begins with a prefix of "/../" or "/..",
1349                         #          where ".." is a complete path segment, then replace that
1350                         #          prefix with "/" in the input buffer and remove the last
1351                         #          segment and its preceding "/" (if any) from the output
1352                         #          buffer; otherwise,
1353                         removeOneDirLevel($input, $output);
1354                 } elseif($input == '.' || $input == '..') {
1355                         #     D.  if the input buffer consists only of "." or "..", then remove
1356                         #         that from the input buffer; otherwise,
1357                         removeLoneDotDot($input);
1358                 } else {
1359                         #     E.  move the first path segment in the input buffer to the end of
1360                         #         the output buffer and any subsequent characters up to, but not including,
1361                         #         the next "/" character or the end of the input buffer
1362                         moveOneSegmentFromInput($input, $output);
1363                 }
1364         }
1365
1366         return $output;
1367 }