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