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