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