Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401]

   1  <?php
   2  
   3  /**
   4   * SCSSPHP
   5   *
   6   * @copyright 2012-2020 Leaf Corcoran
   7   *
   8   * @license http://opensource.org/licenses/MIT MIT
   9   *
  10   * @link http://scssphp.github.io/scssphp
  11   */
  12  
  13  namespace ScssPhp\ScssPhp\SourceMap;
  14  
  15  use ScssPhp\ScssPhp\Exception\CompilerException;
  16  
  17  /**
  18   * Source Map Generator
  19   *
  20   * {@internal Derivative of oyejorge/less.php's lib/SourceMap/Generator.php, relicensed with permission. }}
  21   *
  22   * @author Josh Schmidt <oyejorge@gmail.com>
  23   * @author Nicolas FRANÇOIS <nicolas.francois@frog-labs.com>
  24   *
  25   * @internal
  26   */
  27  class SourceMapGenerator
  28  {
  29      /**
  30       * What version of source map does the generator generate?
  31       */
  32      const VERSION = 3;
  33  
  34      /**
  35       * Array of default options
  36       *
  37       * @var array
  38       * @phpstan-var array{sourceRoot: string, sourceMapFilename: string|null, sourceMapURL: string|null, sourceMapWriteTo: string|null, outputSourceFiles: bool, sourceMapRootpath: string, sourceMapBasepath: string}
  39       */
  40      protected $defaultOptions = [
  41          // an optional source root, useful for relocating source files
  42          // on a server or removing repeated values in the 'sources' entry.
  43          // This value is prepended to the individual entries in the 'source' field.
  44          'sourceRoot' => '',
  45  
  46          // an optional name of the generated code that this source map is associated with.
  47          'sourceMapFilename' => null,
  48  
  49          // url of the map
  50          'sourceMapURL' => null,
  51  
  52          // absolute path to a file to write the map to
  53          'sourceMapWriteTo' => null,
  54  
  55          // output source contents?
  56          'outputSourceFiles' => false,
  57  
  58          // base path for filename normalization
  59          'sourceMapRootpath' => '',
  60  
  61          // base path for filename normalization
  62          'sourceMapBasepath' => ''
  63      ];
  64  
  65      /**
  66       * The base64 VLQ encoder
  67       *
  68       * @var \ScssPhp\ScssPhp\SourceMap\Base64VLQ
  69       */
  70      protected $encoder;
  71  
  72      /**
  73       * Array of mappings
  74       *
  75       * @var array
  76       * @phpstan-var list<array{generated_line: int, generated_column: int, original_line: int, original_column: int, source_file: string}>
  77       */
  78      protected $mappings = [];
  79  
  80      /**
  81       * Array of contents map
  82       *
  83       * @var array
  84       */
  85      protected $contentsMap = [];
  86  
  87      /**
  88       * File to content map
  89       *
  90       * @var array<string, string>
  91       */
  92      protected $sources = [];
  93  
  94      /**
  95       * @var array<string, int>
  96       */
  97      protected $sourceKeys = [];
  98  
  99      /**
 100       * @var array
 101       * @phpstan-var array{sourceRoot: string, sourceMapFilename: string|null, sourceMapURL: string|null, sourceMapWriteTo: string|null, outputSourceFiles: bool, sourceMapRootpath: string, sourceMapBasepath: string}
 102       */
 103      private $options;
 104  
 105      /**
 106       * @phpstan-param array{sourceRoot?: string, sourceMapFilename?: string|null, sourceMapURL?: string|null, sourceMapWriteTo?: string|null, outputSourceFiles?: bool, sourceMapRootpath?: string, sourceMapBasepath?: string} $options
 107       */
 108      public function __construct(array $options = [])
 109      {
 110          $this->options = array_replace($this->defaultOptions, $options);
 111          $this->encoder = new Base64VLQ();
 112      }
 113  
 114      /**
 115       * Adds a mapping
 116       *
 117       * @param int    $generatedLine   The line number in generated file
 118       * @param int    $generatedColumn The column number in generated file
 119       * @param int    $originalLine    The line number in original file
 120       * @param int    $originalColumn  The column number in original file
 121       * @param string $sourceFile      The original source file
 122       *
 123       * @return void
 124       */
 125      public function addMapping($generatedLine, $generatedColumn, $originalLine, $originalColumn, $sourceFile)
 126      {
 127          $this->mappings[] = [
 128              'generated_line'   => $generatedLine,
 129              'generated_column' => $generatedColumn,
 130              'original_line'    => $originalLine,
 131              'original_column'  => $originalColumn,
 132              'source_file'      => $sourceFile
 133          ];
 134  
 135          $this->sources[$sourceFile] = $sourceFile;
 136      }
 137  
 138      /**
 139       * Saves the source map to a file
 140       *
 141       * @param string $content The content to write
 142       *
 143       * @return string|null
 144       *
 145       * @throws \ScssPhp\ScssPhp\Exception\CompilerException If the file could not be saved
 146       * @deprecated
 147       */
 148      public function saveMap($content)
 149      {
 150          $file = $this->options['sourceMapWriteTo'];
 151          assert($file !== null);
 152          $dir  = \dirname($file);
 153  
 154          // directory does not exist
 155          if (! is_dir($dir)) {
 156              // FIXME: create the dir automatically?
 157              throw new CompilerException(
 158                  sprintf('The directory "%s" does not exist. Cannot save the source map.', $dir)
 159              );
 160          }
 161  
 162          // FIXME: proper saving, with dir write check!
 163          if (file_put_contents($file, $content) === false) {
 164              throw new CompilerException(sprintf('Cannot save the source map to "%s"', $file));
 165          }
 166  
 167          return $this->options['sourceMapURL'];
 168      }
 169  
 170      /**
 171       * Generates the JSON source map
 172       *
 173       * @param string $prefix A prefix added in the output file, which needs to shift mappings
 174       *
 175       * @return string
 176       *
 177       * @see https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit#
 178       */
 179      public function generateJson($prefix = '')
 180      {
 181          $sourceMap = [];
 182          $mappings  = $this->generateMappings($prefix);
 183  
 184          // File version (always the first entry in the object) and must be a positive integer.
 185          $sourceMap['version'] = self::VERSION;
 186  
 187          // An optional name of the generated code that this source map is associated with.
 188          $file = $this->options['sourceMapFilename'];
 189  
 190          if ($file) {
 191              $sourceMap['file'] = $file;
 192          }
 193  
 194          // An optional source root, useful for relocating source files on a server or removing repeated values in the
 195          // 'sources' entry. This value is prepended to the individual entries in the 'source' field.
 196          $root = $this->options['sourceRoot'];
 197  
 198          if ($root) {
 199              $sourceMap['sourceRoot'] = $root;
 200          }
 201  
 202          // A list of original sources used by the 'mappings' entry.
 203          $sourceMap['sources'] = [];
 204  
 205          foreach ($this->sources as $sourceFilename) {
 206              $sourceMap['sources'][] = $this->normalizeFilename($sourceFilename);
 207          }
 208  
 209          // A list of symbol names used by the 'mappings' entry.
 210          $sourceMap['names'] = [];
 211  
 212          // A string with the encoded mapping data.
 213          $sourceMap['mappings'] = $mappings;
 214  
 215          if ($this->options['outputSourceFiles']) {
 216              // An optional list of source content, useful when the 'source' can't be hosted.
 217              // The contents are listed in the same order as the sources above.
 218              // 'null' may be used if some original sources should be retrieved by name.
 219              $sourceMap['sourcesContent'] = $this->getSourcesContent();
 220          }
 221  
 222          // less.js compat fixes
 223          if (\count($sourceMap['sources']) && empty($sourceMap['sourceRoot'])) {
 224              unset($sourceMap['sourceRoot']);
 225          }
 226  
 227          $jsonSourceMap = json_encode($sourceMap, JSON_UNESCAPED_SLASHES);
 228  
 229          if (json_last_error() !== JSON_ERROR_NONE) {
 230              throw new \RuntimeException(json_last_error_msg());
 231          }
 232  
 233          assert($jsonSourceMap !== false);
 234  
 235          return $jsonSourceMap;
 236      }
 237  
 238      /**
 239       * Returns the sources contents
 240       *
 241       * @return string[]|null
 242       */
 243      protected function getSourcesContent()
 244      {
 245          if (empty($this->sources)) {
 246              return null;
 247          }
 248  
 249          $content = [];
 250  
 251          foreach ($this->sources as $sourceFile) {
 252              $content[] = file_get_contents($sourceFile);
 253          }
 254  
 255          return $content;
 256      }
 257  
 258      /**
 259       * Generates the mappings string
 260       *
 261       * @param string $prefix A prefix added in the output file, which needs to shift mappings
 262       *
 263       * @return string
 264       */
 265      public function generateMappings($prefix = '')
 266      {
 267          if (! \count($this->mappings)) {
 268              return '';
 269          }
 270  
 271          $prefixLines = substr_count($prefix, "\n");
 272          $lastPrefixNewLine = strrpos($prefix, "\n");
 273          $lastPrefixLineStart = false === $lastPrefixNewLine ? 0 : $lastPrefixNewLine + 1;
 274          $prefixColumn = strlen($prefix) - $lastPrefixLineStart;
 275  
 276          $this->sourceKeys = array_flip(array_keys($this->sources));
 277  
 278          // group mappings by generated line number.
 279          $groupedMap = $groupedMapEncoded = [];
 280  
 281          foreach ($this->mappings as $m) {
 282              $groupedMap[$m['generated_line']][] = $m;
 283          }
 284  
 285          ksort($groupedMap);
 286  
 287          $lastGeneratedLine = $lastOriginalIndex = $lastOriginalLine = $lastOriginalColumn = 0;
 288  
 289          foreach ($groupedMap as $lineNumber => $lineMap) {
 290              if ($lineNumber > 1) {
 291                  // The prefix only impacts the column for the first line of the original output
 292                  $prefixColumn = 0;
 293              }
 294              $lineNumber += $prefixLines;
 295  
 296              while (++$lastGeneratedLine < $lineNumber) {
 297                  $groupedMapEncoded[] = ';';
 298              }
 299  
 300              $lineMapEncoded = [];
 301              $lastGeneratedColumn = 0;
 302  
 303              foreach ($lineMap as $m) {
 304                  $generatedColumn = $m['generated_column'] + $prefixColumn;
 305  
 306                  $mapEncoded = $this->encoder->encode($generatedColumn - $lastGeneratedColumn);
 307                  $lastGeneratedColumn = $generatedColumn;
 308  
 309                  // find the index
 310                  if ($m['source_file']) {
 311                      $index = $this->findFileIndex($m['source_file']);
 312  
 313                      if ($index !== false) {
 314                          $mapEncoded .= $this->encoder->encode($index - $lastOriginalIndex);
 315                          $lastOriginalIndex = $index;
 316                          // lines are stored 0-based in SourceMap spec version 3
 317                          $mapEncoded .= $this->encoder->encode($m['original_line'] - 1 - $lastOriginalLine);
 318                          $lastOriginalLine = $m['original_line'] - 1;
 319                          $mapEncoded .= $this->encoder->encode($m['original_column'] - $lastOriginalColumn);
 320                          $lastOriginalColumn = $m['original_column'];
 321                      }
 322                  }
 323  
 324                  $lineMapEncoded[] = $mapEncoded;
 325              }
 326  
 327              $groupedMapEncoded[] = implode(',', $lineMapEncoded) . ';';
 328          }
 329  
 330          return rtrim(implode($groupedMapEncoded), ';');
 331      }
 332  
 333      /**
 334       * Finds the index for the filename
 335       *
 336       * @param string $filename
 337       *
 338       * @return int|false
 339       */
 340      protected function findFileIndex($filename)
 341      {
 342          return $this->sourceKeys[$filename];
 343      }
 344  
 345      /**
 346       * Normalize filename
 347       *
 348       * @param string $filename
 349       *
 350       * @return string
 351       */
 352      protected function normalizeFilename($filename)
 353      {
 354          $filename = $this->fixWindowsPath($filename);
 355          $rootpath = $this->options['sourceMapRootpath'];
 356          $basePath = $this->options['sourceMapBasepath'];
 357  
 358          // "Trim" the 'sourceMapBasepath' from the output filename.
 359          if (\strlen($basePath) && strpos($filename, $basePath) === 0) {
 360              $filename = substr($filename, \strlen($basePath));
 361          }
 362  
 363          // Remove extra leading path separators.
 364          if (strpos($filename, '\\') === 0 || strpos($filename, '/') === 0) {
 365              $filename = substr($filename, 1);
 366          }
 367  
 368          return $rootpath . $filename;
 369      }
 370  
 371      /**
 372       * Fix windows paths
 373       *
 374       * @param string $path
 375       * @param bool   $addEndSlash
 376       *
 377       * @return string
 378       */
 379      public function fixWindowsPath($path, $addEndSlash = false)
 380      {
 381          $slash = ($addEndSlash) ? '/' : '';
 382  
 383          if (! empty($path)) {
 384              $path = str_replace('\\', '/', $path);
 385              $path = rtrim($path, '/') . $slash;
 386          }
 387  
 388          return $path;
 389      }
 390  }