Support LESS stylesheets in ResourceLoader
This patch adds support for the LESS stylesheet language to ResourceLoader. LESS is a stylesheet language that compiles into CSS. The patch includes lessphp, a LESS compiler implemented in PHP. The rationale for choosing LESS is explained in a MediaWiki RFC which accompanies this patch, available at <https://www.mediawiki.org/wiki/Requests_for_comment/LESS>. LESS support is provided for ResourceLoader file modules. It is triggered by the presence of the '.less' extension in stylesheet filenames. LESS files are compiled by lessc, and the resultant CSS is subjected to the standard set of transformations (CSSJanus & CSSMin). The immediate result of LESS compilation is encoded as an array, which includes the list of LESS files that were compiled and their mtimes. This array is cached. Cache invalidation is performed by comparing the cached mtimes with the mtimes of the files on disk. If the compiler itself throws an exception, ResourceLoader constructs a compilation result which consists of the error message encoded as a CSS comment. Failed compilation results are cached too, but with an expiration time of five minutes. The expiration time is required because the full list of referenced files is not known. Three configuration variables configure the global environment for LESS modules: $wgResourceLoaderLESSVars, $wgResourceLoaderLESSFunctions, and $wgResourceLoaderLESSImportPaths. $wgResourceLoaderLESSVars maps variable names to CSS values, specified as strings. Variables declared in this array are available in all LESS files. $wgResourceLoaderLESSFunctions is similar, except it maps custom function names to PHP callables. These functions can be called from within LESS to transform values. Read more about custom functions at <http://leafo.net/lessphp/docs/#custom_functions>. Finally, $wgResourceLoaderLESSImportPaths specifies file system paths in addition to the current module's path where the LESS compiler should look up files referenced in @import statements. The issue of handling of /* @embed */ and /* @noflip */ annotations is left unresolved. Earlier versions of this patch included an @embed analog implemented as a LESS custom function, but there was enough ambiguity about whether the strategy it took was optimal to merit discussing it in a separate, follow-up patch. Bug: 40964 Change-Id: Id052a04dd2f76a1f4aef39fbd454bd67f5fd282f
This commit is contained in:
parent
8c74b6bebf
commit
b67b9e1b48
12 changed files with 4002 additions and 1 deletions
|
|
@ -219,6 +219,19 @@ production.
|
|||
* Add a variable (wgRedactedFunctionArguments) to redact the values sent as certain function
|
||||
parameters from exception stack traces.
|
||||
* Added {{REVISIONSIZE}} variable to get the current size of a revision.
|
||||
* Add support for the LESS stylesheet language to ResourceLoader. LESS is a
|
||||
stylesheet language that compiles into CSS. ResourceLoader file modules may
|
||||
include LESS style files; ResourceLoader will compile these files into CSS
|
||||
before sending them to the client.
|
||||
** The $wgResourceLoaderLESSVars configuration variable is an associative array
|
||||
mapping variable names to string CSS values. These variables are considered
|
||||
declared for all LESS files. Additional variables may be registered by
|
||||
adding keys to the array.
|
||||
** $wgResourceLoaderLESSFunctions is an associative array of custom LESS
|
||||
function names to PHP callables. See <http://leafo.net/lessphp/docs/#custom_functions>
|
||||
for more details regarding custom functions.
|
||||
** $wgResourceLoaderLESSImportPaths is an array of file system paths. Files
|
||||
referenced in LESS '@import' statements are looked up here first.
|
||||
|
||||
=== Bug fixes in 1.22 ===
|
||||
* Disable Special:PasswordReset when $wgEnableEmail is false. Previously one
|
||||
|
|
|
|||
|
|
@ -704,6 +704,13 @@ $wgAutoloadLocalClasses = array(
|
|||
'JSToken' => 'includes/libs/jsminplus.php',
|
||||
'JSTokenizer' => 'includes/libs/jsminplus.php',
|
||||
|
||||
# includes/libs/lessphp
|
||||
'lessc' => 'includes/libs/lessc.inc.php',
|
||||
'lessc_parser' => 'includes/libs/lessc.inc.php',
|
||||
'lessc_formatter_classic' => 'includes/libs/lessc.inc.php',
|
||||
'lessc_formatter_compressed' => 'includes/libs/lessc.inc.php',
|
||||
'lessc_formatter_lessjs' => 'includes/libs/lessc.inc.php',
|
||||
|
||||
# includes/logging
|
||||
'DatabaseLogEntry' => 'includes/logging/LogEntry.php',
|
||||
'DeleteLogFormatter' => 'includes/logging/DeleteLogFormatter.php',
|
||||
|
|
|
|||
|
|
@ -3282,6 +3282,56 @@ $wgResourceLoaderValidateStaticJS = false;
|
|||
*/
|
||||
$wgResourceLoaderExperimentalAsyncLoading = false;
|
||||
|
||||
/**
|
||||
* Global LESS variables. An associative array binding variable names to CSS
|
||||
* string values.
|
||||
*
|
||||
* Because the hashed contents of this array are used to construct the cache key
|
||||
* that ResourceLoader uses to look up LESS compilation results, updating this
|
||||
* array can be used to deliberately invalidate the set of cached results.
|
||||
*
|
||||
* @par Example:
|
||||
* @code
|
||||
* $wgResourceLoaderLESSVars = array(
|
||||
* 'baseFontSize' => '1em',
|
||||
* 'smallFontSize' => '0.75em',
|
||||
* 'WikimediaBlue' => '#006699',
|
||||
* );
|
||||
* @endcode
|
||||
* @since 1.22
|
||||
*/
|
||||
$wgResourceLoaderLESSVars = array();
|
||||
|
||||
/**
|
||||
* Custom LESS functions. An associative array mapping function name to PHP
|
||||
* callable.
|
||||
*
|
||||
* Changes to LESS functions do not trigger cache invalidation. If you update
|
||||
* the behavior of a LESS function and need to invalidate stale compilation
|
||||
* results, you can touch one of values in $wgResourceLoaderLESSVars, as
|
||||
* documented above.
|
||||
*
|
||||
* @since 1.22
|
||||
*/
|
||||
$wgResourceLoaderLESSFunctions = array();
|
||||
|
||||
/**
|
||||
* Default import paths for LESS modules. LESS files referenced in @import
|
||||
* statements will be looked up here first, and relative to the importing file
|
||||
* second. To avoid collisions, it's important for the LESS files in these
|
||||
* directories to have a common, predictable file name prefix.
|
||||
*
|
||||
* Extensions need not (and should not) register paths in
|
||||
* $wgResourceLoaderLESSImportPaths. The import path includes the path of the
|
||||
* currently compiling LESS file, which allows each extension to freely import
|
||||
* files from its own tree.
|
||||
*
|
||||
* @since 1.22
|
||||
*/
|
||||
$wgResourceLoaderLESSImportPaths = array(
|
||||
"$IP/resources/mediawiki.less/",
|
||||
);
|
||||
|
||||
/** @} */ # End of resource loader settings }
|
||||
|
||||
/*************************************************************************//**
|
||||
|
|
|
|||
3703
includes/libs/lessc.inc.php
Normal file
3703
includes/libs/lessc.inc.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -115,6 +115,12 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
|
|||
protected $raw = false;
|
||||
protected $targets = array( 'desktop' );
|
||||
|
||||
/**
|
||||
* Boolean: Whether getStyleURLsForDebug should return raw file paths,
|
||||
* or return load.php urls
|
||||
*/
|
||||
protected $hasGeneratedStyles = false;
|
||||
|
||||
/**
|
||||
* Array: Cache for mtime
|
||||
* @par Usage:
|
||||
|
|
@ -334,6 +340,13 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
|
|||
* @return array
|
||||
*/
|
||||
public function getStyleURLsForDebug( ResourceLoaderContext $context ) {
|
||||
if ( $this->hasGeneratedStyles ) {
|
||||
// Do the default behaviour of returning a url back to load.php
|
||||
// but with only=styles.
|
||||
return parent::getStyleURLsForDebug( $context );
|
||||
}
|
||||
// Our module consists entirely of real css files,
|
||||
// in debug mode we can load those directly.
|
||||
$urls = array();
|
||||
foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) {
|
||||
$urls[$mediaType] = array();
|
||||
|
|
@ -470,6 +483,16 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
|
|||
return "{$this->remoteBasePath}/$path";
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the stylesheet language from a stylesheet file path.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string: the stylesheet language name
|
||||
*/
|
||||
protected function getStyleSheetLang( $path ) {
|
||||
return preg_match( '/\.less$/i', $path ) ? 'less' : 'css';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collates file paths by option (where provided).
|
||||
*
|
||||
|
|
@ -632,7 +655,14 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
|
|||
wfDebugLog( 'resourceloader', $msg );
|
||||
throw new MWException( $msg );
|
||||
}
|
||||
$style = file_get_contents( $localPath );
|
||||
|
||||
if ( $this->getStyleSheetLang( $path ) === 'less' ) {
|
||||
$style = $this->compileLESSFile( $localPath );
|
||||
$this->hasGeneratedStyles = true;
|
||||
} else {
|
||||
$style = file_get_contents( $localPath );
|
||||
}
|
||||
|
||||
if ( $flip ) {
|
||||
$style = CSSJanus::transform( $style, true, false );
|
||||
}
|
||||
|
|
@ -671,4 +701,82 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
|
|||
return $this->targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key for a LESS file.
|
||||
* The cache key varies on the file name, the names and values of global
|
||||
* LESS variables, and the value of $wgShowExceptionDetails. Varying on
|
||||
* $wgShowExceptionDetails ensures the CSS comment indicating compilation
|
||||
* failure shows the right level of detail.
|
||||
*
|
||||
* @param string $fileName File name of root LESS file.
|
||||
* @return string: Cache key
|
||||
*/
|
||||
protected static function getLESSCacheKey( $fileName ) {
|
||||
global $wgShowExceptionDetails;
|
||||
|
||||
$vars = json_encode( self::getLESSVars() );
|
||||
$hash = md5( $fileName . $vars );
|
||||
return wfMemcKey( 'resourceloader', 'less', (string)$wgShowExceptionDetails, $hash );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a LESS file into CSS.
|
||||
*
|
||||
* If invalid, returns replacement CSS source consisting of the compilation
|
||||
* error message encoded as a comment. To save work, we cache a result object
|
||||
* which comprises the compiled CSS and the names & mtimes of the files
|
||||
* that were processed. lessphp compares the cached & current mtimes and
|
||||
* recompiles as necessary.
|
||||
*
|
||||
* @param string $fileName File path of LESS source
|
||||
* @return string: CSS source
|
||||
*/
|
||||
protected function compileLESSFile( $fileName ) {
|
||||
global $wgShowExceptionDetails;
|
||||
|
||||
$key = self::getLESSCacheKey( $fileName );
|
||||
$cache = wfGetCache( CACHE_ANYTHING );
|
||||
|
||||
// The input to lessc. Either an associative array representing the
|
||||
// cached results of a previous compilation, or the string file name if
|
||||
// no cache result exists.
|
||||
$source = $cache->get( $key );
|
||||
if ( !is_array( $source ) || !isset( $source['root'] ) ) {
|
||||
$source = $fileName;
|
||||
}
|
||||
|
||||
$compiler = self::lessCompiler();
|
||||
$expire = 0;
|
||||
try {
|
||||
$result = $compiler->cachedCompile( $source );
|
||||
if ( !is_array( $result ) ) {
|
||||
throw new Exception( 'LESS compiler result has type ' . gettype( $result ) . '; array expected.' );
|
||||
}
|
||||
} catch ( Exception $e ) {
|
||||
// The exception might have been caused by an imported file rather
|
||||
// than the root node. But we don't know which files were imported,
|
||||
// because compilation failed; we thus cannot rely on file mtime to
|
||||
// know when to reattempt compilation. Expire in 5 mins. instead.
|
||||
$expire = 300;
|
||||
wfDebugLog( 'resourceloader', __METHOD__ . ": $e" );
|
||||
$result = array();
|
||||
$result['root'] = $fileName;
|
||||
|
||||
if ( $wgShowExceptionDetails ) {
|
||||
$result['compiled'] = ResourceLoader::makeComment( 'LESS error: ' . $e->getMessage() );
|
||||
} else {
|
||||
$result['compiled'] = ResourceLoader::makeComment( 'LESS stylesheet compilation failed. ' .
|
||||
'Set "$wgShowExceptionDetails = true;" to show detailed debugging information.' );
|
||||
}
|
||||
|
||||
$result['files'] = array( $fileName => self::safeFilemtime( $fileName ) );
|
||||
$result['updated'] = time();
|
||||
}
|
||||
// Tie cache expiry to the names and mtimes of image files that were
|
||||
// embedded in the generated CSS source.
|
||||
$result['files'] += $compiler->embeddedImages;
|
||||
$this->localFileRefs += array_keys( $result['files'] );
|
||||
$cache->set( $key, $result, $expire );
|
||||
return $result['compiled'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -407,6 +407,9 @@ abstract class ResourceLoaderModule {
|
|||
private static $jsParser;
|
||||
private static $parseCacheVersion = 1;
|
||||
|
||||
/** @var array Global LESS variables */
|
||||
private static $lessVars;
|
||||
|
||||
/**
|
||||
* Validate a given script file; if valid returns the original source.
|
||||
* If invalid, returns replacement JS source that throws an exception.
|
||||
|
|
@ -454,6 +457,42 @@ abstract class ResourceLoaderModule {
|
|||
return self::$jsParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return lessc
|
||||
*/
|
||||
protected static function lessCompiler() {
|
||||
global $wgResourceLoaderLESSFunctions, $wgResourceLoaderLESSImportPaths;
|
||||
|
||||
$less = new lessc();
|
||||
$less->setPreserveComments( true );
|
||||
$less->setVariables( self::getLESSVars() );
|
||||
$less->setImportDir( $wgResourceLoaderLESSImportPaths );
|
||||
foreach ( $wgResourceLoaderLESSFunctions as $name => $func ) {
|
||||
$less->registerFunction( $name, $func );
|
||||
}
|
||||
// To ensure embedded images are refreshed when their source files
|
||||
// change, track the names and modification times of image files that
|
||||
// were embedded in the generated CSS source.
|
||||
$less->embeddedImages = array();
|
||||
return $less;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global LESS variables.
|
||||
*
|
||||
* @return array: Map of variable names to string CSS values.
|
||||
*/
|
||||
protected static function getLESSVars() {
|
||||
global $wgResourceLoaderLESSVars;
|
||||
|
||||
if ( self::$lessVars === null ) {
|
||||
self::$lessVars = $wgResourceLoaderLESSVars;
|
||||
// Sort by key to ensure consistent hashing for cache lookups.
|
||||
ksort( self::$lessVars );
|
||||
}
|
||||
return self::$lessVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe version of filemtime(), which doesn't throw a PHP warning if the file doesn't exist
|
||||
* but returns 1 instead.
|
||||
|
|
|
|||
13
resources/mediawiki.less/mediawiki.mixins.less
Normal file
13
resources/mediawiki.less/mediawiki.mixins.less
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Common LESS mixin library for MediaWiki
|
||||
*
|
||||
* By default the folder containing this file is included in $wgResourceLoaderLESSImportPaths,
|
||||
* which makes this file importable by all less files via '@import "mediawiki.mixins";'.
|
||||
*
|
||||
* The mixins included below are considered a public interface for MediaWiki extensions.
|
||||
* The signatures of parametrized mixins should be kept as stable as possible.
|
||||
*
|
||||
* See <http://lesscss.org/#-mixins> for more information about how to write mixins.
|
||||
*/
|
||||
|
||||
// No mixins yet!
|
||||
5
tests/phpunit/data/less/common/test.common.mixins.less
Normal file
5
tests/phpunit/data/less/common/test.common.mixins.less
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.test-mixin (@value) {
|
||||
color: @value;
|
||||
border: @foo solid @Foo;
|
||||
line-height: test-sum(@bar, 10, 20);
|
||||
}
|
||||
3
tests/phpunit/data/less/module/dependency.less
Normal file
3
tests/phpunit/data/less/module/dependency.less
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@import "test.common.mixins";
|
||||
|
||||
@unitTestColor: green;
|
||||
6
tests/phpunit/data/less/module/styles.css
Normal file
6
tests/phpunit/data/less/module/styles.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/* @noflip */
|
||||
.unit-tests {
|
||||
color: green;
|
||||
border: 2px solid #eeeeee;
|
||||
line-height: 35;
|
||||
}
|
||||
6
tests/phpunit/data/less/module/styles.less
Normal file
6
tests/phpunit/data/less/module/styles.less
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
@import "dependency";
|
||||
|
||||
/* @noflip */
|
||||
.unit-tests {
|
||||
.test-mixin(@unitTestColor);
|
||||
}
|
||||
|
|
@ -4,6 +4,32 @@ class ResourceLoaderTest extends MediaWikiTestCase {
|
|||
|
||||
protected static $resourceLoaderRegisterModulesHook;
|
||||
|
||||
protected function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
// $wgResourceLoaderLESSFunctions, $wgResourceLoaderLESSImportPaths; $wgResourceLoaderLESSVars;
|
||||
|
||||
$this->setMwGlobals( array(
|
||||
'wgResourceLoaderLESSFunctions' => array(
|
||||
'test-sum' => function ( $frame, $less ) {
|
||||
$sum = 0;
|
||||
foreach ( $frame[2] as $arg ) {
|
||||
$sum += (int)$arg[1];
|
||||
}
|
||||
return $sum;
|
||||
},
|
||||
),
|
||||
'wgResourceLoaderLESSImportPaths' => array(
|
||||
dirname( __DIR__ ) . '/data/less/common',
|
||||
),
|
||||
'wgResourceLoaderLESSVars' => array(
|
||||
'foo' => '2px',
|
||||
'Foo' => '#eeeeee',
|
||||
'bar' => 5,
|
||||
),
|
||||
) );
|
||||
}
|
||||
|
||||
/* Hook Methods */
|
||||
|
||||
/**
|
||||
|
|
@ -22,6 +48,14 @@ class ResourceLoaderTest extends MediaWikiTestCase {
|
|||
);
|
||||
}
|
||||
|
||||
public static function provideResourceLoaderContext() {
|
||||
$resourceLoader = new ResourceLoader();
|
||||
$request = new FauxRequest();
|
||||
return array(
|
||||
array( new ResourceLoaderContext( $resourceLoader, $request ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/* Test Methods */
|
||||
|
||||
/**
|
||||
|
|
@ -49,6 +83,20 @@ class ResourceLoaderTest extends MediaWikiTestCase {
|
|||
$this->assertEquals( $module, $resourceLoader->getModule( $name ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideResourceLoaderContext
|
||||
* @covers ResourceLoaderFileModule::compileLessFile
|
||||
*/
|
||||
public function testLessFileCompilation( $context ) {
|
||||
$basePath = __DIR__ . '/../data/less/module';
|
||||
$module = new ResourceLoaderFileModule( array(
|
||||
'localBasePath' => $basePath,
|
||||
'styles' => array( 'styles.less' ),
|
||||
) );
|
||||
$styles = $module->getStyles( $context );
|
||||
$this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider providePackedModules
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue