Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   1  <?php
   2  
   3  /**
   4   * Takes tokens makes them well-formed (balance end tags, etc.)
   5   *
   6   * Specification of the armor attributes this strategy uses:
   7   *
   8   *      - MakeWellFormed_TagClosedError: This armor field is used to
   9   *        suppress tag closed errors for certain tokens [TagClosedSuppress],
  10   *        in particular, if a tag was generated automatically by HTML
  11   *        Purifier, we may rely on our infrastructure to close it for us
  12   *        and shouldn't report an error to the user [TagClosedAuto].
  13   */
  14  class HTMLPurifier_Strategy_MakeWellFormed extends HTMLPurifier_Strategy
  15  {
  16  
  17      /**
  18       * Array stream of tokens being processed.
  19       * @type HTMLPurifier_Token[]
  20       */
  21      protected $tokens;
  22  
  23      /**
  24       * Current token.
  25       * @type HTMLPurifier_Token
  26       */
  27      protected $token;
  28  
  29      /**
  30       * Zipper managing the true state.
  31       * @type HTMLPurifier_Zipper
  32       */
  33      protected $zipper;
  34  
  35      /**
  36       * Current nesting of elements.
  37       * @type array
  38       */
  39      protected $stack;
  40  
  41      /**
  42       * Injectors active in this stream processing.
  43       * @type HTMLPurifier_Injector[]
  44       */
  45      protected $injectors;
  46  
  47      /**
  48       * Current instance of HTMLPurifier_Config.
  49       * @type HTMLPurifier_Config
  50       */
  51      protected $config;
  52  
  53      /**
  54       * Current instance of HTMLPurifier_Context.
  55       * @type HTMLPurifier_Context
  56       */
  57      protected $context;
  58  
  59      /**
  60       * @param HTMLPurifier_Token[] $tokens
  61       * @param HTMLPurifier_Config $config
  62       * @param HTMLPurifier_Context $context
  63       * @return HTMLPurifier_Token[]
  64       * @throws HTMLPurifier_Exception
  65       */
  66      public function execute($tokens, $config, $context)
  67      {
  68          $definition = $config->getHTMLDefinition();
  69  
  70          // local variables
  71          $generator = new HTMLPurifier_Generator($config, $context);
  72          $escape_invalid_tags = $config->get('Core.EscapeInvalidTags');
  73          // used for autoclose early abortion
  74          $global_parent_allowed_elements = $definition->info_parent_def->child->getAllowedElements($config);
  75          $e = $context->get('ErrorCollector', true);
  76          $i = false; // injector index
  77          list($zipper, $token) = HTMLPurifier_Zipper::fromArray($tokens);
  78          if ($token === NULL) {
  79              return array();
  80          }
  81          $reprocess = false; // whether or not to reprocess the same token
  82          $stack = array();
  83  
  84          // member variables
  85          $this->stack =& $stack;
  86          $this->tokens =& $tokens;
  87          $this->token =& $token;
  88          $this->zipper =& $zipper;
  89          $this->config = $config;
  90          $this->context = $context;
  91  
  92          // context variables
  93          $context->register('CurrentNesting', $stack);
  94          $context->register('InputZipper', $zipper);
  95          $context->register('CurrentToken', $token);
  96  
  97          // -- begin INJECTOR --
  98  
  99          $this->injectors = array();
 100  
 101          $injectors = $config->getBatch('AutoFormat');
 102          $def_injectors = $definition->info_injector;
 103          $custom_injectors = $injectors['Custom'];
 104          unset($injectors['Custom']); // special case
 105          foreach ($injectors as $injector => $b) {
 106              // XXX: Fix with a legitimate lookup table of enabled filters
 107              if (strpos($injector, '.') !== false) {
 108                  continue;
 109              }
 110              $injector = "HTMLPurifier_Injector_$injector";
 111              if (!$b) {
 112                  continue;
 113              }
 114              $this->injectors[] = new $injector;
 115          }
 116          foreach ($def_injectors as $injector) {
 117              // assumed to be objects
 118              $this->injectors[] = $injector;
 119          }
 120          foreach ($custom_injectors as $injector) {
 121              if (!$injector) {
 122                  continue;
 123              }
 124              if (is_string($injector)) {
 125                  $injector = "HTMLPurifier_Injector_$injector";
 126                  $injector = new $injector;
 127              }
 128              $this->injectors[] = $injector;
 129          }
 130  
 131          // give the injectors references to the definition and context
 132          // variables for performance reasons
 133          foreach ($this->injectors as $ix => $injector) {
 134              $error = $injector->prepare($config, $context);
 135              if (!$error) {
 136                  continue;
 137              }
 138              array_splice($this->injectors, $ix, 1); // rm the injector
 139              trigger_error("Cannot enable {$injector->name} injector because $error is not allowed", E_USER_WARNING);
 140          }
 141  
 142          // -- end INJECTOR --
 143  
 144          // a note on reprocessing:
 145          //      In order to reduce code duplication, whenever some code needs
 146          //      to make HTML changes in order to make things "correct", the
 147          //      new HTML gets sent through the purifier, regardless of its
 148          //      status. This means that if we add a start token, because it
 149          //      was totally necessary, we don't have to update nesting; we just
 150          //      punt ($reprocess = true; continue;) and it does that for us.
 151  
 152          // isset is in loop because $tokens size changes during loop exec
 153          for (;;
 154               // only increment if we don't need to reprocess
 155               $reprocess ? $reprocess = false : $token = $zipper->next($token)) {
 156  
 157              // check for a rewind
 158              if (is_int($i)) {
 159                  // possibility: disable rewinding if the current token has a
 160                  // rewind set on it already. This would offer protection from
 161                  // infinite loop, but might hinder some advanced rewinding.
 162                  $rewind_offset = $this->injectors[$i]->getRewindOffset();
 163                  if (is_int($rewind_offset)) {
 164                      for ($j = 0; $j < $rewind_offset; $j++) {
 165                          if (empty($zipper->front)) break;
 166                          $token = $zipper->prev($token);
 167                          // indicate that other injectors should not process this token,
 168                          // but we need to reprocess it.  See Note [Injector skips]
 169                          unset($token->skip[$i]);
 170                          $token->rewind = $i;
 171                          if ($token instanceof HTMLPurifier_Token_Start) {
 172                              array_pop($this->stack);
 173                          } elseif ($token instanceof HTMLPurifier_Token_End) {
 174                              $this->stack[] = $token->start;
 175                          }
 176                      }
 177                  }
 178                  $i = false;
 179              }
 180  
 181              // handle case of document end
 182              if ($token === NULL) {
 183                  // kill processing if stack is empty
 184                  if (empty($this->stack)) {
 185                      break;
 186                  }
 187  
 188                  // peek
 189                  $top_nesting = array_pop($this->stack);
 190                  $this->stack[] = $top_nesting;
 191  
 192                  // send error [TagClosedSuppress]
 193                  if ($e && !isset($top_nesting->armor['MakeWellFormed_TagClosedError'])) {
 194                      $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag closed by document end', $top_nesting);
 195                  }
 196  
 197                  // append, don't splice, since this is the end
 198                  $token = new HTMLPurifier_Token_End($top_nesting->name);
 199  
 200                  // punt!
 201                  $reprocess = true;
 202                  continue;
 203              }
 204  
 205              //echo '<br>'; printZipper($zipper, $token);//printTokens($this->stack);
 206              //flush();
 207  
 208              // quick-check: if it's not a tag, no need to process
 209              if (empty($token->is_tag)) {
 210                  if ($token instanceof HTMLPurifier_Token_Text) {
 211                      foreach ($this->injectors as $i => $injector) {
 212                          if (isset($token->skip[$i])) {
 213                              // See Note [Injector skips]
 214                              continue;
 215                          }
 216                          if ($token->rewind !== null && $token->rewind !== $i) {
 217                              continue;
 218                          }
 219                          // XXX fuckup
 220                          $r = $token;
 221                          $injector->handleText($r);
 222                          $token = $this->processToken($r, $i);
 223                          $reprocess = true;
 224                          break;
 225                      }
 226                  }
 227                  // another possibility is a comment
 228                  continue;
 229              }
 230  
 231              if (isset($definition->info[$token->name])) {
 232                  $type = $definition->info[$token->name]->child->type;
 233              } else {
 234                  $type = false; // Type is unknown, treat accordingly
 235              }
 236  
 237              // quick tag checks: anything that's *not* an end tag
 238              $ok = false;
 239              if ($type === 'empty' && $token instanceof HTMLPurifier_Token_Start) {
 240                  // claims to be a start tag but is empty
 241                  $token = new HTMLPurifier_Token_Empty(
 242                      $token->name,
 243                      $token->attr,
 244                      $token->line,
 245                      $token->col,
 246                      $token->armor
 247                  );
 248                  $ok = true;
 249              } elseif ($type && $type !== 'empty' && $token instanceof HTMLPurifier_Token_Empty) {
 250                  // claims to be empty but really is a start tag
 251                  // NB: this assignment is required
 252                  $old_token = $token;
 253                  $token = new HTMLPurifier_Token_End($token->name);
 254                  $token = $this->insertBefore(
 255                      new HTMLPurifier_Token_Start($old_token->name, $old_token->attr, $old_token->line, $old_token->col, $old_token->armor)
 256                  );
 257                  // punt (since we had to modify the input stream in a non-trivial way)
 258                  $reprocess = true;
 259                  continue;
 260              } elseif ($token instanceof HTMLPurifier_Token_Empty) {
 261                  // real empty token
 262                  $ok = true;
 263              } elseif ($token instanceof HTMLPurifier_Token_Start) {
 264                  // start tag
 265  
 266                  // ...unless they also have to close their parent
 267                  if (!empty($this->stack)) {
 268  
 269                      // Performance note: you might think that it's rather
 270                      // inefficient, recalculating the autoclose information
 271                      // for every tag that a token closes (since when we
 272                      // do an autoclose, we push a new token into the
 273                      // stream and then /process/ that, before
 274                      // re-processing this token.)  But this is
 275                      // necessary, because an injector can make an
 276                      // arbitrary transformations to the autoclosing
 277                      // tokens we introduce, so things may have changed
 278                      // in the meantime.  Also, doing the inefficient thing is
 279                      // "easy" to reason about (for certain perverse definitions
 280                      // of "easy")
 281  
 282                      $parent = array_pop($this->stack);
 283                      $this->stack[] = $parent;
 284  
 285                      $parent_def = null;
 286                      $parent_elements = null;
 287                      $autoclose = false;
 288                      if (isset($definition->info[$parent->name])) {
 289                          $parent_def = $definition->info[$parent->name];
 290                          $parent_elements = $parent_def->child->getAllowedElements($config);
 291                          $autoclose = !isset($parent_elements[$token->name]);
 292                      }
 293  
 294                      if ($autoclose && $definition->info[$token->name]->wrap) {
 295                          // Check if an element can be wrapped by another
 296                          // element to make it valid in a context (for
 297                          // example, <ul><ul> needs a <li> in between)
 298                          $wrapname = $definition->info[$token->name]->wrap;
 299                          $wrapdef = $definition->info[$wrapname];
 300                          $elements = $wrapdef->child->getAllowedElements($config);
 301                          if (isset($elements[$token->name]) && isset($parent_elements[$wrapname])) {
 302                              $newtoken = new HTMLPurifier_Token_Start($wrapname);
 303                              $token = $this->insertBefore($newtoken);
 304                              $reprocess = true;
 305                              continue;
 306                          }
 307                      }
 308  
 309                      $carryover = false;
 310                      if ($autoclose && $parent_def->formatting) {
 311                          $carryover = true;
 312                      }
 313  
 314                      if ($autoclose) {
 315                          // check if this autoclose is doomed to fail
 316                          // (this rechecks $parent, which his harmless)
 317                          $autoclose_ok = isset($global_parent_allowed_elements[$token->name]);
 318                          if (!$autoclose_ok) {
 319                              foreach ($this->stack as $ancestor) {
 320                                  $elements = $definition->info[$ancestor->name]->child->getAllowedElements($config);
 321                                  if (isset($elements[$token->name])) {
 322                                      $autoclose_ok = true;
 323                                      break;
 324                                  }
 325                                  if ($definition->info[$token->name]->wrap) {
 326                                      $wrapname = $definition->info[$token->name]->wrap;
 327                                      $wrapdef = $definition->info[$wrapname];
 328                                      $wrap_elements = $wrapdef->child->getAllowedElements($config);
 329                                      if (isset($wrap_elements[$token->name]) && isset($elements[$wrapname])) {
 330                                          $autoclose_ok = true;
 331                                          break;
 332                                      }
 333                                  }
 334                              }
 335                          }
 336                          if ($autoclose_ok) {
 337                              // errors need to be updated
 338                              $new_token = new HTMLPurifier_Token_End($parent->name);
 339                              $new_token->start = $parent;
 340                              // [TagClosedSuppress]
 341                              if ($e && !isset($parent->armor['MakeWellFormed_TagClosedError'])) {
 342                                  if (!$carryover) {
 343                                      $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag auto closed', $parent);
 344                                  } else {
 345                                      $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag carryover', $parent);
 346                                  }
 347                              }
 348                              if ($carryover) {
 349                                  $element = clone $parent;
 350                                  // [TagClosedAuto]
 351                                  $element->armor['MakeWellFormed_TagClosedError'] = true;
 352                                  $element->carryover = true;
 353                                  $token = $this->processToken(array($new_token, $token, $element));
 354                              } else {
 355                                  $token = $this->insertBefore($new_token);
 356                              }
 357                          } else {
 358                              $token = $this->remove();
 359                          }
 360                          $reprocess = true;
 361                          continue;
 362                      }
 363  
 364                  }
 365                  $ok = true;
 366              }
 367  
 368              if ($ok) {
 369                  foreach ($this->injectors as $i => $injector) {
 370                      if (isset($token->skip[$i])) {
 371                          // See Note [Injector skips]
 372                          continue;
 373                      }
 374                      if ($token->rewind !== null && $token->rewind !== $i) {
 375                          continue;
 376                      }
 377                      $r = $token;
 378                      $injector->handleElement($r);
 379                      $token = $this->processToken($r, $i);
 380                      $reprocess = true;
 381                      break;
 382                  }
 383                  if (!$reprocess) {
 384                      // ah, nothing interesting happened; do normal processing
 385                      if ($token instanceof HTMLPurifier_Token_Start) {
 386                          $this->stack[] = $token;
 387                      } elseif ($token instanceof HTMLPurifier_Token_End) {
 388                          throw new HTMLPurifier_Exception(
 389                              'Improper handling of end tag in start code; possible error in MakeWellFormed'
 390                          );
 391                      }
 392                  }
 393                  continue;
 394              }
 395  
 396              // sanity check: we should be dealing with a closing tag
 397              if (!$token instanceof HTMLPurifier_Token_End) {
 398                  throw new HTMLPurifier_Exception('Unaccounted for tag token in input stream, bug in HTML Purifier');
 399              }
 400  
 401              // make sure that we have something open
 402              if (empty($this->stack)) {
 403                  if ($escape_invalid_tags) {
 404                      if ($e) {
 405                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Unnecessary end tag to text');
 406                      }
 407                      $token = new HTMLPurifier_Token_Text($generator->generateFromToken($token));
 408                  } else {
 409                      if ($e) {
 410                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Unnecessary end tag removed');
 411                      }
 412                      $token = $this->remove();
 413                  }
 414                  $reprocess = true;
 415                  continue;
 416              }
 417  
 418              // first, check for the simplest case: everything closes neatly.
 419              // Eventually, everything passes through here; if there are problems
 420              // we modify the input stream accordingly and then punt, so that
 421              // the tokens get processed again.
 422              $current_parent = array_pop($this->stack);
 423              if ($current_parent->name == $token->name) {
 424                  $token->start = $current_parent;
 425                  foreach ($this->injectors as $i => $injector) {
 426                      if (isset($token->skip[$i])) {
 427                          // See Note [Injector skips]
 428                          continue;
 429                      }
 430                      if ($token->rewind !== null && $token->rewind !== $i) {
 431                          continue;
 432                      }
 433                      $r = $token;
 434                      $injector->handleEnd($r);
 435                      $token = $this->processToken($r, $i);
 436                      $this->stack[] = $current_parent;
 437                      $reprocess = true;
 438                      break;
 439                  }
 440                  continue;
 441              }
 442  
 443              // okay, so we're trying to close the wrong tag
 444  
 445              // undo the pop previous pop
 446              $this->stack[] = $current_parent;
 447  
 448              // scroll back the entire nest, trying to find our tag.
 449              // (feature could be to specify how far you'd like to go)
 450              $size = count($this->stack);
 451              // -2 because -1 is the last element, but we already checked that
 452              $skipped_tags = false;
 453              for ($j = $size - 2; $j >= 0; $j--) {
 454                  if ($this->stack[$j]->name == $token->name) {
 455                      $skipped_tags = array_slice($this->stack, $j);
 456                      break;
 457                  }
 458              }
 459  
 460              // we didn't find the tag, so remove
 461              if ($skipped_tags === false) {
 462                  if ($escape_invalid_tags) {
 463                      if ($e) {
 464                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Stray end tag to text');
 465                      }
 466                      $token = new HTMLPurifier_Token_Text($generator->generateFromToken($token));
 467                  } else {
 468                      if ($e) {
 469                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Stray end tag removed');
 470                      }
 471                      $token = $this->remove();
 472                  }
 473                  $reprocess = true;
 474                  continue;
 475              }
 476  
 477              // do errors, in REVERSE $j order: a,b,c with </a></b></c>
 478              $c = count($skipped_tags);
 479              if ($e) {
 480                  for ($j = $c - 1; $j > 0; $j--) {
 481                      // notice we exclude $j == 0, i.e. the current ending tag, from
 482                      // the errors... [TagClosedSuppress]
 483                      if (!isset($skipped_tags[$j]->armor['MakeWellFormed_TagClosedError'])) {
 484                          $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag closed by element end', $skipped_tags[$j]);
 485                      }
 486                  }
 487              }
 488  
 489              // insert tags, in FORWARD $j order: c,b,a with </a></b></c>
 490              $replace = array($token);
 491              for ($j = 1; $j < $c; $j++) {
 492                  // ...as well as from the insertions
 493                  $new_token = new HTMLPurifier_Token_End($skipped_tags[$j]->name);
 494                  $new_token->start = $skipped_tags[$j];
 495                  array_unshift($replace, $new_token);
 496                  if (isset($definition->info[$new_token->name]) && $definition->info[$new_token->name]->formatting) {
 497                      // [TagClosedAuto]
 498                      $element = clone $skipped_tags[$j];
 499                      $element->carryover = true;
 500                      $element->armor['MakeWellFormed_TagClosedError'] = true;
 501                      $replace[] = $element;
 502                  }
 503              }
 504              $token = $this->processToken($replace);
 505              $reprocess = true;
 506              continue;
 507          }
 508  
 509          $context->destroy('CurrentToken');
 510          $context->destroy('CurrentNesting');
 511          $context->destroy('InputZipper');
 512  
 513          unset($this->injectors, $this->stack, $this->tokens);
 514          return $zipper->toArray($token);
 515      }
 516  
 517      /**
 518       * Processes arbitrary token values for complicated substitution patterns.
 519       * In general:
 520       *
 521       * If $token is an array, it is a list of tokens to substitute for the
 522       * current token. These tokens then get individually processed. If there
 523       * is a leading integer in the list, that integer determines how many
 524       * tokens from the stream should be removed.
 525       *
 526       * If $token is a regular token, it is swapped with the current token.
 527       *
 528       * If $token is false, the current token is deleted.
 529       *
 530       * If $token is an integer, that number of tokens (with the first token
 531       * being the current one) will be deleted.
 532       *
 533       * @param HTMLPurifier_Token|array|int|bool $token Token substitution value
 534       * @param HTMLPurifier_Injector|int $injector Injector that performed the substitution; default is if
 535       *        this is not an injector related operation.
 536       * @throws HTMLPurifier_Exception
 537       */
 538      protected function processToken($token, $injector = -1)
 539      {
 540          // Zend OpCache miscompiles $token = array($token), so
 541          // avoid this pattern.  See: https://github.com/ezyang/htmlpurifier/issues/108
 542  
 543          // normalize forms of token
 544          if (is_object($token)) {
 545              $tmp = $token;
 546              $token = array(1, $tmp);
 547          }
 548          if (is_int($token)) {
 549              $tmp = $token;
 550              $token = array($tmp);
 551          }
 552          if ($token === false) {
 553              $token = array(1);
 554          }
 555          if (!is_array($token)) {
 556              throw new HTMLPurifier_Exception('Invalid token type from injector');
 557          }
 558          if (!is_int($token[0])) {
 559              array_unshift($token, 1);
 560          }
 561          if ($token[0] === 0) {
 562              throw new HTMLPurifier_Exception('Deleting zero tokens is not valid');
 563          }
 564  
 565          // $token is now an array with the following form:
 566          // array(number nodes to delete, new node 1, new node 2, ...)
 567  
 568          $delete = array_shift($token);
 569          list($old, $r) = $this->zipper->splice($this->token, $delete, $token);
 570  
 571          if ($injector > -1) {
 572              // See Note [Injector skips]
 573              // Determine appropriate skips.  Here's what the code does:
 574              //  *If* we deleted one or more tokens, copy the skips
 575              //  of those tokens into the skips of the new tokens (in $token).
 576              //  Also, mark the newly inserted tokens as having come from
 577              //  $injector.
 578              $oldskip = isset($old[0]) ? $old[0]->skip : array();
 579              foreach ($token as $object) {
 580                  $object->skip = $oldskip;
 581                  $object->skip[$injector] = true;
 582              }
 583          }
 584  
 585          return $r;
 586  
 587      }
 588  
 589      /**
 590       * Inserts a token before the current token. Cursor now points to
 591       * this token.  You must reprocess after this.
 592       * @param HTMLPurifier_Token $token
 593       */
 594      private function insertBefore($token)
 595      {
 596          // NB not $this->zipper->insertBefore(), due to positioning
 597          // differences
 598          $splice = $this->zipper->splice($this->token, 0, array($token));
 599  
 600          return $splice[1];
 601      }
 602  
 603      /**
 604       * Removes current token. Cursor now points to new token occupying previously
 605       * occupied space.  You must reprocess after this.
 606       */
 607      private function remove()
 608      {
 609          return $this->zipper->delete();
 610      }
 611  }
 612  
 613  // Note [Injector skips]
 614  // ~~~~~~~~~~~~~~~~~~~~~
 615  // When I originally designed this class, the idea behind the 'skip'
 616  // property of HTMLPurifier_Token was to help avoid infinite loops
 617  // in injector processing.  For example, suppose you wrote an injector
 618  // that bolded swear words.  Naively, you might write it so that
 619  // whenever you saw ****, you replaced it with <strong>****</strong>.
 620  //
 621  // When this happens, we will reprocess all of the tokens with the
 622  // other injectors.  Now there is an opportunity for infinite loop:
 623  // if we rerun the swear-word injector on these tokens, we might
 624  // see **** and then reprocess again to get
 625  // <strong><strong>****</strong></strong> ad infinitum.
 626  //
 627  // Thus, the idea of a skip is that once we process a token with
 628  // an injector, we mark all of those tokens as having "come from"
 629  // the injector, and we never run the injector again on these
 630  // tokens.
 631  //
 632  // There were two more complications, however:
 633  //
 634  //  - With HTMLPurifier_Injector_RemoveEmpty, we noticed that if
 635  //    you had <b><i></i></b>, after you removed the <i></i>, you
 636  //    really would like this injector to go back and reprocess
 637  //    the <b> tag, discovering that it is now empty and can be
 638  //    removed.  So we reintroduced the possibility of infinite looping
 639  //    by adding a "rewind" function, which let you go back to an
 640  //    earlier point in the token stream and reprocess it with injectors.
 641  //    Needless to say, we need to UN-skip the token so it gets
 642  //    reprocessed.
 643  //
 644  //  - Suppose that you successfuly process a token, replace it with
 645  //    one with your skip mark, but now another injector wants to
 646  //    process the skipped token with another token.  Should you continue
 647  //    to skip that new token, or reprocess it?  If you reprocess,
 648  //    you can end up with an infinite loop where one injector converts
 649  //    <a> to <b>, and then another injector converts it back.  So
 650  //    we inherit the skips, but for some reason, I thought that we
 651  //    should inherit the skip from the first token of the token
 652  //    that we deleted.  Why?  Well, it seems to work OK.
 653  //
 654  // If I were to redesign this functionality, I would absolutely not
 655  // go about doing it this way: the semantics are just not very well
 656  // defined, and in any case you probably wanted to operate on trees,
 657  // not token streams.
 658  
 659  // vim: et sw=4 sts=4