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