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:
Ori Livneh 2013-08-12 00:40:20 +08:00
parent 8c74b6bebf
commit b67b9e1b48
12 changed files with 4002 additions and 1 deletions

View file

@ -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

View file

@ -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',

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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'];
}
}

View file

@ -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.

View 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!

View file

@ -0,0 +1,5 @@
.test-mixin (@value) {
color: @value;
border: @foo solid @Foo;
line-height: test-sum(@bar, 10, 20);
}

View file

@ -0,0 +1,3 @@
@import "test.common.mixins";
@unitTestColor: green;

View file

@ -0,0 +1,6 @@
/* @noflip */
.unit-tests {
color: green;
border: 2px solid #eeeeee;
line-height: 35;
}

View file

@ -0,0 +1,6 @@
@import "dependency";
/* @noflip */
.unit-tests {
.test-mixin(@unitTestColor);
}

View file

@ -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
*/