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
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'] ?? null
208        ];
209
210        if ( isset( $fileInfo['currentUri'] ) ) {
211            $this->sources[$fileInfo['currentUri']] = $fileInfo['filename'];
212        }
213    }
214
215    /**
216     * Generates the JSON source map
217     *
218     * @return string
219     * @see https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit#
220     */
221    protected function generateJson() {
222        $sourceMap = [];
223        $mappings = $this->generateMappings();
224
225        // File version (always the first entry in the object) and must be a positive integer.
226        $sourceMap['version'] = self::VERSION;
227
228        // An optional name of the generated code that this source map is associated with.
229        $file = $this->getOption( 'sourceMapFilename' );
230        if ( $file ) {
231            $sourceMap['file'] = $file;
232        }
233
234        // 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.
235        $root = $this->getOption( 'sourceRoot' );
236        if ( $root ) {
237            $sourceMap['sourceRoot'] = $root;
238        }
239
240        // A list of original sources used by the 'mappings' entry.
241        $sourceMap['sources'] = [];
242        foreach ( $this->sources as $source_uri => $source_filename ) {
243            $sourceMap['sources'][] = $this->normalizeFilename( $source_filename );
244        }
245
246        // A list of symbol names used by the 'mappings' entry.
247        $sourceMap['names'] = [];
248
249        // A string with the encoded mapping data.
250        $sourceMap['mappings'] = $mappings;
251
252        if ( $this->getOption( 'outputSourceFiles' ) ) {
253            // An optional list of source content, useful when the 'source' can't be hosted.
254            // The contents are listed in the same order as the sources above.
255            // 'null' may be used if some original sources should be retrieved by name.
256            $sourceMap['sourcesContent'] = $this->getSourcesContent();
257        }
258
259        // less.js compat fixes
260        if ( count( $sourceMap['sources'] ) && empty( $sourceMap['sourceRoot'] ) ) {
261            unset( $sourceMap['sourceRoot'] );
262        }
263
264        return json_encode( $sourceMap );
265    }
266
267    /**
268     * Returns the sources contents
269     *
270     * @return array|null
271     */
272    protected function getSourcesContent() {
273        if ( empty( $this->sources ) ) {
274            return;
275        }
276        $content = [];
277        foreach ( $this->sources as $sourceFile ) {
278            $content[] = file_get_contents( $sourceFile );
279        }
280        return $content;
281    }
282
283    /**
284     * Generates the mappings string
285     *
286     * @return string
287     */
288    public function generateMappings() {
289        if ( !count( $this->mappings ) ) {
290            return '';
291        }
292
293        $this->source_keys = array_flip( array_keys( $this->sources ) );
294
295        // group mappings by generated line number.
296        $groupedMap = $groupedMapEncoded = [];
297        foreach ( $this->mappings as $m ) {
298            $groupedMap[$m['generated_line']][] = $m;
299        }
300        ksort( $groupedMap );
301
302        $lastGeneratedLine = $lastOriginalIndex = $lastOriginalLine = $lastOriginalColumn = 0;
303
304        foreach ( $groupedMap as $lineNumber => $line_map ) {
305            while ( ++$lastGeneratedLine < $lineNumber ) {
306                $groupedMapEncoded[] = ';';
307            }
308
309            $lineMapEncoded = [];
310            $lastGeneratedColumn = 0;
311
312            foreach ( $line_map as $m ) {
313                $mapEncoded = $this->encoder->encode( $m['generated_column'] - $lastGeneratedColumn );
314                $lastGeneratedColumn = $m['generated_column'];
315
316                // find the index
317                if ( $m['source_file'] ) {
318                    $index = $this->findFileIndex( $m['source_file'] );
319                    if ( $index !== false ) {
320                        $mapEncoded .= $this->encoder->encode( $index - $lastOriginalIndex );
321                        $lastOriginalIndex = $index;
322
323                        // lines are stored 0-based in SourceMap spec version 3
324                        $mapEncoded .= $this->encoder->encode( $m['original_line'] - 1 - $lastOriginalLine );
325                        $lastOriginalLine = $m['original_line'] - 1;
326
327                        $mapEncoded .= $this->encoder->encode( $m['original_column'] - $lastOriginalColumn );
328                        $lastOriginalColumn = $m['original_column'];
329                    }
330                }
331
332                $lineMapEncoded[] = $mapEncoded;
333            }
334
335            $groupedMapEncoded[] = implode( ',', $lineMapEncoded ) . ';';
336        }
337
338        return rtrim( implode( $groupedMapEncoded ), ';' );
339    }
340
341    /**
342     * Finds the index for the filename
343     *
344     * @param string $filename
345     * @return int|false
346     */
347    protected function findFileIndex( $filename ) {
348        return $this->source_keys[$filename];
349    }
350
351    /**
352     * fix windows paths
353     * @param string $path
354     * @param bool $addEndSlash
355     * @return string
356     */
357    public function fixWindowsPath( $path, $addEndSlash = false ) {
358        $slash = ( $addEndSlash ) ? '/' : '';
359        if ( !empty( $path ) ) {
360            $path = str_replace( '\\', '/', $path );
361            $path = rtrim( $path, '/' ) . $slash;
362        }
363
364        return $path;
365    }
366
367}