commit 4b4e92f3e9485d710a6c4032119b41da0902aa03 Author: yutent Date: Sat Jun 17 22:31:03 2023 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4061e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.vscode +node_modules/ +dist +benchmark +bin + +*.sublime-project +*.sublime-workspace +package-lock.json + +._* + +.Spotlight-V100 +.Trashes +.DS_Store +.AppleDouble +.LSOverride \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..c53a7e2 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,406 @@ +'use strict' + +var resolve = require('resolve') +var path = require('path') + +var testFolder = path.relative( + process.cwd(), + path.dirname(resolve.sync('@less/test-data')) +) +var lessFolder = path.join(testFolder, 'less') + +module.exports = function (grunt) { + grunt.option('stack', true) + + // Report the elapsed execution time of tasks. + require('time-grunt')(grunt) + + var git = require('git-rev') + + // Sauce Labs browser + var browsers = [ + // Desktop browsers + { + browserName: 'chrome', + version: 'latest', + platform: 'Windows 7' + }, + { + browserName: 'firefox', + version: 'latest', + platform: 'Linux' + }, + { + browserName: 'safari', + version: '9', + platform: 'OS X 10.11' + }, + { + browserName: 'internet explorer', + version: '8', + platform: 'Windows XP' + }, + { + browserName: 'internet explorer', + version: '11', + platform: 'Windows 8.1' + }, + { + browserName: 'edge', + version: '13', + platform: 'Windows 10' + }, + // Mobile browsers + { + browserName: 'ipad', + deviceName: 'iPad Air Simulator', + deviceOrientation: 'portrait', + version: '8.4', + platform: 'OS X 10.9' + }, + { + browserName: 'iphone', + deviceName: 'iPhone 5 Simulator', + deviceOrientation: 'portrait', + version: '9.3', + platform: 'OS X 10.11' + }, + { + browserName: 'android', + deviceName: 'Google Nexus 7 HD Emulator', + deviceOrientation: 'portrait', + version: '4.4', + platform: 'Linux' + } + ] + + var sauceJobs = {} + + var browserTests = [ + 'filemanager-plugin', + 'visitor-plugin', + 'global-vars', + 'modify-vars', + 'production', + 'rootpath-relative', + 'rootpath-rewrite-urls', + 'rootpath', + 'relative-urls', + 'rewrite-urls', + 'browser', + 'no-js-errors', + 'legacy' + ] + + function makeJob(testName) { + sauceJobs[testName] = { + options: { + urls: + testName === 'all' + ? browserTests.map(function (name) { + return ( + 'http://localhost:8081/tmp/browser/test-runner-' + + name + + '.html' + ) + }) + : [ + 'http://localhost:8081/tmp/browser/test-runner-' + + testName + + '.html' + ], + testname: testName === 'all' ? 'Unit Tests for Less.js' : testName, + browsers: browsers, + public: 'public', + recordVideo: false, + videoUploadOnPass: false, + recordScreenshots: process.env.TRAVIS_BRANCH !== 'master', + build: + process.env.TRAVIS_BRANCH === 'master' + ? process.env.TRAVIS_JOB_ID + : undefined, + tags: [ + process.env.TRAVIS_BUILD_NUMBER, + process.env.TRAVIS_PULL_REQUEST, + process.env.TRAVIS_BRANCH + ], + statusCheckAttempts: -1, + sauceConfig: { + 'idle-timeout': 100 + }, + throttled: 5, + onTestComplete: function (result, callback) { + // Called after a unit test is done, per page, per browser + // 'result' param is the object returned by the test framework's reporter + // 'callback' is a Node.js style callback function. You must invoke it after you + // finish your work. + // Pass a non-null value as the callback's first parameter if you want to throw an + // exception. If your function is synchronous you can also throw exceptions + // directly. + // Passing true or false as the callback's second parameter passes or fails the + // test. Passing undefined does not alter the test result. Please note that this + // only affects the grunt task's result. You have to explicitly update the Sauce + // Labs job's status via its REST API, if you want so. + + // This should be the encrypted value in Travis + var user = process.env.SAUCE_USERNAME + var pass = process.env.SAUCE_ACCESS_KEY + + git.short(function (hash) { + require('phin')( + { + method: 'PUT', + url: [ + 'https://saucelabs.com/rest/v1', + user, + 'jobs', + result.job_id + ].join('/'), + auth: { user: user, pass: pass }, + data: { + passed: result.passed, + build: 'build-' + hash + } + }, + function (error, response) { + if (error) { + console.log(error) + callback(error) + } else if (response.statusCode !== 200) { + console.log(response) + callback(new Error('Unexpected response status')) + } else { + callback(null, result.passed) + } + } + ) + }) + } + } + } + } + + // Make the SauceLabs jobs + ;['all'].concat(browserTests).map(makeJob) + + var path = require('path') + + // Handle async / await in Rollup build for tests + const tsNodeRuntime = path.resolve( + path.join('node_modules', '.bin', 'ts-node') + ) + const crossEnv = path.resolve(path.join('node_modules', '.bin', 'cross-env')) + + // Project configuration. + grunt.initConfig({ + shell: { + options: { + stdout: true, + failOnError: true, + execOptions: { + maxBuffer: Infinity + } + }, + build: { + command: [ + /** Browser runtime */ + 'node build/rollup.js --dist', + /** Copy to repo root */ + 'npm run copy:root', + /** Node.js runtime */ + 'npm run build' + ].join(' && ') + }, + testbuild: { + command: [ + 'npm run build', + 'node build/rollup.js --browser --out=./tmp/browser/less.min.js' + ].join(' && ') + }, + testcjs: { + command: 'npm run build' + }, + testbrowser: { + command: + 'node build/rollup.js --browser --out=./tmp/browser/less.min.js' + }, + test: { + command: [ + // https://github.com/TypeStrong/ts-node/issues/693#issuecomment-848907036 + crossEnv + ' TS_NODE_SCOPE=true', + tsNodeRuntime + ' test/test-es6.ts', + 'node test/index.js' + ].join(' && ') + }, + generatebrowser: { + command: 'node test/browser/generator/generate.js' + }, + runbrowser: { + command: 'node test/browser/generator/runner.js' + }, + benchmark: { + command: 'node benchmark/index.js' + }, + opts: { + // test running with all current options (using `opts` since `options` means something already) + command: [ + // @TODO: make this more thorough + // CURRENT OPTIONS + `node bin/lessc --ie-compat ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`, + // --math + `node bin/lessc --math=always ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`, + `node bin/lessc --math=parens-division ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`, + `node bin/lessc --math=parens ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`, + `node bin/lessc --math=strict ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`, + `node bin/lessc --math=strict-legacy ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`, + + // DEPRECATED OPTIONS + // --strict-math + `node bin/lessc --strict-math=on ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css` + ].join(' && ') + }, + plugin: { + command: [ + `node bin/lessc --clean-css="--s1 --advanced" ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`, + 'cd lib', + `node ../bin/lessc --clean-css="--s1 --advanced" ../${lessFolder}/_main/lazy-eval.less ../tmp/lazy-eval.css`, + `node ../bin/lessc --source-map=lazy-eval.css.map --autoprefix ../${lessFolder}/_main/lazy-eval.less ../tmp/lazy-eval.css`, + 'cd ..', + // Test multiple plugins + `node bin/lessc --plugin=clean-css="--s1 --advanced" --plugin=autoprefix="ie 11,Edge >= 13,Chrome >= 47,Firefox >= 45,iOS >= 9.2,Safari >= 9" ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css` + ].join(' && ') + }, + 'sourcemap-test': { + // quoted value doesn't seem to get picked up by time-grunt, or isn't output, at least; maybe just "sourcemap" is fine? + command: [ + `node bin/lessc --source-map=test/sourcemaps/maps/import-map.map ${lessFolder}/_main/import.less test/sourcemaps/import.css`, + `node bin/lessc --source-map ${lessFolder}/sourcemaps/basic.less test/sourcemaps/basic.css` + ].join(' && ') + } + }, + + eslint: { + target: [ + 'test/**/*.js', + 'src/less*/**/*.js', + '!test/less/errors/plugin/plugin-error.js' + ], + options: { + configFile: '.eslintrc.js', + fix: true + } + }, + + connect: { + server: { + options: { + port: 8081 + } + } + }, + + 'saucelabs-mocha': sauceJobs, + + // Clean the version of less built for the tests + clean: { + test: ['test/browser/less.js', 'tmp', 'test/less-bom'], + 'sourcemap-test': ['test/sourcemaps/*.css', 'test/sourcemaps/*.map'], + sauce_log: ['sc_*.log'] + } + }) + + // Load these plugins to provide the necessary tasks + grunt.loadNpmTasks('grunt-saucelabs') + + require('jit-grunt')(grunt) + + // by default, run tests + grunt.registerTask('default', ['test']) + + // Release + grunt.registerTask('dist', ['shell:build']) + + // Create the browser version of less.js + grunt.registerTask('browsertest-lessjs', ['shell:testbrowser']) + + // Run all browser tests + grunt.registerTask('browsertest', [ + 'browsertest-lessjs', + 'connect', + 'shell:runbrowser' + ]) + + // setup a web server to run the browser tests in a browser rather than phantom + grunt.registerTask('browsertest-server', [ + 'browsertest-lessjs', + 'shell:generatebrowser', + 'connect::keepalive' + ]) + + var previous_force_state = grunt.option('force') + + grunt.registerTask('force', function (set) { + if (set === 'on') { + grunt.option('force', true) + } else if (set === 'off') { + grunt.option('force', false) + } else if (set === 'restore') { + grunt.option('force', previous_force_state) + } + }) + + grunt.registerTask('sauce', [ + 'browsertest-lessjs', + 'shell:generatebrowser', + 'connect', + 'sauce-after-setup' + ]) + + grunt.registerTask('sauce-after-setup', [ + 'saucelabs-mocha:all', + 'clean:sauce_log' + ]) + + var testTasks = [ + 'clean', + 'eslint', + 'shell:testbuild', + 'shell:test', + 'shell:opts', + 'shell:plugin', + 'connect', + 'shell:runbrowser' + ] + + if ( + isNaN(Number(process.env.TRAVIS_PULL_REQUEST, 10)) && + process.env.TRAVIS_BRANCH === 'master' + ) { + testTasks.push('force:on') + testTasks.push('sauce-after-setup') + testTasks.push('force:off') + } + + // Run all tests + grunt.registerTask('test', testTasks) + + // Run shell option tests (includes deprecated options) + grunt.registerTask('shell-options', ['shell:opts']) + + // Run shell plugin test + grunt.registerTask('shell-plugin', ['shell:plugin']) + + // Quickly build and run Node tests + grunt.registerTask('quicktest', ['shell:testcjs', 'shell:test']) + + // generate a good test environment for testing sourcemaps + grunt.registerTask('sourcemap-test', [ + 'clean:sourcemap-test', + 'shell:build:lessc', + 'shell:sourcemap-test', + 'connect::keepalive' + ]) + + // Run benchmark + grunt.registerTask('benchmark', ['shell:testcjs', 'shell:benchmark']) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca6684f --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# [Less.js](http://lesscss.org) + +> The **dynamic** stylesheet language. [http://lesscss.org](http://lesscss.org). + +This is the JavaScript, official, stable version of Less. + + +## Getting Started + +Add Less.js to your project: +```sh +npm install less +``` diff --git a/build/banner.js b/build/banner.js new file mode 100644 index 0000000..fce6f79 --- /dev/null +++ b/build/banner.js @@ -0,0 +1,14 @@ +const pkg = require('./../package.json') + +module.exports = `/** + * Less - ${pkg.description} v${pkg.version} + * http://lesscss.org + * + * Copyright (c) 2009-${new Date().getFullYear()}, ${pkg.author.name} <${ + pkg.author.email +}> + * Licensed under the ${pkg.license} License. + * + * @license ${pkg.license} + */ +` diff --git a/build/rollup.js b/build/rollup.js new file mode 100644 index 0000000..8093541 --- /dev/null +++ b/build/rollup.js @@ -0,0 +1,88 @@ +const rollup = require('rollup') +const typescript = require('rollup-plugin-typescript2') +const commonjs = require('@rollup/plugin-commonjs') +const json = require('@rollup/plugin-json') +const resolve = require('@rollup/plugin-node-resolve').nodeResolve +const terser = require('rollup-plugin-terser').terser +const banner = require('./banner') +const path = require('path') + +const rootPath = path.join(__dirname, '..') + +const args = require('minimist')(process.argv.slice(2)) + +let outDir = args.dist ? './dist' : './tmp' + +async function buildBrowser() { + let bundle = await rollup.rollup({ + input: './src/less-browser/bootstrap.js', + output: [ + { + file: 'less.js', + format: 'umd' + }, + { + file: 'less.min.js', + format: 'umd' + } + ], + plugins: [ + resolve(), + commonjs(), + json(), + typescript({ + verbosity: 2, + tsconfigDefaults: { + compilerOptions: { + allowJs: true, + sourceMap: true, + target: 'ES5' + } + }, + include: ['*.ts', '**/*.ts', '*.js', '**/*.js'], + exclude: ['node_modules'] // only transpile our source code + }), + terser({ + compress: true, + include: [/^.+\.min\.js$/], + output: { + comments: function (node, comment) { + if (comment.type == 'comment2') { + // preserve banner + return /@license/i.test(comment.value) + } + } + } + }) + ] + }) + + if (!args.out || args.out.indexOf('less.js') > -1) { + const file = args.out || `${outDir}/less.js` + console.log(`Writing ${file}...`) + await bundle.write({ + file: path.join(rootPath, file), + format: 'umd', + name: 'less', + banner + }) + } + + if (!args.out || args.out.indexOf('less.min.js') > -1) { + const file = args.out || `${outDir}/less.min.js` + console.log(`Writing ${file}...`) + await bundle.write({ + file: path.join(rootPath, file), + format: 'umd', + name: 'less', + sourcemap: true, + banner + }) + } +} + +async function build() { + await buildBrowser() +} + +build() diff --git a/index.js b/index.js new file mode 100644 index 0000000..ccd64ae --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/less-node').default; diff --git a/package.json b/package.json new file mode 100644 index 0000000..df664a3 --- /dev/null +++ b/package.json @@ -0,0 +1,130 @@ +{ + "name": "less", + "version": "4.1.3", + "description": "Leaner CSS", + "homepage": "http://lesscss.org", + "author": { + "name": "Alexis Sellier", + "email": "self@cloudhead.net" + }, + "contributors": [ + "The Core Less Team" + ], + "repository": { + "type": "git", + "url": "https://github.com/less/less.js.git" + }, + "license": "Apache-2.0", + "bin": { + "lessc": "./bin/lessc" + }, + "main": "index", + "module": "./lib/less-node/index", + "directories": { + "test": "./test" + }, + "browser": "./dist/less.js", + "engines": { + "node": ">=6" + }, + "scripts": { + "test": "grunt test", + "grunt": "grunt", + "lint": "eslint '**/*.{ts,js}'", + "lint:fix": "eslint '**/*.{ts,js}' --fix", + "build": "npm-run-all clean compile", + "clean": "shx rm -rf ./lib tsconfig.tsbuildinfo", + "compile": "tsc -p tsconfig.build.json", + "copy:root": "shx cp -rf ./dist ../../", + "dev": "tsc -p tsconfig.build.json -w", + "prepublishOnly": "grunt dist" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^2.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "source-map": "~0.6.0" + }, + "devDependencies": { + "@less/test-data": "^4.1.0", + "@less/test-import-module": "^4.0.0", + "@rollup/plugin-commonjs": "^17.0.0", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^11.0.0", + "@typescript-eslint/eslint-plugin": "^4.28.0", + "@typescript-eslint/parser": "^4.28.0", + "benny": "^3.6.12", + "bootstrap-less-port": "0.3.0", + "chai": "^4.2.0", + "cross-env": "^7.0.3", + "diff": "^3.2.0", + "eslint": "^7.29.0", + "fs-extra": "^8.1.0", + "git-rev": "^0.2.1", + "globby": "^10.0.1", + "grunt": "^1.0.4", + "grunt-cli": "^1.3.2", + "grunt-contrib-clean": "^1.0.0", + "grunt-contrib-connect": "^1.0.2", + "grunt-eslint": "^23.0.0", + "grunt-saucelabs": "^9.0.1", + "grunt-shell": "^1.3.0", + "html-template-tag": "^3.2.0", + "jit-grunt": "^0.10.0", + "less-plugin-autoprefix": "^1.5.1", + "less-plugin-clean-css": "^1.5.1", + "minimist": "^1.2.0", + "mocha": "^6.2.1", + "mocha-headless-chrome": "^4.0.0", + "mocha-teamcity-reporter": "^3.0.0", + "nock": "^11.8.2", + "npm-run-all": "^4.1.5", + "performance-now": "^0.2.0", + "phin": "^2.2.3", + "promise": "^7.1.1", + "read-glob": "^3.0.0", + "resolve": "^1.17.0", + "rollup": "^2.52.2", + "rollup-plugin-terser": "^5.1.1", + "rollup-plugin-typescript2": "^0.29.0", + "semver": "^6.3.0", + "shx": "^0.3.2", + "time-grunt": "^1.3.0", + "ts-node": "^9.1.1", + "typescript": "^4.3.4", + "uikit": "2.27.4" + }, + "keywords": [ + "compile less", + "css nesting", + "css variable", + "css", + "gradients css", + "gradients css3", + "less compiler", + "less css", + "less mixins", + "less", + "less.js", + "lesscss", + "mixins", + "nested css", + "parser", + "preprocessor", + "bootstrap css", + "bootstrap less", + "style", + "styles", + "stylesheet", + "variables in css", + "css less" + ], + "dependencies": { + "copy-anything": "^2.0.1", + "parse-node-version": "^1.0.1", + "tslib": "^2.3.0" + } +} diff --git a/src/less-node/environment.js b/src/less-node/environment.js new file mode 100644 index 0000000..fa541a0 --- /dev/null +++ b/src/less-node/environment.js @@ -0,0 +1,16 @@ +export default { + encodeBase64: function encodeBase64(str) { + // Avoid Buffer constructor on newer versions of Node.js. + const buffer = Buffer.from ? Buffer.from(str) : new Buffer(str) + return buffer.toString('base64') + }, + mimeLookup: function (filename) { + return require('mime').lookup(filename) + }, + charsetLookup: function (mime) { + return require('mime').charsets.lookup(mime) + }, + getSourceMapGenerator: function getSourceMapGenerator() { + return require('source-map').SourceMapGenerator + } +} diff --git a/src/less-node/file-manager.js b/src/less-node/file-manager.js new file mode 100644 index 0000000..a4d2f7c --- /dev/null +++ b/src/less-node/file-manager.js @@ -0,0 +1,153 @@ +import path from 'path' +import fs from './fs' +import AbstractFileManager from '../less/environment/abstract-file-manager.js' + +const FileManager = function () {} +FileManager.prototype = Object.assign(new AbstractFileManager(), { + supports() { + return true + }, + + supportsSync() { + return true + }, + + loadFile(filename, currentDirectory, options, environment, callback) { + let fullFilename + const isAbsoluteFilename = this.isPathAbsolute(filename) + const filenamesTried = [] + const self = this + const prefix = filename.slice(0, 1) + const explicit = prefix === '.' || prefix === '/' + let result = null + let isNodeModule = false + const npmPrefix = 'npm://' + + options = options || {} + + const paths = isAbsoluteFilename ? [''] : [currentDirectory] + + if (options.paths) { + paths.push.apply(paths, options.paths) + } + + if (!isAbsoluteFilename && paths.indexOf('.') === -1) { + paths.push('.') + } + + const prefixes = options.prefixes || [''] + const fileParts = this.extractUrlParts(filename) + + if (options.syncImport) { + getFileData(returnData, returnData) + if (callback) { + callback(result.error, result) + } else { + return result + } + } else { + // promise is guaranteed to be asyncronous + // which helps as it allows the file handle + // to be closed before it continues with the next file + return new Promise(getFileData) + } + + function returnData(data) { + if (!data.filename) { + result = { error: data } + } else { + result = data + } + } + + function getFileData(fulfill, reject) { + ;(function tryPathIndex(i) { + function tryWithExtension() { + const extFilename = options.ext + ? self.tryAppendExtension(fullFilename, options.ext) + : fullFilename + + if (extFilename !== fullFilename && !explicit && paths[i] === '.') { + try { + fullFilename = require.resolve(extFilename) + isNodeModule = true + } catch (e) { + filenamesTried.push(npmPrefix + extFilename) + fullFilename = extFilename + } + } else { + fullFilename = extFilename + } + } + if (i < paths.length) { + ;(function tryPrefix(j) { + if (j < prefixes.length) { + isNodeModule = false + fullFilename = + fileParts.rawPath + prefixes[j] + fileParts.filename + + if (paths[i]) { + fullFilename = path.join(paths[i], fullFilename) + } + + if (!explicit && paths[i] === '.') { + try { + fullFilename = require.resolve(fullFilename) + isNodeModule = true + } catch (e) { + filenamesTried.push(npmPrefix + fullFilename) + tryWithExtension() + } + } else { + tryWithExtension() + } + + const readFileArgs = [fullFilename] + if (!options.rawBuffer) { + readFileArgs.push('utf-8') + } + if (options.syncImport) { + try { + const data = fs.readFileSync.apply(this, readFileArgs) + fulfill({ contents: data, filename: fullFilename }) + } catch (e) { + filenamesTried.push( + isNodeModule ? npmPrefix + fullFilename : fullFilename + ) + return tryPrefix(j + 1) + } + } else { + readFileArgs.push(function (e, data) { + if (e) { + filenamesTried.push( + isNodeModule ? npmPrefix + fullFilename : fullFilename + ) + return tryPrefix(j + 1) + } + fulfill({ contents: data, filename: fullFilename }) + }) + fs.readFile.apply(this, readFileArgs) + } + } else { + tryPathIndex(i + 1) + } + })(0) + } else { + reject({ + type: 'File', + message: `'${filename}' wasn't found. Tried - ${filenamesTried.join( + ',' + )}` + }) + } + })(0) + } + }, + + loadFileSync(filename, currentDirectory, options, environment) { + options.syncImport = true + return this.loadFile(filename, currentDirectory, options, environment) + } +}) + +export default FileManager diff --git a/src/less-node/fs.js b/src/less-node/fs.js new file mode 100644 index 0000000..95a0134 --- /dev/null +++ b/src/less-node/fs.js @@ -0,0 +1,7 @@ +let fs +try { + fs = require('graceful-fs') +} catch (e) { + fs = require('fs') +} +export default fs diff --git a/src/less-node/image-size.js b/src/less-node/image-size.js new file mode 100644 index 0000000..44390bd --- /dev/null +++ b/src/less-node/image-size.js @@ -0,0 +1,67 @@ +import Dimension from '../less/tree/dimension' +import Expression from '../less/tree/expression' +import functionRegistry from './../less/functions/function-registry' + +export default environment => { + function imageSize(functionContext, filePathNode) { + let filePath = filePathNode.value + const currentFileInfo = functionContext.currentFileInfo + const currentDirectory = currentFileInfo.rewriteUrls + ? currentFileInfo.currentDirectory + : currentFileInfo.entryPath + + const fragmentStart = filePath.indexOf('#') + if (fragmentStart !== -1) { + filePath = filePath.slice(0, fragmentStart) + } + + const fileManager = environment.getFileManager( + filePath, + currentDirectory, + functionContext.context, + environment, + true + ) + + if (!fileManager) { + throw { + type: 'File', + message: `Can not set up FileManager for ${filePathNode}` + } + } + + const fileSync = fileManager.loadFileSync( + filePath, + currentDirectory, + functionContext.context, + environment + ) + + if (fileSync.error) { + throw fileSync.error + } + + const sizeOf = require('image-size') + return sizeOf(fileSync.filename) + } + + const imageFunctions = { + 'image-size': function (filePathNode) { + const size = imageSize(this, filePathNode) + return new Expression([ + new Dimension(size.width, 'px'), + new Dimension(size.height, 'px') + ]) + }, + 'image-width': function (filePathNode) { + const size = imageSize(this, filePathNode) + return new Dimension(size.width, 'px') + }, + 'image-height': function (filePathNode) { + const size = imageSize(this, filePathNode) + return new Dimension(size.height, 'px') + } + } + + functionRegistry.addMultiple(imageFunctions) +} diff --git a/src/less-node/index.js b/src/less-node/index.js new file mode 100644 index 0000000..e0f9ed3 --- /dev/null +++ b/src/less-node/index.js @@ -0,0 +1,25 @@ +import environment from './environment' +import FileManager from './file-manager' +import UrlFileManager from './url-file-manager' +import createFromEnvironment from '../less' +const less = createFromEnvironment(environment, [ + new FileManager(), + new UrlFileManager() +]) +import lesscHelper from './lessc-helper' + +// allow people to create less with their own environment +less.createFromEnvironment = createFromEnvironment +less.lesscHelper = lesscHelper +less.PluginLoader = require('./plugin-loader').default +less.fs = require('./fs').default +less.FileManager = FileManager +less.UrlFileManager = UrlFileManager + +// Set up options +less.options = require('../less/default-options').default() + +// provide image-size functionality +require('./image-size').default(less.environment) + +export default less diff --git a/src/less-node/lessc-helper.js b/src/less-node/lessc-helper.js new file mode 100644 index 0000000..519268b --- /dev/null +++ b/src/less-node/lessc-helper.js @@ -0,0 +1,179 @@ +// lessc_helper.js +// +// helper functions for lessc +const lessc_helper = { + // Stylize a string + stylize: function (str, style) { + const styles = { + reset: [0, 0], + bold: [1, 22], + inverse: [7, 27], + underline: [4, 24], + yellow: [33, 39], + green: [32, 39], + red: [31, 39], + grey: [90, 39] + } + return `\x1b[${styles[style][0]}m${str}\x1b[${styles[style][1]}m` + }, + + // Print command line options + printUsage: function () { + console.log( + 'usage: lessc [option option=parameter ...] [destination]' + ) + console.log('') + console.log( + "If source is set to `-' (dash or hyphen-minus), input is read from stdin." + ) + console.log('') + console.log('options:') + console.log( + ' -h, --help Prints help (this message) and exit.' + ) + console.log( + " --include-path=PATHS Sets include paths. Separated by `:'. `;' also supported on windows." + ) + console.log( + ' -M, --depends Outputs a makefile import dependency list to stdout.' + ) + console.log(' --no-color Disables colorized output.') + console.log( + ' --ie-compat Enables IE8 compatibility checks.' + ) + console.log( + ' --js Enables inline JavaScript in less files' + ) + console.log(' -l, --lint Syntax check only (lint).') + console.log( + ' -s, --silent Suppresses output of error messages.' + ) + console.log(' --strict-imports Forces evaluation of imports.') + console.log( + ' --insecure Allows imports from insecure https hosts.' + ) + console.log( + ' -v, --version Prints version number and exit.' + ) + console.log(' --verbose Be verbose.') + console.log( + ' --source-map[=FILENAME] Outputs a v3 sourcemap to the filename (or output filename.map).' + ) + console.log( + ' --source-map-rootpath=X Adds this path onto the sourcemap filename and less file paths.' + ) + console.log( + ' --source-map-basepath=X Sets sourcemap base path, defaults to current working directory.' + ) + console.log( + ' --source-map-include-source Puts the less files into the map instead of referencing them.' + ) + console.log( + ' --source-map-inline Puts the map (and any less files) as a base64 data uri into the output css file.' + ) + console.log( + ' --source-map-url=URL Sets a custom URL to map file, for sourceMappingURL comment' + ) + console.log(' in generated CSS file.') + console.log( + ' --source-map-no-annotation Excludes the sourceMappingURL comment from the output css file.' + ) + console.log( + ' -rp, --rootpath=URL Sets rootpath for url rewriting in relative imports and urls' + ) + console.log( + ' Works with or without the relative-urls option.' + ) + console.log( + ' -ru=, --rewrite-urls= Rewrites URLs to make them relative to the base less file.' + ) + console.log( + " all|local|off 'all' rewrites all URLs, 'local' just those starting with a '.'" + ) + console.log('') + console.log(' -m=, --math=') + console.log( + ' always Less will eagerly perform math operations always.' + ) + console.log( + ' parens-division Math performed except for division (/) operator' + ) + console.log( + ' parens | strict Math only performed inside parentheses' + ) + console.log( + ' strict-legacy Parens required in very strict terms (legacy --strict-math)' + ) + console.log('') + console.log( + ' -su=on|off Allows mixed units, e.g. 1px+1em or 1px*1px which have units' + ) + console.log(' --strict-units=on|off that cannot be represented.') + console.log( + " --global-var='VAR=VALUE' Defines a variable that can be referenced by the file." + ) + console.log( + " --modify-var='VAR=VALUE' Modifies a variable already declared in the file." + ) + console.log( + " --url-args='QUERYSTRING' Adds params into url tokens (e.g. 42, cb=42 or 'a=1&b=2')" + ) + console.log( + ' --plugin=PLUGIN=OPTIONS Loads a plugin. You can also omit the --plugin= if the plugin begins' + ) + console.log( + ' less-plugin. E.g. the clean css plugin is called less-plugin-clean-css' + ) + console.log( + ' once installed (npm install less-plugin-clean-css), use either with' + ) + console.log( + ' --plugin=less-plugin-clean-css or just --clean-css' + ) + console.log( + ' specify options afterwards e.g. --plugin=less-plugin-clean-css="advanced"' + ) + console.log(' or --clean-css="advanced"') + console.log(' --disable-plugin-rule Disallow @plugin statements') + console.log('') + console.log('-------------------------- Deprecated ----------------') + console.log( + ' -sm=on|off Legacy parens-only math. Use --math' + ) + console.log(' --strict-math=on|off ') + console.log('') + console.log(' --line-numbers=TYPE Outputs filename and line numbers.') + console.log( + " TYPE can be either 'comments', which will output" + ) + console.log( + " the debug info within comments, 'mediaquery'" + ) + console.log( + ' that will output the information within a fake' + ) + console.log( + ' media query which is compatible with the SASS' + ) + console.log( + " format, and 'all' which will do both." + ) + console.log( + ' -x, --compress Compresses output by removing some whitespaces.' + ) + console.log( + ' We recommend you use a dedicated minifer like less-plugin-clean-css' + ) + console.log('') + console.log('Report bugs to: http://github.com/less/less.js/issues') + console.log('Home page: ') + } +} + +// Exports helper functions +// eslint-disable-next-line no-prototype-builtins +for (const h in lessc_helper) { + if (lessc_helper.hasOwnProperty(h)) { + exports[h] = lessc_helper[h] + } +} diff --git a/src/less-node/plugin-loader.js b/src/less-node/plugin-loader.js new file mode 100644 index 0000000..d5f12fb --- /dev/null +++ b/src/less-node/plugin-loader.js @@ -0,0 +1,66 @@ +import path from 'path' +import AbstractPluginLoader from '../less/environment/abstract-plugin-loader.js' + +/** + * Node Plugin Loader + */ +const PluginLoader = function (less) { + this.less = less + this.require = prefix => { + prefix = path.dirname(prefix) + return id => { + const str = id.substr(0, 2) + if (str === '..' || str === './') { + return require(path.join(prefix, id)) + } else { + return require(id) + } + } + } +} + +PluginLoader.prototype = Object.assign(new AbstractPluginLoader(), { + loadPlugin(filename, basePath, context, environment, fileManager) { + const prefix = filename.slice(0, 1) + const explicit = + prefix === '.' || + prefix === '/' || + filename.slice(-3).toLowerCase() === '.js' + if (!explicit) { + context.prefixes = ['less-plugin-', ''] + } + + if (context.syncImport) { + return fileManager.loadFileSync(filename, basePath, context, environment) + } + + return new Promise((fulfill, reject) => { + fileManager + .loadFile(filename, basePath, context, environment) + .then(data => { + try { + fulfill(data) + } catch (e) { + console.log(e) + reject(e) + } + }) + .catch(err => { + reject(err) + }) + }) + }, + + loadPluginSync(filename, basePath, context, environment, fileManager) { + context.syncImport = true + return this.loadPlugin( + filename, + basePath, + context, + environment, + fileManager + ) + } +}) + +export default PluginLoader diff --git a/src/less-node/url-file-manager.js b/src/less-node/url-file-manager.js new file mode 100644 index 0000000..6cbd477 --- /dev/null +++ b/src/less-node/url-file-manager.js @@ -0,0 +1,73 @@ +/* eslint-disable no-unused-vars */ +/** + * @todo - remove top eslint rule when FileManagers have JSDoc type + * and are TS-type-checked + */ +const isUrlRe = /^(?:https?:)?\/\//i +import url from 'url' +let request +import AbstractFileManager from '../less/environment/abstract-file-manager.js' +import logger from '../less/logger' + +const UrlFileManager = function () {} +UrlFileManager.prototype = Object.assign(new AbstractFileManager(), { + supports(filename, currentDirectory, options, environment) { + return isUrlRe.test(filename) || isUrlRe.test(currentDirectory) + }, + + loadFile(filename, currentDirectory, options, environment) { + return new Promise((fulfill, reject) => { + if (request === undefined) { + try { + request = require('needle') + } catch (e) { + request = null + } + } + if (!request) { + reject({ + type: 'File', + message: + "optional dependency 'needle' required to import over http(s)\n" + }) + return + } + + let urlStr = isUrlRe.test(filename) + ? filename + : url.resolve(currentDirectory, filename) + + /** native-request currently has a bug */ + const hackUrlStr = urlStr.indexOf('?') === -1 ? urlStr + '?' : urlStr + + request.get(hackUrlStr, { follow_max: 5 }, (err, resp, body) => { + if (err || (resp && resp.statusCode >= 400)) { + const message = + resp && resp.statusCode === 404 + ? `resource '${urlStr}' was not found\n` + : `resource '${urlStr}' gave this Error:\n ${ + err || resp.statusMessage || resp.statusCode + }\n` + reject({ type: 'File', message }) + return + } + if (resp.statusCode >= 300) { + reject({ + type: 'File', + message: `resource '${urlStr}' caused too many redirects` + }) + return + } + body = body.toString('utf8') + if (!body) { + logger.warn( + `Warning: Empty body (HTTP ${resp.statusCode}) returned by "${urlStr}"` + ) + } + fulfill({ contents: body || '', filename: urlStr }) + }) + }) + } +}) + +export default UrlFileManager diff --git a/src/less/constants.js b/src/less/constants.js new file mode 100644 index 0000000..6e4ce36 --- /dev/null +++ b/src/less/constants.js @@ -0,0 +1,12 @@ +export const Math = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2 + // removed - STRICT_LEGACY: 3 +} + +export const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2 +} diff --git a/src/less/contexts.js b/src/less/contexts.js new file mode 100644 index 0000000..48eacb1 --- /dev/null +++ b/src/less/contexts.js @@ -0,0 +1,183 @@ +const contexts = {} +export default contexts +import * as Constants from './constants' + +const copyFromOriginal = function copyFromOriginal( + original, + destination, + propertiesToCopy +) { + if (!original) { + return + } + + for (let i = 0; i < propertiesToCopy.length; i++) { + if (Object.prototype.hasOwnProperty.call(original, propertiesToCopy[i])) { + destination[propertiesToCopy[i]] = original[propertiesToCopy[i]] + } + } +} + +/* + parse is used whilst parsing + */ +const parseCopyProperties = [ + // options + 'paths', // option - unmodified - paths to search for imports on + 'rewriteUrls', // option - whether to adjust URL's to be relative + 'rootpath', // option - rootpath to append to URL's + 'strictImports', // option - + 'insecure', // option - whether to allow imports from insecure ssl hosts + 'dumpLineNumbers', // option - whether to dump line numbers + 'compress', // option - whether to compress + 'syncImport', // option - whether to import synchronously + 'chunkInput', // option - whether to chunk input. more performant but causes parse issues. + 'mime', // browser only - mime type for sheet import + 'useFileCache', // browser only - whether to use the per file session cache + // context + 'processImports', // option & context - whether to process imports. if false then imports will not be imported. + // Used by the import manager to stop multiple import visitors being created. + 'pluginManager' // Used as the plugin manager for the session +] + +contexts.Parse = function (options) { + copyFromOriginal(options, this, parseCopyProperties) + + if (typeof this.paths === 'string') { + this.paths = [this.paths] + } +} + +const evalCopyProperties = [ + 'paths', // additional include paths + 'compress', // whether to compress + 'math', // whether math has to be within parenthesis + 'strictUnits', // whether units need to evaluate correctly + 'sourceMap', // whether to output a source map + 'importMultiple', // whether we are currently importing multiple copies + 'urlArgs', // whether to add args into url tokens + 'javascriptEnabled', // option - whether Inline JavaScript is enabled. if undefined, defaults to false + 'pluginManager', // Used as the plugin manager for the session + 'importantScope', // used to bubble up !important statements + 'rewriteUrls' // option - whether to adjust URL's to be relative +] + +contexts.Eval = function (options, frames) { + copyFromOriginal(options, this, evalCopyProperties) + + if (typeof this.paths === 'string') { + this.paths = [this.paths] + } + + this.frames = frames || [] + this.importantScope = this.importantScope || [] +} + +contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = [] + } + this.calcStack.push(true) + this.inCalc = true +} + +contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop() + if (!this.calcStack.length) { + this.inCalc = false + } +} + +contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = [] + } + this.parensStack.push(true) +} + +contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop() +} + +contexts.Eval.prototype.inCalc = false +contexts.Eval.prototype.mathOn = true +contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false + } + if ( + op === '/' && + this.math !== Constants.Math.ALWAYS && + (!this.parensStack || !this.parensStack.length) + ) { + return false + } + if (this.math > Constants.Math.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length + } + return true +} + +contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = + this.rewriteUrls === Constants.RewriteUrls.LOCAL + ? isPathLocalRelative + : isPathRelative + + return isRelative(path) +} + +contexts.Eval.prototype.rewritePath = function (path, rootpath) { + let newPath + + rootpath = rootpath || '' + newPath = this.normalizePath(rootpath + path) + + // If a path was explicit relative and the rootpath was not an absolute path + // we must ensure that the new path is also explicit relative. + if ( + isPathLocalRelative(path) && + isPathRelative(rootpath) && + isPathLocalRelative(newPath) === false + ) { + newPath = `./${newPath}` + } + + return newPath +} + +contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse() + let segment + + path = [] + while (segments.length !== 0) { + segment = segments.pop() + switch (segment) { + case '.': + break + case '..': + if (path.length === 0 || path[path.length - 1] === '..') { + path.push(segment) + } else { + path.pop() + } + break + default: + path.push(segment) + break + } + } + + return path.join('/') +} + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path) +} + +function isPathLocalRelative(path) { + return path.charAt(0) === '.' +} + +// todo - do the same for the toCSS ? diff --git a/src/less/data/colors.js b/src/less/data/colors.js new file mode 100644 index 0000000..68f7cf3 --- /dev/null +++ b/src/less/data/colors.js @@ -0,0 +1,150 @@ +export default { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgrey: '#a9a9a9', + darkgreen: '#006400', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dimgrey: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + grey: '#808080', + green: '#008000', + greenyellow: '#adff2f', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + indianred: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', + lightgrey: '#d3d3d3', + lightgreen: '#90ee90', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightslategrey: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370d8', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#d87093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + rebeccapurple: '#663399', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + slategrey: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd32' +} diff --git a/src/less/data/index.js b/src/less/data/index.js new file mode 100644 index 0000000..85b4c28 --- /dev/null +++ b/src/less/data/index.js @@ -0,0 +1,4 @@ +import colors from './colors' +import unitConversions from './unit-conversions' + +export default { colors, unitConversions } diff --git a/src/less/data/unit-conversions.js b/src/less/data/unit-conversions.js new file mode 100644 index 0000000..dc38582 --- /dev/null +++ b/src/less/data/unit-conversions.js @@ -0,0 +1,21 @@ +export default { + length: { + m: 1, + cm: 0.01, + mm: 0.001, + in: 0.0254, + px: 0.0254 / 96, + pt: 0.0254 / 72, + pc: (0.0254 / 72) * 12 + }, + duration: { + s: 1, + ms: 0.001 + }, + angle: { + rad: 1 / (2 * Math.PI), + deg: 1 / 360, + grad: 1 / 400, + turn: 1 + } +} diff --git a/src/less/default-options.js b/src/less/default-options.js new file mode 100644 index 0000000..d62e63c --- /dev/null +++ b/src/less/default-options.js @@ -0,0 +1,70 @@ +// Export a new default each time +export default function () { + return { + /* Inline Javascript - @plugin still allowed */ + javascriptEnabled: false, + + /* Outputs a makefile import dependency list to stdout. */ + depends: false, + + /* (DEPRECATED) Compress using less built-in compression. + * This does an okay job but does not utilise all the tricks of + * dedicated css compression. */ + compress: false, + + /* Runs the less parser and just reports errors without any output. */ + lint: false, + + /* Sets available include paths. + * If the file in an @import rule does not exist at that exact location, + * less will look for it at the location(s) passed to this option. + * You might use this for instance to specify a path to a library which + * you want to be referenced simply and relatively in the less files. */ + paths: [], + + /* color output in the terminal */ + color: true, + + /* The strictImports controls whether the compiler will allow an @import inside of either + * @media blocks or (a later addition) other selector blocks. + * See: https://github.com/less/less.js/issues/656 */ + strictImports: false, + + /* Allow Imports from Insecure HTTPS Hosts */ + insecure: false, + + /* Allows you to add a path to every generated import and url in your css. + * This does not affect less import statements that are processed, just ones + * that are left in the output css. */ + rootpath: '', + + /* By default URLs are kept as-is, so if you import a file in a sub-directory + * that references an image, exactly the same URL will be output in the css. + * This option allows you to re-write URL's in imported files so that the + * URL is always relative to the base imported file */ + rewriteUrls: false, + + /* How to process math + * 0 always - eagerly try to solve all operations + * 1 parens-division - require parens for division "/" + * 2 parens | strict - require parens for all operations + * 3 strict-legacy - legacy strict behavior (super-strict) + */ + math: 1, + + /* Without this option, less attempts to guess at the output unit when it does maths. */ + strictUnits: false, + + /* Effectively the declaration is put at the top of your base Less file, + * meaning it can be used but it also can be overridden if this variable + * is defined in the file. */ + globalVars: null, + + /* As opposed to the global variable option, this puts the declaration at the + * end of your base file, meaning it will override anything defined in your Less file. */ + modifyVars: null, + + /* This option allows you to specify a argument to go on to every URL. */ + urlArgs: '' + } +} diff --git a/src/less/environment/abstract-file-manager.js b/src/less/environment/abstract-file-manager.js new file mode 100644 index 0000000..c3dd63b --- /dev/null +++ b/src/less/environment/abstract-file-manager.js @@ -0,0 +1,140 @@ +class AbstractFileManager { + getPath(filename) { + let j = filename.lastIndexOf('?') + if (j > 0) { + filename = filename.slice(0, j) + } + j = filename.lastIndexOf('/') + if (j < 0) { + j = filename.lastIndexOf('\\') + } + if (j < 0) { + return '' + } + return filename.slice(0, j + 1) + } + + tryAppendExtension(path, ext) { + return /(\.[a-z]*$)|([?;].*)$/.test(path) ? path : path + ext + } + + tryAppendLessExtension(path) { + return this.tryAppendExtension(path, '.less') + } + + supportsSync() { + return false + } + + alwaysMakePathsAbsolute() { + return false + } + + isPathAbsolute(filename) { + return /^(?:[a-z-]+:|\/|\\|#)/i.test(filename) + } + + // TODO: pull out / replace? + join(basePath, laterPath) { + if (!basePath) { + return laterPath + } + return basePath + laterPath + } + + pathDiff(url, baseUrl) { + // diff between two paths to create a relative path + + const urlParts = this.extractUrlParts(url) + + const baseUrlParts = this.extractUrlParts(baseUrl) + let i + let max + let urlDirectories + let baseUrlDirectories + let diff = '' + if (urlParts.hostPart !== baseUrlParts.hostPart) { + return '' + } + max = Math.max(baseUrlParts.directories.length, urlParts.directories.length) + for (i = 0; i < max; i++) { + if (baseUrlParts.directories[i] !== urlParts.directories[i]) { + break + } + } + baseUrlDirectories = baseUrlParts.directories.slice(i) + urlDirectories = urlParts.directories.slice(i) + for (i = 0; i < baseUrlDirectories.length - 1; i++) { + diff += '../' + } + for (i = 0; i < urlDirectories.length - 1; i++) { + diff += `${urlDirectories[i]}/` + } + return diff + } + + /** + * Helper function, not part of API. + * This should be replaceable by newer Node / Browser APIs + * + * @param {string} url + * @param {string} baseUrl + */ + extractUrlParts(url, baseUrl) { + // urlParts[1] = protocol://hostname/ OR / + // urlParts[2] = / if path relative to host base + // urlParts[3] = directories + // urlParts[4] = filename + // urlParts[5] = parameters + + const urlPartsRegex = + /^((?:[a-z-]+:)?\/{2}(?:[^/?#]*\/)|([/\\]))?((?:[^/\\?#]*[/\\])*)([^/\\?#]*)([#?].*)?$/i + + const urlParts = url.match(urlPartsRegex) + const returner = {} + let rawDirectories = [] + const directories = [] + let i + let baseUrlParts + + if (!urlParts) { + throw new Error(`Could not parse sheet href - '${url}'`) + } + + // Stylesheets in IE don't always return the full path + if (baseUrl && (!urlParts[1] || urlParts[2])) { + baseUrlParts = baseUrl.match(urlPartsRegex) + if (!baseUrlParts) { + throw new Error(`Could not parse page url - '${baseUrl}'`) + } + urlParts[1] = urlParts[1] || baseUrlParts[1] || '' + if (!urlParts[2]) { + urlParts[3] = baseUrlParts[3] + urlParts[3] + } + } + + if (urlParts[3]) { + rawDirectories = urlParts[3].replace(/\\/g, '/').split('/') + + // collapse '..' and skip '.' + for (i = 0; i < rawDirectories.length; i++) { + if (rawDirectories[i] === '..') { + directories.pop() + } else if (rawDirectories[i] !== '.') { + directories.push(rawDirectories[i]) + } + } + } + + returner.hostPart = urlParts[1] + returner.directories = directories + returner.rawPath = (urlParts[1] || '') + rawDirectories.join('/') + returner.path = (urlParts[1] || '') + directories.join('/') + returner.filename = urlParts[4] + returner.fileUrl = returner.path + (urlParts[4] || '') + returner.url = returner.fileUrl + (urlParts[5] || '') + return returner + } +} + +export default AbstractFileManager diff --git a/src/less/environment/abstract-plugin-loader.js b/src/less/environment/abstract-plugin-loader.js new file mode 100644 index 0000000..b0a1200 --- /dev/null +++ b/src/less/environment/abstract-plugin-loader.js @@ -0,0 +1,215 @@ +import functionRegistry from '../functions/function-registry' +import LessError from '../less-error' + +class AbstractPluginLoader { + constructor() { + // Implemented by Node.js plugin loader + this.require = function () { + return null + } + } + + evalPlugin(contents, context, imports, pluginOptions, fileInfo) { + let loader, + registry, + pluginObj, + localModule, + pluginManager, + filename, + result + + pluginManager = context.pluginManager + + if (fileInfo) { + if (typeof fileInfo === 'string') { + filename = fileInfo + } else { + filename = fileInfo.filename + } + } + const shortname = new this.less.FileManager().extractUrlParts( + filename + ).filename + + if (filename) { + pluginObj = pluginManager.get(filename) + + if (pluginObj) { + result = this.trySetOptions( + pluginObj, + filename, + shortname, + pluginOptions + ) + if (result) { + return result + } + try { + if (pluginObj.use) { + pluginObj.use.call(this.context, pluginObj) + } + } catch (e) { + e.message = e.message || 'Error during @plugin call' + return new LessError(e, imports, filename) + } + return pluginObj + } + } + localModule = { + exports: {}, + pluginManager, + fileInfo + } + registry = functionRegistry.create() + + const registerPlugin = function (obj) { + pluginObj = obj + } + + try { + loader = new Function( + 'module', + 'require', + 'registerPlugin', + 'functions', + 'tree', + 'less', + 'fileInfo', + contents + ) + loader( + localModule, + this.require(filename), + registerPlugin, + registry, + this.less.tree, + this.less, + fileInfo + ) + } catch (e) { + return new LessError(e, imports, filename) + } + + if (!pluginObj) { + pluginObj = localModule.exports + } + pluginObj = this.validatePlugin(pluginObj, filename, shortname) + + if (pluginObj instanceof LessError) { + return pluginObj + } + + if (pluginObj) { + pluginObj.imports = imports + pluginObj.filename = filename + + // For < 3.x (or unspecified minVersion) - setOptions() before install() + if ( + !pluginObj.minVersion || + this.compareVersion('3.0.0', pluginObj.minVersion) < 0 + ) { + result = this.trySetOptions( + pluginObj, + filename, + shortname, + pluginOptions + ) + + if (result) { + return result + } + } + + // Run on first load + pluginManager.addPlugin(pluginObj, fileInfo.filename, registry) + pluginObj.functions = registry.getLocalFunctions() + + // Need to call setOptions again because the pluginObj might have functions + result = this.trySetOptions(pluginObj, filename, shortname, pluginOptions) + if (result) { + return result + } + + // Run every @plugin call + try { + if (pluginObj.use) { + pluginObj.use.call(this.context, pluginObj) + } + } catch (e) { + e.message = e.message || 'Error during @plugin call' + return new LessError(e, imports, filename) + } + } else { + return new LessError({ message: 'Not a valid plugin' }, imports, filename) + } + + return pluginObj + } + + trySetOptions(plugin, filename, name, options) { + if (options && !plugin.setOptions) { + return new LessError({ + message: `Options have been provided but the plugin ${name} does not support any options.` + }) + } + try { + plugin.setOptions && plugin.setOptions(options) + } catch (e) { + return new LessError(e) + } + } + + validatePlugin(plugin, filename, name) { + if (plugin) { + // support plugins being a function + // so that the plugin can be more usable programmatically + if (typeof plugin === 'function') { + plugin = new plugin() + } + + if (plugin.minVersion) { + if (this.compareVersion(plugin.minVersion, this.less.version) < 0) { + return new LessError({ + message: `Plugin ${name} requires version ${this.versionToString( + plugin.minVersion + )}` + }) + } + } + return plugin + } + return null + } + + compareVersion(aVersion, bVersion) { + if (typeof aVersion === 'string') { + aVersion = aVersion.match(/^(\d+)\.?(\d+)?\.?(\d+)?/) + aVersion.shift() + } + for (let i = 0; i < aVersion.length; i++) { + if (aVersion[i] !== bVersion[i]) { + return parseInt(aVersion[i]) > parseInt(bVersion[i]) ? -1 : 1 + } + } + return 0 + } + + versionToString(version) { + let versionString = '' + for (let i = 0; i < version.length; i++) { + versionString += (versionString ? '.' : '') + version[i] + } + return versionString + } + + printUsage(plugins) { + for (let i = 0; i < plugins.length; i++) { + const plugin = plugins[i] + if (plugin.printUsage) { + plugin.printUsage() + } + } + } +} + +export default AbstractPluginLoader diff --git a/src/less/environment/environment-api.ts b/src/less/environment/environment-api.ts new file mode 100644 index 0000000..f3725a4 --- /dev/null +++ b/src/less/environment/environment-api.ts @@ -0,0 +1,21 @@ +export interface Environment { + /** + * Converts a string to a base 64 string + */ + encodeBase64(str: string): string + /** + * Lookup the mime-type of a filename + */ + mimeLookup(filename: string): string + /** + * Look up the charset of a mime type + * @param mime + */ + charsetLookup(mime: string): string + /** + * Gets a source map generator + * + * @todo - Figure out precise type + */ + getSourceMapGenerator(): any +} diff --git a/src/less/environment/environment.js b/src/less/environment/environment.js new file mode 100644 index 0000000..a59f67a --- /dev/null +++ b/src/less/environment/environment.js @@ -0,0 +1,76 @@ +/** + * @todo Document why this abstraction exists, and the relationship between + * environment, file managers, and plugin manager + */ + +import logger from '../logger' + +class Environment { + constructor(externalEnvironment, fileManagers) { + this.fileManagers = fileManagers || [] + externalEnvironment = externalEnvironment || {} + + const optionalFunctions = [ + 'encodeBase64', + 'mimeLookup', + 'charsetLookup', + 'getSourceMapGenerator' + ] + const requiredFunctions = [] + const functions = requiredFunctions.concat(optionalFunctions) + + for (let i = 0; i < functions.length; i++) { + const propName = functions[i] + const environmentFunc = externalEnvironment[propName] + if (environmentFunc) { + this[propName] = environmentFunc.bind(externalEnvironment) + } else if (i < requiredFunctions.length) { + this.warn(`missing required function in environment - ${propName}`) + } + } + } + + getFileManager(filename, currentDirectory, options, environment, isSync) { + if (!filename) { + logger.warn( + 'getFileManager called with no filename.. Please report this issue. continuing.' + ) + } + if (currentDirectory === undefined) { + logger.warn( + 'getFileManager called with null directory.. Please report this issue. continuing.' + ) + } + + let fileManagers = this.fileManagers + if (options.pluginManager) { + fileManagers = [] + .concat(fileManagers) + .concat(options.pluginManager.getFileManagers()) + } + for (let i = fileManagers.length - 1; i >= 0; i--) { + const fileManager = fileManagers[i] + if ( + fileManager[isSync ? 'supportsSync' : 'supports']( + filename, + currentDirectory, + options, + environment + ) + ) { + return fileManager + } + } + return null + } + + addFileManager(fileManager) { + this.fileManagers.push(fileManager) + } + + clearFileManagers() { + this.fileManagers = [] + } +} + +export default Environment diff --git a/src/less/environment/file-manager-api.ts b/src/less/environment/file-manager-api.ts new file mode 100644 index 0000000..47db48a --- /dev/null +++ b/src/less/environment/file-manager-api.ts @@ -0,0 +1,77 @@ +import type { Environment } from './environment-api' + +export interface FileManager { + /** + * Given the full path to a file, return the path component + * Provided by AbstractFileManager + */ + getPath(filename: string): string + /** + * Append a .less extension if appropriate. Only called if less thinks one could be added. + * Provided by AbstractFileManager + */ + tryAppendLessExtension(filename: string): string + /** + * Whether the rootpath should be converted to be absolute. + * The browser ovverides this to return true because urls must be absolute. + * Provided by AbstractFileManager (returns false) + */ + alwaysMakePathsAbsolute(): boolean + /** + * Returns whether a path is absolute + * Provided by AbstractFileManager + */ + isPathAbsolute(path: string): boolean + /** + * joins together 2 paths + * Provided by AbstractFileManager + */ + join(basePath: string, laterPath: string): string + /** + * Returns the difference between 2 paths + * E.g. url = a/ baseUrl = a/b/ returns ../ + * url = a/b/ baseUrl = a/ returns b/ + * Provided by AbstractFileManager + */ + pathDiff(url: string, baseUrl: string): string + /** + * Returns whether this file manager supports this file for syncronous file retrieval + * If true is returned, loadFileSync will then be called with the file. + * Provided by AbstractFileManager (returns false) + * + * @todo - Narrow Options type + */ + supportsSync( + filename: string, + currentDirectory: string, + options: Record, + environment: Environment + ): boolean + /** + * If file manager supports async file retrieval for this file type + */ + supports( + filename: string, + currentDirectory: string, + options: Record, + environment: Environment + ): boolean + /** + * Loads a file asynchronously. + */ + loadFile( + filename: string, + currentDirectory: string, + options: Record, + environment: Environment + ): Promise<{ filename: string, contents: string }> + /** + * Loads a file synchronously. Expects an immediate return with an object + */ + loadFileSync( + filename: string, + currentDirectory: string, + options: Record, + environment: Environment + ): { error?: unknown, filename: string, contents: string } +} diff --git a/src/less/functions/boolean.js b/src/less/functions/boolean.js new file mode 100644 index 0000000..98b4362 --- /dev/null +++ b/src/less/functions/boolean.js @@ -0,0 +1,32 @@ +import Anonymous from '../tree/anonymous' +import Keyword from '../tree/keyword' + +function boolean(condition) { + return condition ? Keyword.True : Keyword.False +} + +/** + * Functions with evalArgs set to false are sent context + * as the first argument. + */ +function If(context, condition, trueValue, falseValue) { + return condition.eval(context) + ? trueValue.eval(context) + : falseValue + ? falseValue.eval(context) + : new Anonymous() +} +If.evalArgs = false + +function isdefined(context, variable) { + try { + variable.eval(context) + return Keyword.True + } catch (e) { + return Keyword.False + } +} + +isdefined.evalArgs = false + +export default { isdefined, boolean, if: If } diff --git a/src/less/functions/color-blending.js b/src/less/functions/color-blending.js new file mode 100644 index 0000000..d6fb31b --- /dev/null +++ b/src/less/functions/color-blending.js @@ -0,0 +1,83 @@ +import Color from '../tree/color' + +// Color Blending +// ref: http://www.w3.org/TR/compositing-1 + +function colorBlend(mode, color1, color2) { + const ab = color1.alpha // result + + let // backdrop + cb + + const as = color2.alpha + + let // source + cs + + let ar + let cr + const r = [] + + ar = as + ab * (1 - as) + for (let i = 0; i < 3; i++) { + cb = color1.rgb[i] / 255 + cs = color2.rgb[i] / 255 + cr = mode(cb, cs) + if (ar) { + cr = (as * cs + ab * (cb - as * (cb + cs - cr))) / ar + } + r[i] = cr * 255 + } + + return new Color(r, ar) +} + +const colorBlendModeFunctions = { + multiply: function (cb, cs) { + return cb * cs + }, + screen: function (cb, cs) { + return cb + cs - cb * cs + }, + overlay: function (cb, cs) { + cb *= 2 + return cb <= 1 + ? colorBlendModeFunctions.multiply(cb, cs) + : colorBlendModeFunctions.screen(cb - 1, cs) + }, + softlight: function (cb, cs) { + let d = 1 + let e = cb + if (cs > 0.5) { + e = 1 + d = cb > 0.25 ? Math.sqrt(cb) : ((16 * cb - 12) * cb + 4) * cb + } + return cb - (1 - 2 * cs) * e * (d - cb) + }, + hardlight: function (cb, cs) { + return colorBlendModeFunctions.overlay(cs, cb) + }, + difference: function (cb, cs) { + return Math.abs(cb - cs) + }, + exclusion: function (cb, cs) { + return cb + cs - 2 * cb * cs + }, + + // non-w3c functions: + average: function (cb, cs) { + return (cb + cs) / 2 + }, + negation: function (cb, cs) { + return 1 - Math.abs(cb + cs - 1) + } +} + +for (const f in colorBlendModeFunctions) { + // eslint-disable-next-line no-prototype-builtins + if (colorBlendModeFunctions.hasOwnProperty(f)) { + colorBlend[f] = colorBlend.bind(null, colorBlendModeFunctions[f]) + } +} + +export default colorBlend diff --git a/src/less/functions/color.js b/src/less/functions/color.js new file mode 100644 index 0000000..70ac545 --- /dev/null +++ b/src/less/functions/color.js @@ -0,0 +1,445 @@ +import Dimension from '../tree/dimension' +import Color from '../tree/color' +import Quoted from '../tree/quoted' +import Anonymous from '../tree/anonymous' +import Expression from '../tree/expression' +import Operation from '../tree/operation' +let colorFunctions + +function clamp(val) { + return Math.min(1, Math.max(0, val)) +} +function hsla(origColor, hsl) { + const color = colorFunctions.hsla(hsl.h, hsl.s, hsl.l, hsl.a) + if (color) { + if (origColor.value && /^(rgb|hsl)/.test(origColor.value)) { + color.value = origColor.value + } else { + color.value = 'rgb' + } + return color + } +} +function toHSL(color) { + if (color.toHSL) { + return color.toHSL() + } else { + throw new Error('Argument cannot be evaluated to a color') + } +} + +function toHSV(color) { + if (color.toHSV) { + return color.toHSV() + } else { + throw new Error('Argument cannot be evaluated to a color') + } +} + +function number(n) { + if (n instanceof Dimension) { + return parseFloat(n.unit.is('%') ? n.value / 100 : n.value) + } else if (typeof n === 'number') { + return n + } else { + throw { + type: 'Argument', + message: 'color functions take numbers as parameters' + } + } +} +function scaled(n, size) { + if (n instanceof Dimension && n.unit.is('%')) { + return parseFloat((n.value * size) / 100) + } else { + return number(n) + } +} +colorFunctions = { + rgb: function (r, g, b) { + let a = 1 + /** + * Comma-less syntax + * e.g. rgb(0 128 255 / 50%) + */ + if (r instanceof Expression) { + const val = r.value + r = val[0] + g = val[1] + b = val[2] + /** + * @todo - should this be normalized in + * function caller? Or parsed differently? + */ + if (b instanceof Operation) { + const op = b + b = op.operands[0] + a = op.operands[1] + } + } + const color = colorFunctions.rgba(r, g, b, a) + if (color) { + color.value = 'rgb' + return color + } + }, + rgba: function (r, g, b, a) { + try { + if (r instanceof Color) { + if (g) { + a = number(g) + } else { + a = r.alpha + } + return new Color(r.rgb, a, 'rgba') + } + const rgb = [r, g, b].map(c => scaled(c, 255)) + a = number(a) + return new Color(rgb, a, 'rgba') + } catch (e) {} + }, + hsl: function (h, s, l) { + let a = 1 + if (h instanceof Expression) { + const val = h.value + h = val[0] + s = val[1] + l = val[2] + + if (l instanceof Operation) { + const op = l + l = op.operands[0] + a = op.operands[1] + } + } + const color = colorFunctions.hsla(h, s, l, a) + if (color) { + color.value = 'hsl' + return color + } + }, + hsla: function (h, s, l, a) { + let m1 + let m2 + + function hue(h) { + h = h < 0 ? h + 1 : h > 1 ? h - 1 : h + if (h * 6 < 1) { + return m1 + (m2 - m1) * h * 6 + } else if (h * 2 < 1) { + return m2 + } else if (h * 3 < 2) { + return m1 + (m2 - m1) * (2 / 3 - h) * 6 + } else { + return m1 + } + } + + try { + if (h instanceof Color) { + if (s) { + a = number(s) + } else { + a = h.alpha + } + return new Color(h.rgb, a, 'hsla') + } + + h = (number(h) % 360) / 360 + s = clamp(number(s)) + l = clamp(number(l)) + a = clamp(number(a)) + + m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s + m1 = l * 2 - m2 + + const rgb = [hue(h + 1 / 3) * 255, hue(h) * 255, hue(h - 1 / 3) * 255] + a = number(a) + return new Color(rgb, a, 'hsla') + } catch (e) {} + }, + + hsv: function (h, s, v) { + return colorFunctions.hsva(h, s, v, 1.0) + }, + + hsva: function (h, s, v, a) { + h = ((number(h) % 360) / 360) * 360 + s = number(s) + v = number(v) + a = number(a) + + let i + let f + i = Math.floor((h / 60) % 6) + f = h / 60 - i + + const vs = [v, v * (1 - s), v * (1 - f * s), v * (1 - (1 - f) * s)] + const perm = [ + [0, 3, 1], + [2, 0, 1], + [1, 0, 3], + [1, 2, 0], + [3, 1, 0], + [0, 1, 2] + ] + + return colorFunctions.rgba( + vs[perm[i][0]] * 255, + vs[perm[i][1]] * 255, + vs[perm[i][2]] * 255, + a + ) + }, + + hue: function (color) { + return new Dimension(toHSL(color).h) + }, + saturation: function (color) { + return new Dimension(toHSL(color).s * 100, '%') + }, + lightness: function (color) { + return new Dimension(toHSL(color).l * 100, '%') + }, + hsvhue: function (color) { + return new Dimension(toHSV(color).h) + }, + hsvsaturation: function (color) { + return new Dimension(toHSV(color).s * 100, '%') + }, + hsvvalue: function (color) { + return new Dimension(toHSV(color).v * 100, '%') + }, + red: function (color) { + return new Dimension(color.rgb[0]) + }, + green: function (color) { + return new Dimension(color.rgb[1]) + }, + blue: function (color) { + return new Dimension(color.rgb[2]) + }, + alpha: function (color) { + return new Dimension(toHSL(color).a) + }, + luma: function (color) { + return new Dimension(color.luma() * color.alpha * 100, '%') + }, + luminance: function (color) { + const luminance = + (0.2126 * color.rgb[0]) / 255 + + (0.7152 * color.rgb[1]) / 255 + + (0.0722 * color.rgb[2]) / 255 + + return new Dimension(luminance * color.alpha * 100, '%') + }, + saturate: function (color, amount, method) { + // filter: saturate(3.2); + // should be kept as is, so check for color + if (!color.rgb) { + return null + } + const hsl = toHSL(color) + + if (typeof method !== 'undefined' && method.value === 'relative') { + hsl.s += (hsl.s * amount.value) / 100 + } else { + hsl.s += amount.value / 100 + } + hsl.s = clamp(hsl.s) + return hsla(color, hsl) + }, + desaturate: function (color, amount, method) { + const hsl = toHSL(color) + + if (typeof method !== 'undefined' && method.value === 'relative') { + hsl.s -= (hsl.s * amount.value) / 100 + } else { + hsl.s -= amount.value / 100 + } + hsl.s = clamp(hsl.s) + return hsla(color, hsl) + }, + lighten: function (color, amount, method) { + const hsl = toHSL(color) + + if (typeof method !== 'undefined' && method.value === 'relative') { + hsl.l += (hsl.l * amount.value) / 100 + } else { + hsl.l += amount.value / 100 + } + hsl.l = clamp(hsl.l) + return hsla(color, hsl) + }, + darken: function (color, amount, method) { + const hsl = toHSL(color) + + if (typeof method !== 'undefined' && method.value === 'relative') { + hsl.l -= (hsl.l * amount.value) / 100 + } else { + hsl.l -= amount.value / 100 + } + hsl.l = clamp(hsl.l) + return hsla(color, hsl) + }, + fadein: function (color, amount, method) { + const hsl = toHSL(color) + + if (typeof method !== 'undefined' && method.value === 'relative') { + hsl.a += (hsl.a * amount.value) / 100 + } else { + hsl.a += amount.value / 100 + } + hsl.a = clamp(hsl.a) + return hsla(color, hsl) + }, + fadeout: function (color, amount, method) { + const hsl = toHSL(color) + + if (typeof method !== 'undefined' && method.value === 'relative') { + hsl.a -= (hsl.a * amount.value) / 100 + } else { + hsl.a -= amount.value / 100 + } + hsl.a = clamp(hsl.a) + return hsla(color, hsl) + }, + fade: function (color, amount) { + const hsl = toHSL(color) + + hsl.a = amount.value / 100 + hsl.a = clamp(hsl.a) + return hsla(color, hsl) + }, + spin: function (color, amount) { + const hsl = toHSL(color) + const hue = (hsl.h + amount.value) % 360 + + hsl.h = hue < 0 ? 360 + hue : hue + + return hsla(color, hsl) + }, + // + // Copyright (c) 2006-2009 Hampton Catlin, Natalie Weizenbaum, and Chris Eppstein + // http://sass-lang.com + // + mix: function (color1, color2, weight) { + if (!weight) { + weight = new Dimension(50) + } + const p = weight.value / 100.0 + const w = p * 2 - 1 + const a = toHSL(color1).a - toHSL(color2).a + + const w1 = ((w * a == -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0 + const w2 = 1 - w1 + + const rgb = [ + color1.rgb[0] * w1 + color2.rgb[0] * w2, + color1.rgb[1] * w1 + color2.rgb[1] * w2, + color1.rgb[2] * w1 + color2.rgb[2] * w2 + ] + + const alpha = color1.alpha * p + color2.alpha * (1 - p) + + return new Color(rgb, alpha) + }, + greyscale: function (color) { + return colorFunctions.desaturate(color, new Dimension(100)) + }, + contrast: function (color, dark, light, threshold) { + // filter: contrast(3.2); + // should be kept as is, so check for color + if (!color.rgb) { + return null + } + if (typeof light === 'undefined') { + light = colorFunctions.rgba(255, 255, 255, 1.0) + } + if (typeof dark === 'undefined') { + dark = colorFunctions.rgba(0, 0, 0, 1.0) + } + // Figure out which is actually light and dark: + if (dark.luma() > light.luma()) { + const t = light + light = dark + dark = t + } + if (typeof threshold === 'undefined') { + threshold = 0.43 + } else { + threshold = number(threshold) + } + if (color.luma() < threshold) { + return light + } else { + return dark + } + }, + // Changes made in 2.7.0 - Reverted in 3.0.0 + // contrast: function (color, color1, color2, threshold) { + // // Return which of `color1` and `color2` has the greatest contrast with `color` + // // according to the standard WCAG contrast ratio calculation. + // // http://www.w3.org/TR/WCAG20/#contrast-ratiodef + // // The threshold param is no longer used, in line with SASS. + // // filter: contrast(3.2); + // // should be kept as is, so check for color + // if (!color.rgb) { + // return null; + // } + // if (typeof color1 === 'undefined') { + // color1 = colorFunctions.rgba(0, 0, 0, 1.0); + // } + // if (typeof color2 === 'undefined') { + // color2 = colorFunctions.rgba(255, 255, 255, 1.0); + // } + // var contrast1, contrast2; + // var luma = color.luma(); + // var luma1 = color1.luma(); + // var luma2 = color2.luma(); + // // Calculate contrast ratios for each color + // if (luma > luma1) { + // contrast1 = (luma + 0.05) / (luma1 + 0.05); + // } else { + // contrast1 = (luma1 + 0.05) / (luma + 0.05); + // } + // if (luma > luma2) { + // contrast2 = (luma + 0.05) / (luma2 + 0.05); + // } else { + // contrast2 = (luma2 + 0.05) / (luma + 0.05); + // } + // if (contrast1 > contrast2) { + // return color1; + // } else { + // return color2; + // } + // }, + argb: function (color) { + return new Anonymous(color.toARGB()) + }, + color: function (c) { + if ( + c instanceof Quoted && + /^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3,4})$/i.test(c.value) + ) { + const val = c.value.slice(1) + return new Color(val, undefined, `#${val}`) + } + if (c instanceof Color || (c = Color.fromKeyword(c.value))) { + c.value = undefined + return c + } + throw { + type: 'Argument', + message: 'argument must be a color keyword or 3|4|6|8 digit hex e.g. #FFF' + } + }, + tint: function (color, amount) { + return colorFunctions.mix(colorFunctions.rgb(255, 255, 255), color, amount) + }, + shade: function (color, amount) { + return colorFunctions.mix(colorFunctions.rgb(0, 0, 0), color, amount) + } +} + +export default colorFunctions diff --git a/src/less/functions/data-uri.js b/src/less/functions/data-uri.js new file mode 100644 index 0000000..5c18de0 --- /dev/null +++ b/src/less/functions/data-uri.js @@ -0,0 +1,95 @@ +import Quoted from '../tree/quoted' +import URL from '../tree/url' +import * as utils from '../utils' +import logger from '../logger' + +export default environment => { + const fallback = (functionThis, node) => + new URL(node, functionThis.index, functionThis.currentFileInfo).eval( + functionThis.context + ) + + return { + 'data-uri': function (mimetypeNode, filePathNode) { + if (!filePathNode) { + filePathNode = mimetypeNode + mimetypeNode = null + } + + let mimetype = mimetypeNode && mimetypeNode.value + let filePath = filePathNode.value + const currentFileInfo = this.currentFileInfo + const currentDirectory = currentFileInfo.rewriteUrls + ? currentFileInfo.currentDirectory + : currentFileInfo.entryPath + + const fragmentStart = filePath.indexOf('#') + let fragment = '' + if (fragmentStart !== -1) { + fragment = filePath.slice(fragmentStart) + filePath = filePath.slice(0, fragmentStart) + } + const context = utils.clone(this.context) + context.rawBuffer = true + + const fileManager = environment.getFileManager( + filePath, + currentDirectory, + context, + environment, + true + ) + + if (!fileManager) { + return fallback(this, filePathNode) + } + + let useBase64 = false + + // detect the mimetype if not given + if (!mimetypeNode) { + mimetype = environment.mimeLookup(filePath) + + if (mimetype === 'image/svg+xml') { + useBase64 = false + } else { + // use base 64 unless it's an ASCII or UTF-8 format + const charset = environment.charsetLookup(mimetype) + useBase64 = ['US-ASCII', 'UTF-8'].indexOf(charset) < 0 + } + if (useBase64) { + mimetype += ';base64' + } + } else { + useBase64 = /;base64$/.test(mimetype) + } + + const fileSync = fileManager.loadFileSync( + filePath, + currentDirectory, + context, + environment + ) + if (!fileSync.contents) { + logger.warn( + `Skipped data-uri embedding of ${filePath} because file not found` + ) + return fallback(this, filePathNode || mimetypeNode) + } + let buf = fileSync.contents + if (useBase64 && !environment.encodeBase64) { + return fallback(this, filePathNode) + } + + buf = useBase64 ? environment.encodeBase64(buf) : encodeURIComponent(buf) + + const uri = `data:${mimetype},${buf}${fragment}` + + return new URL( + new Quoted(`"${uri}"`, uri, false, this.index, this.currentFileInfo), + this.index, + this.currentFileInfo + ) + } + } +} diff --git a/src/less/functions/default.js b/src/less/functions/default.js new file mode 100644 index 0000000..a2792c4 --- /dev/null +++ b/src/less/functions/default.js @@ -0,0 +1,26 @@ +import Keyword from '../tree/keyword' +import * as utils from '../utils' + +const defaultFunc = { + eval: function () { + const v = this.value_ + const e = this.error_ + if (e) { + throw e + } + if (!utils.isNullOrUndefined(v)) { + return v ? Keyword.True : Keyword.False + } + }, + value: function (v) { + this.value_ = v + }, + error: function (e) { + this.error_ = e + }, + reset: function () { + this.value_ = this.error_ = null + } +} + +export default defaultFunc diff --git a/src/less/functions/function-caller.js b/src/less/functions/function-caller.js new file mode 100644 index 0000000..9b58d31 --- /dev/null +++ b/src/less/functions/function-caller.js @@ -0,0 +1,53 @@ +import Expression from '../tree/expression' + +class functionCaller { + constructor(name, context, index, currentFileInfo) { + this.name = name.toLowerCase() + this.index = index + this.context = context + this.currentFileInfo = currentFileInfo + + this.func = context.frames[0].functionRegistry.get(this.name) + } + + isValid() { + return Boolean(this.func) + } + + call(args) { + if (!Array.isArray(args)) { + args = [args] + } + const evalArgs = this.func.evalArgs + if (evalArgs !== false) { + args = args.map(a => a.eval(this.context)) + } + const commentFilter = item => !(item.type === 'Comment') + + // This code is terrible and should be replaced as per this issue... + // https://github.com/less/less.js/issues/2477 + args = args.filter(commentFilter).map(item => { + if (item.type === 'Expression') { + const subNodes = item.value.filter(commentFilter) + if (subNodes.length === 1) { + // https://github.com/less/less.js/issues/3616 + if (item.parens && subNodes[0].op === '/') { + return item + } + return subNodes[0] + } else { + return new Expression(subNodes) + } + } + return item + }) + + if (evalArgs === false) { + return this.func(this.context, ...args) + } + + return this.func(...args) + } +} + +export default functionCaller diff --git a/src/less/functions/function-registry.js b/src/less/functions/function-registry.js new file mode 100644 index 0000000..4dcfd5d --- /dev/null +++ b/src/less/functions/function-registry.js @@ -0,0 +1,35 @@ +function makeRegistry(base) { + return { + _data: {}, + add: function (name, func) { + // precautionary case conversion, as later querying of + // the registry by function-caller uses lower case as well. + name = name.toLowerCase() + + // eslint-disable-next-line no-prototype-builtins + if (this._data.hasOwnProperty(name)) { + // TODO warn + } + this._data[name] = func + }, + addMultiple: function (functions) { + Object.keys(functions).forEach(name => { + this.add(name, functions[name]) + }) + }, + get: function (name) { + return this._data[name] || (base && base.get(name)) + }, + getLocalFunctions: function () { + return this._data + }, + inherit: function () { + return makeRegistry(this) + }, + create: function (base) { + return makeRegistry(base) + } + } +} + +export default makeRegistry(null) diff --git a/src/less/functions/index.js b/src/less/functions/index.js new file mode 100644 index 0000000..935f1e9 --- /dev/null +++ b/src/less/functions/index.js @@ -0,0 +1,33 @@ +import functionRegistry from './function-registry' +import functionCaller from './function-caller' + +import boolean from './boolean' +import defaultFunc from './default' +import color from './color' +import colorBlending from './color-blending' +import dataUri from './data-uri' +import list from './list' +import math from './math' +import number from './number' +import string from './string' +import svg from './svg' +import types from './types' + +export default environment => { + const functions = { functionRegistry, functionCaller } + + // register functions + functionRegistry.addMultiple(boolean) + functionRegistry.add('default', defaultFunc.eval.bind(defaultFunc)) + functionRegistry.addMultiple(color) + functionRegistry.addMultiple(colorBlending) + functionRegistry.addMultiple(dataUri(environment)) + functionRegistry.addMultiple(list) + functionRegistry.addMultiple(math) + functionRegistry.addMultiple(number) + functionRegistry.addMultiple(string) + functionRegistry.addMultiple(svg(environment)) + functionRegistry.addMultiple(types) + + return functions +} diff --git a/src/less/functions/list.js b/src/less/functions/list.js new file mode 100644 index 0000000..efb86cb --- /dev/null +++ b/src/less/functions/list.js @@ -0,0 +1,181 @@ +import Comment from '../tree/comment' +import Node from '../tree/node' +import Dimension from '../tree/dimension' +import Declaration from '../tree/declaration' +import Expression from '../tree/expression' +import Ruleset from '../tree/ruleset' +import Selector from '../tree/selector' +import Element from '../tree/element' +import Quote from '../tree/quoted' +import Value from '../tree/value' + +const getItemsFromNode = node => { + // handle non-array values as an array of length 1 + // return 'undefined' if index is invalid + const items = Array.isArray(node.value) ? node.value : Array(node) + + return items +} + +export default { + _SELF: function (n) { + return n + }, + '~': function (...expr) { + if (expr.length === 1) { + return expr[0] + } + return new Value(expr) + }, + extract: function (values, index) { + // (1-based index) + index = index.value - 1 + + return getItemsFromNode(values)[index] + }, + length: function (values) { + return new Dimension(getItemsFromNode(values).length) + }, + /** + * Creates a Less list of incremental values. + * Modeled after Lodash's range function, also exists natively in PHP + * + * @param {Dimension} [start=1] + * @param {Dimension} end - e.g. 10 or 10px - unit is added to output + * @param {Dimension} [step=1] + */ + range: function (start, end, step) { + let from + let to + let stepValue = 1 + const list = [] + if (end) { + to = end + from = start.value + if (step) { + stepValue = step.value + } + } else { + from = 1 + to = start + } + + for (let i = from; i <= to.value; i += stepValue) { + list.push(new Dimension(i, to.unit)) + } + + return new Expression(list) + }, + each: function (list, rs) { + const rules = [] + let newRules + let iterator + + const tryEval = val => { + if (val instanceof Node) { + return val.eval(this.context) + } + return val + } + + if (list.value && !(list instanceof Quote)) { + if (Array.isArray(list.value)) { + iterator = list.value.map(tryEval) + } else { + iterator = [tryEval(list.value)] + } + } else if (list.ruleset) { + iterator = tryEval(list.ruleset).rules + } else if (list.rules) { + iterator = list.rules.map(tryEval) + } else if (Array.isArray(list)) { + iterator = list.map(tryEval) + } else { + iterator = [tryEval(list)] + } + + let valueName = '@value' + let keyName = '@key' + let indexName = '@index' + + if (rs.params) { + valueName = rs.params[0] && rs.params[0].name + keyName = rs.params[1] && rs.params[1].name + indexName = rs.params[2] && rs.params[2].name + rs = rs.rules + } else { + rs = rs.ruleset + } + + for (let i = 0; i < iterator.length; i++) { + let key + let value + const item = iterator[i] + if (item instanceof Declaration) { + key = typeof item.name === 'string' ? item.name : item.name[0].value + value = item.value + } else { + key = new Dimension(i + 1) + value = item + } + + if (item instanceof Comment) { + continue + } + + newRules = rs.rules.slice(0) + if (valueName) { + newRules.push( + new Declaration( + valueName, + value, + false, + false, + this.index, + this.currentFileInfo + ) + ) + } + if (indexName) { + newRules.push( + new Declaration( + indexName, + new Dimension(i + 1), + false, + false, + this.index, + this.currentFileInfo + ) + ) + } + if (keyName) { + newRules.push( + new Declaration( + keyName, + key, + false, + false, + this.index, + this.currentFileInfo + ) + ) + } + + rules.push( + new Ruleset( + [new Selector([new Element('', '&')])], + newRules, + rs.strictImports, + rs.visibilityInfo() + ) + ) + } + + return new Ruleset( + [new Selector([new Element('', '&')])], + rules, + rs.strictImports, + rs.visibilityInfo() + ).eval(this.context) + } +} diff --git a/src/less/functions/math-helper.js b/src/less/functions/math-helper.js new file mode 100644 index 0000000..cdf63c8 --- /dev/null +++ b/src/less/functions/math-helper.js @@ -0,0 +1,15 @@ +import Dimension from '../tree/dimension' + +const MathHelper = (fn, unit, n) => { + if (!(n instanceof Dimension)) { + throw { type: 'Argument', message: 'argument must be a number' } + } + if (unit === null) { + unit = n.unit + } else { + n = n.unify() + } + return new Dimension(fn(parseFloat(n.value)), unit) +} + +export default MathHelper diff --git a/src/less/functions/math.js b/src/less/functions/math.js new file mode 100644 index 0000000..40ab030 --- /dev/null +++ b/src/less/functions/math.js @@ -0,0 +1,29 @@ +import mathHelper from './math-helper.js' + +const mathFunctions = { + // name, unit + ceil: null, + floor: null, + sqrt: null, + abs: null, + tan: '', + sin: '', + cos: '', + atan: 'rad', + asin: 'rad', + acos: 'rad' +} + +for (const f in mathFunctions) { + // eslint-disable-next-line no-prototype-builtins + if (mathFunctions.hasOwnProperty(f)) { + mathFunctions[f] = mathHelper.bind(null, Math[f], mathFunctions[f]) + } +} + +mathFunctions.round = (n, f) => { + const fraction = typeof f === 'undefined' ? 0 : f.value + return mathHelper(num => num.toFixed(fraction), null, n) +} + +export default mathFunctions diff --git a/src/less/functions/number.js b/src/less/functions/number.js new file mode 100644 index 0000000..bed987a --- /dev/null +++ b/src/less/functions/number.js @@ -0,0 +1,122 @@ +import Dimension from '../tree/dimension' +import Anonymous from '../tree/anonymous' +import mathHelper from './math-helper.js' + +const minMax = function (isMin, args) { + args = Array.prototype.slice.call(args) + switch (args.length) { + case 0: + throw { type: 'Argument', message: 'one or more arguments required' } + } + let i // key is the unit.toString() for unified Dimension values, + let j + let current + let currentUnified + let referenceUnified + let unit + let unitStatic + let unitClone + + const // elems only contains original argument values. + order = [] + + const values = {} + // value is the index into the order array. + for (i = 0; i < args.length; i++) { + current = args[i] + if (!(current instanceof Dimension)) { + if (Array.isArray(args[i].value)) { + Array.prototype.push.apply( + args, + Array.prototype.slice.call(args[i].value) + ) + } + continue + } + currentUnified = + current.unit.toString() === '' && unitClone !== undefined + ? new Dimension(current.value, unitClone).unify() + : current.unify() + unit = + currentUnified.unit.toString() === '' && unitStatic !== undefined + ? unitStatic + : currentUnified.unit.toString() + unitStatic = + (unit !== '' && unitStatic === undefined) || + (unit !== '' && order[0].unify().unit.toString() === '') + ? unit + : unitStatic + unitClone = + unit !== '' && unitClone === undefined + ? current.unit.toString() + : unitClone + j = + values[''] !== undefined && unit !== '' && unit === unitStatic + ? values[''] + : values[unit] + if (j === undefined) { + if (unitStatic !== undefined && unit !== unitStatic) { + throw { type: 'Argument', message: 'incompatible types' } + } + values[unit] = order.length + order.push(current) + continue + } + referenceUnified = + order[j].unit.toString() === '' && unitClone !== undefined + ? new Dimension(order[j].value, unitClone).unify() + : order[j].unify() + if ( + (isMin && currentUnified.value < referenceUnified.value) || + (!isMin && currentUnified.value > referenceUnified.value) + ) { + order[j] = current + } + } + if (order.length == 1) { + return order[0] + } + args = order + .map(a => { + return a.toCSS(this.context) + }) + .join(this.context.compress ? ',' : ', ') + return new Anonymous(`${isMin ? 'min' : 'max'}(${args})`) +} + +export default { + min: function (...args) { + try { + return minMax.call(this, true, args) + } catch (e) {} + }, + max: function (...args) { + try { + return minMax.call(this, false, args) + } catch (e) {} + }, + convert: function (val, unit) { + return val.convertTo(unit.value) + }, + pi: function () { + return new Dimension(Math.PI) + }, + mod: function (a, b) { + return new Dimension(a.value % b.value, a.unit) + }, + pow: function (x, y) { + if (typeof x === 'number' && typeof y === 'number') { + x = new Dimension(x) + y = new Dimension(y) + } else if (!(x instanceof Dimension) || !(y instanceof Dimension)) { + throw { type: 'Argument', message: 'arguments must be numbers' } + } + + return new Dimension(Math.pow(x.value, y.value), x.unit) + }, + percentage: function (n) { + const result = mathHelper(num => num * 100, '%', n) + + return result + } +} diff --git a/src/less/functions/string.js b/src/less/functions/string.js new file mode 100644 index 0000000..f089e3d --- /dev/null +++ b/src/less/functions/string.js @@ -0,0 +1,51 @@ +import Quoted from '../tree/quoted' +import Anonymous from '../tree/anonymous' +import JavaScript from '../tree/javascript' + +export default { + e: function (str) { + return new Quoted( + '"', + str instanceof JavaScript ? str.evaluated : str.value, + true + ) + }, + escape: function (str) { + return new Anonymous( + encodeURI(str.value) + .replace(/=/g, '%3D') + .replace(/:/g, '%3A') + .replace(/#/g, '%23') + .replace(/;/g, '%3B') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + ) + }, + replace: function (string, pattern, replacement, flags) { + let result = string.value + replacement = + replacement.type === 'Quoted' ? replacement.value : replacement.toCSS() + result = result.replace( + new RegExp(pattern.value, flags ? flags.value : ''), + replacement + ) + return new Quoted(string.quote || '', result, string.escaped) + }, + '%': function (string /* arg, arg, ... */) { + const args = Array.prototype.slice.call(arguments, 1) + let result = string.value + + for (let i = 0; i < args.length; i++) { + /* jshint loopfunc:true */ + result = result.replace(/%[sda]/i, token => { + const value = + args[i].type === 'Quoted' && token.match(/s/i) + ? args[i].value + : args[i].toCSS() + return token.match(/[A-Z]$/) ? encodeURIComponent(value) : value + }) + } + result = result.replace(/%%/g, '%') + return new Quoted(string.quote || '', result, string.escaped) + } +} diff --git a/src/less/functions/svg.js b/src/less/functions/svg.js new file mode 100644 index 0000000..c315011 --- /dev/null +++ b/src/less/functions/svg.js @@ -0,0 +1,116 @@ +import Dimension from '../tree/dimension' +import Color from '../tree/color' +import Expression from '../tree/expression' +import Quoted from '../tree/quoted' +import URL from '../tree/url' + +export default () => { + return { + 'svg-gradient': function (direction) { + let stops + let gradientDirectionSvg + let gradientType = 'linear' + let rectangleDimension = 'x="0" y="0" width="1" height="1"' + const renderEnv = { compress: false } + let returner + const directionValue = direction.toCSS(renderEnv) + let i + let color + let position + let positionValue + let alpha + + function throwArgumentDescriptor() { + throw { + type: 'Argument', + message: + 'svg-gradient expects direction, start_color [start_position], [color position,]...,' + + ' end_color [end_position] or direction, color list' + } + } + + if (arguments.length == 2) { + if (arguments[1].value.length < 2) { + throwArgumentDescriptor() + } + stops = arguments[1].value + } else if (arguments.length < 3) { + throwArgumentDescriptor() + } else { + stops = Array.prototype.slice.call(arguments, 1) + } + + switch (directionValue) { + case 'to bottom': + gradientDirectionSvg = 'x1="0%" y1="0%" x2="0%" y2="100%"' + break + case 'to right': + gradientDirectionSvg = 'x1="0%" y1="0%" x2="100%" y2="0%"' + break + case 'to bottom right': + gradientDirectionSvg = 'x1="0%" y1="0%" x2="100%" y2="100%"' + break + case 'to top right': + gradientDirectionSvg = 'x1="0%" y1="100%" x2="100%" y2="0%"' + break + case 'ellipse': + case 'ellipse at center': + gradientType = 'radial' + gradientDirectionSvg = 'cx="50%" cy="50%" r="75%"' + rectangleDimension = 'x="-50" y="-50" width="101" height="101"' + break + default: + throw { + type: 'Argument', + message: + "svg-gradient direction must be 'to bottom', 'to right'," + + " 'to bottom right', 'to top right' or 'ellipse at center'" + } + } + returner = `<${gradientType}Gradient id="g" ${gradientDirectionSvg}>` + + for (i = 0; i < stops.length; i += 1) { + if (stops[i] instanceof Expression) { + color = stops[i].value[0] + position = stops[i].value[1] + } else { + color = stops[i] + position = undefined + } + + if ( + !(color instanceof Color) || + (!((i === 0 || i + 1 === stops.length) && position === undefined) && + !(position instanceof Dimension)) + ) { + throwArgumentDescriptor() + } + positionValue = position + ? position.toCSS(renderEnv) + : i === 0 + ? '0%' + : '100%' + alpha = color.alpha + returner += `` + } + returner += `` + + returner = encodeURIComponent(returner) + + returner = `data:image/svg+xml,${returner}` + return new URL( + new Quoted( + `'${returner}'`, + returner, + false, + this.index, + this.currentFileInfo + ), + this.index, + this.currentFileInfo + ) + } + } +} diff --git a/src/less/functions/types.js b/src/less/functions/types.js new file mode 100644 index 0000000..7f2d624 --- /dev/null +++ b/src/less/functions/types.js @@ -0,0 +1,82 @@ +import Keyword from '../tree/keyword' +import DetachedRuleset from '../tree/detached-ruleset' +import Dimension from '../tree/dimension' +import Color from '../tree/color' +import Quoted from '../tree/quoted' +import Anonymous from '../tree/anonymous' +import URL from '../tree/url' +import Operation from '../tree/operation' + +const isa = (n, Type) => (n instanceof Type ? Keyword.True : Keyword.False) +const isunit = (n, unit) => { + if (unit === undefined) { + throw { + type: 'Argument', + message: 'missing the required second argument to isunit.' + } + } + unit = typeof unit.value === 'string' ? unit.value : unit + if (typeof unit !== 'string') { + throw { + type: 'Argument', + message: 'Second argument to isunit should be a unit or a string.' + } + } + return n instanceof Dimension && n.unit.is(unit) + ? Keyword.True + : Keyword.False +} + +export default { + isruleset: function (n) { + return isa(n, DetachedRuleset) + }, + iscolor: function (n) { + return isa(n, Color) + }, + isnumber: function (n) { + return isa(n, Dimension) + }, + isstring: function (n) { + return isa(n, Quoted) + }, + iskeyword: function (n) { + return isa(n, Keyword) + }, + isurl: function (n) { + return isa(n, URL) + }, + ispixel: function (n) { + return isunit(n, 'px') + }, + ispercentage: function (n) { + return isunit(n, '%') + }, + isem: function (n) { + return isunit(n, 'em') + }, + isunit, + unit: function (val, unit) { + if (!(val instanceof Dimension)) { + throw { + type: 'Argument', + message: `the first argument to unit must be a number${ + val instanceof Operation ? '. Have you forgotten parenthesis?' : '' + }` + } + } + if (unit) { + if (unit instanceof Keyword) { + unit = unit.value + } else { + unit = unit.toCSS() + } + } else { + unit = '' + } + return new Dimension(val.value, unit) + }, + 'get-unit': function (n) { + return new Anonymous(n.unit) + } +} diff --git a/src/less/import-manager.js b/src/less/import-manager.js new file mode 100644 index 0000000..482191b --- /dev/null +++ b/src/less/import-manager.js @@ -0,0 +1,235 @@ +import contexts from './contexts' +import Parser from './parser/parser' +import LessError from './less-error' +import * as utils from './utils' +import logger from './logger' + +export default function (environment) { + // FileInfo = { + // 'rewriteUrls' - option - whether to adjust URL's to be relative + // 'filename' - full resolved filename of current file + // 'rootpath' - path to append to normal URLs for this node + // 'currentDirectory' - path to the current file, absolute + // 'rootFilename' - filename of the base file + // 'entryPath' - absolute path to the entry file + // 'reference' - whether the file should not be output and only output parts that are referenced + + class ImportManager { + constructor(less, context, rootFileInfo) { + this.less = less + this.rootFilename = rootFileInfo.filename + this.paths = context.paths || [] // Search paths, when importing + this.contents = {} // map - filename to contents of all the files + this.contentsIgnoredChars = {} // map - filename to lines at the beginning of each file to ignore + this.mime = context.mime + this.error = null + this.context = context + // Deprecated? Unused outside of here, could be useful. + this.queue = [] // Files which haven't been imported yet + this.files = {} // Holds the imported parse trees. + } + + /** + * Add an import to be imported + * @param path - the raw path + * @param tryAppendExtension - whether to try appending a file extension (.less or .js if the path has no extension) + * @param currentFileInfo - the current file info (used for instance to work out relative paths) + * @param importOptions - import options + * @param callback - callback for when it is imported + */ + push(path, tryAppendExtension, currentFileInfo, importOptions, callback) { + const importManager = this, + pluginLoader = this.context.pluginManager.Loader + + this.queue.push(path) + + const fileParsedFunc = function (e, root, fullPath) { + importManager.queue.splice(importManager.queue.indexOf(path), 1) // Remove the path from the queue + + const importedEqualsRoot = fullPath === importManager.rootFilename + if (importOptions.optional && e) { + callback(null, { rules: [] }, false, null) + logger.info( + `The file ${fullPath} was skipped because it was not found and the import was marked optional.` + ) + } else { + // Inline imports aren't cached here. + // If we start to cache them, please make sure they won't conflict with non-inline imports of the + // same name as they used to do before this comment and the condition below have been added. + if (!importManager.files[fullPath] && !importOptions.inline) { + importManager.files[fullPath] = { root, options: importOptions } + } + if (e && !importManager.error) { + importManager.error = e + } + callback(e, root, importedEqualsRoot, fullPath) + } + } + + const newFileInfo = { + rewriteUrls: this.context.rewriteUrls, + entryPath: currentFileInfo.entryPath, + rootpath: currentFileInfo.rootpath, + rootFilename: currentFileInfo.rootFilename + } + + const fileManager = environment.getFileManager( + path, + currentFileInfo.currentDirectory, + this.context, + environment + ) + + if (!fileManager) { + fileParsedFunc({ message: `Could not find a file-manager for ${path}` }) + return + } + + const loadFileCallback = function (loadedFile) { + let plugin + const resolvedFilename = loadedFile.filename + const contents = loadedFile.contents.replace(/^\uFEFF/, '') + + // Pass on an updated rootpath if path of imported file is relative and file + // is in a (sub|sup) directory + // + // Examples: + // - If path of imported file is 'module/nav/nav.less' and rootpath is 'less/', + // then rootpath should become 'less/module/nav/' + // - If path of imported file is '../mixins.less' and rootpath is 'less/', + // then rootpath should become 'less/../' + newFileInfo.currentDirectory = fileManager.getPath(resolvedFilename) + if (newFileInfo.rewriteUrls) { + newFileInfo.rootpath = fileManager.join( + importManager.context.rootpath || '', + fileManager.pathDiff( + newFileInfo.currentDirectory, + newFileInfo.entryPath + ) + ) + + if ( + !fileManager.isPathAbsolute(newFileInfo.rootpath) && + fileManager.alwaysMakePathsAbsolute() + ) { + newFileInfo.rootpath = fileManager.join( + newFileInfo.entryPath, + newFileInfo.rootpath + ) + } + } + newFileInfo.filename = resolvedFilename + + const newEnv = new contexts.Parse(importManager.context) + + newEnv.processImports = false + importManager.contents[resolvedFilename] = contents + + if (currentFileInfo.reference || importOptions.reference) { + newFileInfo.reference = true + } + + if (importOptions.isPlugin) { + plugin = pluginLoader.evalPlugin( + contents, + newEnv, + importManager, + importOptions.pluginArgs, + newFileInfo + ) + if (plugin instanceof LessError) { + fileParsedFunc(plugin, null, resolvedFilename) + } else { + fileParsedFunc(null, plugin, resolvedFilename) + } + } else if (importOptions.inline) { + fileParsedFunc(null, contents, resolvedFilename) + } else { + // import (multiple) parse trees apparently get altered and can't be cached. + // TODO: investigate why this is + if ( + importManager.files[resolvedFilename] && + !importManager.files[resolvedFilename].options.multiple && + !importOptions.multiple + ) { + fileParsedFunc( + null, + importManager.files[resolvedFilename].root, + resolvedFilename + ) + } else { + new Parser(newEnv, importManager, newFileInfo).parse( + contents, + function (e, root) { + fileParsedFunc(e, root, resolvedFilename) + } + ) + } + } + } + let loadedFile + let promise + const context = utils.clone(this.context) + + if (tryAppendExtension) { + context.ext = importOptions.isPlugin ? '.js' : '.less' + } + + if (importOptions.isPlugin) { + context.mime = 'application/javascript' + + if (context.syncImport) { + loadedFile = pluginLoader.loadPluginSync( + path, + currentFileInfo.currentDirectory, + context, + environment, + fileManager + ) + } else { + promise = pluginLoader.loadPlugin( + path, + currentFileInfo.currentDirectory, + context, + environment, + fileManager + ) + } + } else { + if (context.syncImport) { + loadedFile = fileManager.loadFileSync( + path, + currentFileInfo.currentDirectory, + context, + environment + ) + } else { + promise = fileManager.loadFile( + path, + currentFileInfo.currentDirectory, + context, + environment, + (err, loadedFile) => { + if (err) { + fileParsedFunc(err) + } else { + loadFileCallback(loadedFile) + } + } + ) + } + } + if (loadedFile) { + if (!loadedFile.filename) { + fileParsedFunc(loadedFile) + } else { + loadFileCallback(loadedFile) + } + } else if (promise) { + promise.then(loadFileCallback, fileParsedFunc) + } + } + } + + return ImportManager +} diff --git a/src/less/index.js b/src/less/index.js new file mode 100644 index 0000000..d838a18 --- /dev/null +++ b/src/less/index.js @@ -0,0 +1,97 @@ +import Environment from './environment/environment' +import data from './data' +import tree from './tree' +import AbstractFileManager from './environment/abstract-file-manager' +import AbstractPluginLoader from './environment/abstract-plugin-loader' +import visitors from './visitors' +import Parser from './parser/parser' +import functions from './functions' +import contexts from './contexts' +import LessError from './less-error' +import transformTree from './transform-tree' +import * as utils from './utils' +import PluginManager from './plugin-manager' +import logger from './logger' +import SourceMapOutput from './source-map-output' +import SourceMapBuilder from './source-map-builder' +import ParseTree from './parse-tree' +import ImportManager from './import-manager' +import Parse from './parse' +import Render from './render' +import { version } from '../../package.json' +import parseVersion from 'parse-node-version' + +export default function (environment, fileManagers) { + let sourceMapOutput, sourceMapBuilder, parseTree, importManager + + environment = new Environment(environment, fileManagers) + sourceMapOutput = SourceMapOutput(environment) + sourceMapBuilder = SourceMapBuilder(sourceMapOutput, environment) + parseTree = ParseTree(sourceMapBuilder) + importManager = ImportManager(environment) + + const render = Render(environment, parseTree, importManager) + const parse = Parse(environment, parseTree, importManager) + + const v = parseVersion(`v${version}`) + const initial = { + version: [v.major, v.minor, v.patch], + data, + tree, + Environment, + AbstractFileManager, + AbstractPluginLoader, + environment, + visitors, + Parser, + functions: functions(environment), + contexts, + SourceMapOutput: sourceMapOutput, + SourceMapBuilder: sourceMapBuilder, + ParseTree: parseTree, + ImportManager: importManager, + render, + parse, + LessError, + transformTree, + utils, + PluginManager, + logger + } + + // Create a public API + + const ctor = function (t) { + return function () { + const obj = Object.create(t.prototype) + t.apply(obj, Array.prototype.slice.call(arguments, 0)) + return obj + } + } + let t + const api = Object.create(initial) + for (const n in initial.tree) { + /* eslint guard-for-in: 0 */ + t = initial.tree[n] + if (typeof t === 'function') { + api[n.toLowerCase()] = ctor(t) + } else { + api[n] = Object.create(null) + for (const o in t) { + /* eslint guard-for-in: 0 */ + api[n][o.toLowerCase()] = ctor(t[o]) + } + } + } + + /** + * Some of the functions assume a `this` context of the API object, + * which causes it to fail when wrapped for ES6 imports. + * + * An assumed `this` should be removed in the future. + */ + initial.parse = initial.parse.bind(api) + initial.render = initial.render.bind(api) + + return api +} diff --git a/src/less/less-error.js b/src/less/less-error.js new file mode 100644 index 0000000..0138537 --- /dev/null +++ b/src/less/less-error.js @@ -0,0 +1,172 @@ +import * as utils from './utils' + +const anonymousFunc = /(|Function):(\d+):(\d+)/ + +/** + * This is a centralized class of any error that could be thrown internally (mostly by the parser). + * Besides standard .message it keeps some additional data like a path to the file where the error + * occurred along with line and column numbers. + * + * @class + * @extends Error + * @type {module.LessError} + * + * @prop {string} type + * @prop {string} filename + * @prop {number} index + * @prop {number} line + * @prop {number} column + * @prop {number} callLine + * @prop {number} callExtract + * @prop {string[]} extract + * + * @param {Object} e - An error object to wrap around or just a descriptive object + * @param {Object} fileContentMap - An object with file contents in 'contents' property (like importManager) @todo - move to fileManager? + * @param {string} [currentFilename] + */ +const LessError = function (e, fileContentMap, currentFilename) { + Error.call(this) + + const filename = e.filename || currentFilename + + this.message = e.message + this.stack = e.stack + + if (fileContentMap && filename) { + const input = fileContentMap.contents[filename] + const loc = utils.getLocation(e.index, input) + var line = loc.line + const col = loc.column + const callLine = e.call && utils.getLocation(e.call, input).line + const lines = input ? input.split('\n') : '' + + this.type = e.type || 'Syntax' + this.filename = filename + this.index = e.index + this.line = typeof line === 'number' ? line + 1 : null + this.column = col + + if (!this.line && this.stack) { + const found = this.stack.match(anonymousFunc) + + /** + * We have to figure out how this environment stringifies anonymous functions + * so we can correctly map plugin errors. + * + * Note, in Node 8, the output of anonymous funcs varied based on parameters + * being present or not, so we inject dummy params. + */ + const func = new Function('a', 'throw new Error()') + let lineAdjust = 0 + try { + func() + } catch (e) { + const match = e.stack.match(anonymousFunc) + lineAdjust = 1 - parseInt(match[2]) + } + + if (found) { + if (found[2]) { + this.line = parseInt(found[2]) + lineAdjust + } + if (found[3]) { + this.column = parseInt(found[3]) + } + } + } + + this.callLine = callLine + 1 + this.callExtract = lines[callLine] + + this.extract = [ + lines[this.line - 2], + lines[this.line - 1], + lines[this.line] + ] + } +} + +if (typeof Object.create === 'undefined') { + const F = function () {} + F.prototype = Error.prototype + LessError.prototype = new F() +} else { + LessError.prototype = Object.create(Error.prototype) +} + +LessError.prototype.constructor = LessError + +/** + * An overridden version of the default Object.prototype.toString + * which uses additional information to create a helpful message. + * + * @param {Object} options + * @returns {string} + */ +LessError.prototype.toString = function (options) { + options = options || {} + + let message = '' + const extract = this.extract || [] + let error = [] + let stylize = function (str) { + return str + } + if (options.stylize) { + const type = typeof options.stylize + if (type !== 'function') { + throw Error(`options.stylize should be a function, got a ${type}!`) + } + stylize = options.stylize + } + + if (this.line !== null) { + if (typeof extract[0] === 'string') { + error.push(stylize(`${this.line - 1} ${extract[0]}`, 'grey')) + } + + if (typeof extract[1] === 'string') { + let errorTxt = `${this.line} ` + if (extract[1]) { + errorTxt += + extract[1].slice(0, this.column) + + stylize( + stylize( + stylize(extract[1].substr(this.column, 1), 'bold') + + extract[1].slice(this.column + 1), + 'red' + ), + 'inverse' + ) + } + error.push(errorTxt) + } + + if (typeof extract[2] === 'string') { + error.push(stylize(`${this.line + 1} ${extract[2]}`, 'grey')) + } + error = `${error.join('\n') + stylize('', 'reset')}\n` + } + + message += stylize(`${this.type}Error: ${this.message}`, 'red') + if (this.filename) { + message += stylize(' in ', 'red') + this.filename + } + if (this.line) { + message += stylize( + ` on line ${this.line}, column ${this.column + 1}:`, + 'grey' + ) + } + + message += `\n${error}` + + if (this.callLine) { + message += `${stylize('from ', 'red') + (this.filename || '')}/n` + message += `${stylize(this.callLine, 'grey')} ${this.callExtract}/n` + } + + return message +} + +export default LessError diff --git a/src/less/logger.js b/src/less/logger.js new file mode 100644 index 0000000..3380877 --- /dev/null +++ b/src/less/logger.js @@ -0,0 +1,34 @@ +export default { + error: function (msg) { + this._fireEvent('error', msg) + }, + warn: function (msg) { + this._fireEvent('warn', msg) + }, + info: function (msg) { + this._fireEvent('info', msg) + }, + debug: function (msg) { + this._fireEvent('debug', msg) + }, + addListener: function (listener) { + this._listeners.push(listener) + }, + removeListener: function (listener) { + for (let i = 0; i < this._listeners.length; i++) { + if (this._listeners[i] === listener) { + this._listeners.splice(i, 1) + return + } + } + }, + _fireEvent: function (type, msg) { + for (let i = 0; i < this._listeners.length; i++) { + const logFunction = this._listeners[i][type] + if (logFunction) { + logFunction(msg) + } + } + }, + _listeners: [] +} diff --git a/src/less/parse-tree.js b/src/less/parse-tree.js new file mode 100644 index 0000000..5f61540 --- /dev/null +++ b/src/less/parse-tree.js @@ -0,0 +1,80 @@ +import LessError from './less-error' +import transformTree from './transform-tree' +import logger from './logger' + +export default function (SourceMapBuilder) { + class ParseTree { + constructor(root, imports) { + this.root = root + this.imports = imports + } + + toCSS(options) { + let evaldRoot + const result = {} + let sourceMapBuilder + try { + evaldRoot = transformTree(this.root, options) + } catch (e) { + throw new LessError(e, this.imports) + } + + try { + const compress = Boolean(options.compress) + if (compress) { + logger.warn( + 'The compress option has been deprecated. ' + + 'We recommend you use a dedicated css minifier, for instance see less-plugin-clean-css.' + ) + } + + const toCSSOptions = { + compress, + dumpLineNumbers: options.dumpLineNumbers, + strictUnits: Boolean(options.strictUnits), + numPrecision: 8 + } + + if (options.sourceMap) { + sourceMapBuilder = new SourceMapBuilder(options.sourceMap) + result.css = sourceMapBuilder.toCSS( + evaldRoot, + toCSSOptions, + this.imports + ) + } else { + result.css = evaldRoot.toCSS(toCSSOptions) + } + } catch (e) { + throw new LessError(e, this.imports) + } + + if (options.pluginManager) { + const postProcessors = options.pluginManager.getPostProcessors() + for (let i = 0; i < postProcessors.length; i++) { + result.css = postProcessors[i].process(result.css, { + sourceMap: sourceMapBuilder, + options, + imports: this.imports + }) + } + } + if (options.sourceMap) { + result.map = sourceMapBuilder.getExternalSourceMap() + } + + result.imports = [] + for (const file in this.imports.files) { + if ( + Object.prototype.hasOwnProperty.call(this.imports.files, file) && + file !== this.imports.rootFilename + ) { + result.imports.push(file) + } + } + return result + } + } + + return ParseTree +} diff --git a/src/less/parse.js b/src/less/parse.js new file mode 100644 index 0000000..2e8c1cb --- /dev/null +++ b/src/less/parse.js @@ -0,0 +1,95 @@ +import contexts from './contexts' +import Parser from './parser/parser' +import PluginManager from './plugin-manager' +import LessError from './less-error' +import * as utils from './utils' + +export default function (environment, ParseTree, ImportManager) { + const parse = function (input, options, callback) { + if (typeof options === 'function') { + callback = options + options = utils.copyOptions(this.options, {}) + } else { + options = utils.copyOptions(this.options, options || {}) + } + + if (!callback) { + const self = this + return new Promise(function (resolve, reject) { + parse.call(self, input, options, function (err, output) { + if (err) { + reject(err) + } else { + resolve(output) + } + }) + }) + } else { + let context + let rootFileInfo + const pluginManager = new PluginManager(this, !options.reUsePluginManager) + + options.pluginManager = pluginManager + + context = new contexts.Parse(options) + + if (options.rootFileInfo) { + rootFileInfo = options.rootFileInfo + } else { + const filename = options.filename || 'input' + const entryPath = filename.replace(/[^/\\]*$/, '') + rootFileInfo = { + filename, + rewriteUrls: context.rewriteUrls, + rootpath: context.rootpath || '', + currentDirectory: entryPath, + entryPath, + rootFilename: filename + } + // add in a missing trailing slash + if (rootFileInfo.rootpath && rootFileInfo.rootpath.slice(-1) !== '/') { + rootFileInfo.rootpath += '/' + } + } + + const imports = new ImportManager(this, context, rootFileInfo) + this.importManager = imports + + // TODO: allow the plugins to be just a list of paths or names + // Do an async plugin queue like lessc + + if (options.plugins) { + options.plugins.forEach(function (plugin) { + let evalResult, contents + if (plugin.fileContent) { + contents = plugin.fileContent.replace(/^\uFEFF/, '') + evalResult = pluginManager.Loader.evalPlugin( + contents, + context, + imports, + plugin.options, + plugin.filename + ) + if (evalResult instanceof LessError) { + return callback(evalResult) + } + } else { + pluginManager.addPlugin(plugin) + } + }) + } + + new Parser(context, imports, rootFileInfo).parse( + input, + function (e, root) { + if (e) { + return callback(e) + } + callback(null, root, imports, options) + }, + options + ) + } + } + return parse +} diff --git a/src/less/parser/chunker.js b/src/less/parser/chunker.js new file mode 100644 index 0000000..485470b --- /dev/null +++ b/src/less/parser/chunker.js @@ -0,0 +1,172 @@ +// Split the input into chunks. +export default function (input, fail) { + const len = input.length + let level = 0 + let parenLevel = 0 + let lastOpening + let lastOpeningParen + let lastMultiComment + let lastMultiCommentEndBrace + const chunks = [] + let emitFrom = 0 + let chunkerCurrentIndex + let currentChunkStartIndex + let cc + let cc2 + let matched + + function emitChunk(force) { + const len = chunkerCurrentIndex - emitFrom + if ((len < 512 && !force) || !len) { + return + } + chunks.push(input.slice(emitFrom, chunkerCurrentIndex + 1)) + emitFrom = chunkerCurrentIndex + 1 + } + + for ( + chunkerCurrentIndex = 0; + chunkerCurrentIndex < len; + chunkerCurrentIndex++ + ) { + cc = input.charCodeAt(chunkerCurrentIndex) + if ((cc >= 97 && cc <= 122) || cc < 34) { + // a-z or whitespace + continue + } + + switch (cc) { + case 40: // ( + parenLevel++ + lastOpeningParen = chunkerCurrentIndex + continue + case 41: // ) + if (--parenLevel < 0) { + return fail('missing opening `(`', chunkerCurrentIndex) + } + continue + case 59: // ; + if (!parenLevel) { + emitChunk() + } + continue + case 123: // { + level++ + lastOpening = chunkerCurrentIndex + continue + case 125: // } + if (--level < 0) { + return fail('missing opening `{`', chunkerCurrentIndex) + } + if (!level && !parenLevel) { + emitChunk() + } + continue + case 92: // \ + if (chunkerCurrentIndex < len - 1) { + chunkerCurrentIndex++ + continue + } + return fail('unescaped `\\`', chunkerCurrentIndex) + case 34: + case 39: + case 96: // ", ' and ` + matched = 0 + currentChunkStartIndex = chunkerCurrentIndex + for ( + chunkerCurrentIndex = chunkerCurrentIndex + 1; + chunkerCurrentIndex < len; + chunkerCurrentIndex++ + ) { + cc2 = input.charCodeAt(chunkerCurrentIndex) + if (cc2 > 96) { + continue + } + if (cc2 == cc) { + matched = 1 + break + } + if (cc2 == 92) { + // \ + if (chunkerCurrentIndex == len - 1) { + return fail('unescaped `\\`', chunkerCurrentIndex) + } + chunkerCurrentIndex++ + } + } + if (matched) { + continue + } + return fail( + `unmatched \`${String.fromCharCode(cc)}\``, + currentChunkStartIndex + ) + case 47: // /, check for comment + if (parenLevel || chunkerCurrentIndex == len - 1) { + continue + } + cc2 = input.charCodeAt(chunkerCurrentIndex + 1) + if (cc2 == 47) { + // //, find lnfeed + for ( + chunkerCurrentIndex = chunkerCurrentIndex + 2; + chunkerCurrentIndex < len; + chunkerCurrentIndex++ + ) { + cc2 = input.charCodeAt(chunkerCurrentIndex) + if (cc2 <= 13 && (cc2 == 10 || cc2 == 13)) { + break + } + } + } else if (cc2 == 42) { + // /*, find */ + lastMultiComment = currentChunkStartIndex = chunkerCurrentIndex + for ( + chunkerCurrentIndex = chunkerCurrentIndex + 2; + chunkerCurrentIndex < len - 1; + chunkerCurrentIndex++ + ) { + cc2 = input.charCodeAt(chunkerCurrentIndex) + if (cc2 == 125) { + lastMultiCommentEndBrace = chunkerCurrentIndex + } + if (cc2 != 42) { + continue + } + if (input.charCodeAt(chunkerCurrentIndex + 1) == 47) { + break + } + } + if (chunkerCurrentIndex == len - 1) { + return fail('missing closing `*/`', currentChunkStartIndex) + } + chunkerCurrentIndex++ + } + continue + case 42: // *, check for unmatched */ + if ( + chunkerCurrentIndex < len - 1 && + input.charCodeAt(chunkerCurrentIndex + 1) == 47 + ) { + return fail('unmatched `/*`', chunkerCurrentIndex) + } + continue + } + } + + if (level !== 0) { + if ( + lastMultiComment > lastOpening && + lastMultiCommentEndBrace > lastMultiComment + ) { + return fail('missing closing `}` or `*/`', lastOpening) + } else { + return fail('missing closing `}`', lastOpening) + } + } else if (parenLevel !== 0) { + return fail('missing closing `)`', lastOpeningParen) + } + + emitChunk(true) + return chunks +} diff --git a/src/less/parser/parser-input.js b/src/less/parser/parser-input.js new file mode 100644 index 0000000..f4df7e0 --- /dev/null +++ b/src/less/parser/parser-input.js @@ -0,0 +1,412 @@ +import chunker from './chunker' + +export default () => { + let // Less input string + input + + let // current chunk + j + + const // holds state for backtracking + saveStack = [] + + let // furthest index the parser has gone to + furthest + + let // if this is furthest we got to, this is the probably cause + furthestPossibleErrorMessage + + let // chunkified input + chunks + + let // current chunk + current + + let // index of current chunk, in `input` + currentPos + + const parserInput = {} + const CHARCODE_SPACE = 32 + const CHARCODE_TAB = 9 + const CHARCODE_LF = 10 + const CHARCODE_CR = 13 + const CHARCODE_PLUS = 43 + const CHARCODE_COMMA = 44 + const CHARCODE_FORWARD_SLASH = 47 + const CHARCODE_9 = 57 + + function skipWhitespace(length) { + const oldi = parserInput.i + const oldj = j + const curr = parserInput.i - currentPos + const endIndex = parserInput.i + current.length - curr + const mem = (parserInput.i += length) + const inp = input + let c + let nextChar + let comment + + for (; parserInput.i < endIndex; parserInput.i++) { + c = inp.charCodeAt(parserInput.i) + + if (parserInput.autoCommentAbsorb && c === CHARCODE_FORWARD_SLASH) { + nextChar = inp.charAt(parserInput.i + 1) + if (nextChar === '/') { + comment = { index: parserInput.i, isLineComment: true } + let nextNewLine = inp.indexOf('\n', parserInput.i + 2) + if (nextNewLine < 0) { + nextNewLine = endIndex + } + parserInput.i = nextNewLine + comment.text = inp.substr( + comment.index, + parserInput.i - comment.index + ) + parserInput.commentStore.push(comment) + continue + } else if (nextChar === '*') { + const nextStarSlash = inp.indexOf('*/', parserInput.i + 2) + if (nextStarSlash >= 0) { + comment = { + index: parserInput.i, + text: inp.substr( + parserInput.i, + nextStarSlash + 2 - parserInput.i + ), + isLineComment: false + } + parserInput.i += comment.text.length - 1 + parserInput.commentStore.push(comment) + continue + } + } + break + } + + if ( + c !== CHARCODE_SPACE && + c !== CHARCODE_LF && + c !== CHARCODE_TAB && + c !== CHARCODE_CR + ) { + break + } + } + + current = current.slice(length + parserInput.i - mem + curr) + currentPos = parserInput.i + + if (!current.length) { + if (j < chunks.length - 1) { + current = chunks[++j] + skipWhitespace(0) // skip space at the beginning of a chunk + return true // things changed + } + parserInput.finished = true + } + + return oldi !== parserInput.i || oldj !== j + } + + parserInput.save = () => { + currentPos = parserInput.i + saveStack.push({ current, i: parserInput.i, j }) + } + parserInput.restore = possibleErrorMessage => { + if ( + parserInput.i > furthest || + (parserInput.i === furthest && + possibleErrorMessage && + !furthestPossibleErrorMessage) + ) { + furthest = parserInput.i + furthestPossibleErrorMessage = possibleErrorMessage + } + const state = saveStack.pop() + current = state.current + currentPos = parserInput.i = state.i + j = state.j + } + parserInput.forget = () => { + saveStack.pop() + } + parserInput.isWhitespace = offset => { + const pos = parserInput.i + (offset || 0) + const code = input.charCodeAt(pos) + return ( + code === CHARCODE_SPACE || + code === CHARCODE_CR || + code === CHARCODE_TAB || + code === CHARCODE_LF + ) + } + + // Specialization of $(tok) + parserInput.$re = tok => { + if (parserInput.i > currentPos) { + current = current.slice(parserInput.i - currentPos) + currentPos = parserInput.i + } + + const m = tok.exec(current) + if (!m) { + return null + } + + skipWhitespace(m[0].length) + if (typeof m === 'string') { + return m + } + + return m.length === 1 ? m[0] : m + } + + parserInput.$char = tok => { + if (input.charAt(parserInput.i) !== tok) { + return null + } + skipWhitespace(1) + return tok + } + + parserInput.$str = tok => { + const tokLength = tok.length + + // https://jsperf.com/string-startswith/21 + for (let i = 0; i < tokLength; i++) { + if (input.charAt(parserInput.i + i) !== tok.charAt(i)) { + return null + } + } + + skipWhitespace(tokLength) + return tok + } + + parserInput.$quoted = loc => { + const pos = loc || parserInput.i + const startChar = input.charAt(pos) + + if (startChar !== "'" && startChar !== '"') { + return + } + const length = input.length + const currentPosition = pos + + for (let i = 1; i + currentPosition < length; i++) { + const nextChar = input.charAt(i + currentPosition) + switch (nextChar) { + case '\\': + i++ + continue + case '\r': + case '\n': + break + case startChar: { + const str = input.substr(currentPosition, i + 1) + if (!loc && loc !== 0) { + skipWhitespace(i + 1) + return str + } + return [startChar, str] + } + default: + } + } + return null + } + + /** + * Permissive parsing. Ignores everything except matching {} [] () and quotes + * until matching token (outside of blocks) + */ + parserInput.$parseUntil = tok => { + let quote = '' + let returnVal = null + let inComment = false + let blockDepth = 0 + const blockStack = [] + const parseGroups = [] + const length = input.length + const startPos = parserInput.i + let lastPos = parserInput.i + let i = parserInput.i + let loop = true + let testChar + + if (typeof tok === 'string') { + testChar = char => char === tok + } else { + testChar = char => tok.test(char) + } + + do { + let nextChar = input.charAt(i) + if (blockDepth === 0 && testChar(nextChar)) { + returnVal = input.substr(lastPos, i - lastPos) + if (returnVal) { + parseGroups.push(returnVal) + } else { + parseGroups.push(' ') + } + returnVal = parseGroups + skipWhitespace(i - startPos) + loop = false + } else { + if (inComment) { + if (nextChar === '*' && input.charAt(i + 1) === '/') { + i++ + blockDepth-- + inComment = false + } + i++ + continue + } + switch (nextChar) { + case '\\': + i++ + nextChar = input.charAt(i) + parseGroups.push(input.substr(lastPos, i - lastPos + 1)) + lastPos = i + 1 + break + case '/': + if (input.charAt(i + 1) === '*') { + i++ + inComment = true + blockDepth++ + } + break + case "'": + case '"': + quote = parserInput.$quoted(i) + if (quote) { + parseGroups.push(input.substr(lastPos, i - lastPos), quote) + i += quote[1].length - 1 + lastPos = i + 1 + } else { + skipWhitespace(i - startPos) + returnVal = nextChar + loop = false + } + break + case '{': + blockStack.push('}') + blockDepth++ + break + case '(': + blockStack.push(')') + blockDepth++ + break + case '[': + blockStack.push(']') + blockDepth++ + break + case '}': + case ')': + case ']': { + const expected = blockStack.pop() + if (nextChar === expected) { + blockDepth-- + } else { + // move the parser to the error and return expected + skipWhitespace(i - startPos) + returnVal = expected + loop = false + } + } + } + i++ + if (i > length) { + loop = false + } + } + } while (loop) + + return returnVal ? returnVal : null + } + + parserInput.autoCommentAbsorb = true + parserInput.commentStore = [] + parserInput.finished = false + + // Same as $(), but don't change the state of the parser, + // just return the match. + parserInput.peek = tok => { + if (typeof tok === 'string') { + // https://jsperf.com/string-startswith/21 + for (let i = 0; i < tok.length; i++) { + if (input.charAt(parserInput.i + i) !== tok.charAt(i)) { + return false + } + } + return true + } else { + return tok.test(current) + } + } + + // Specialization of peek() + // TODO remove or change some currentChar calls to peekChar + parserInput.peekChar = tok => input.charAt(parserInput.i) === tok + + parserInput.currentChar = () => input.charAt(parserInput.i) + + parserInput.prevChar = () => input.charAt(parserInput.i - 1) + + parserInput.getInput = () => input + + parserInput.peekNotNumeric = () => { + const c = input.charCodeAt(parserInput.i) + // Is the first char of the dimension 0-9, '.', '+' or '-' + return ( + c > CHARCODE_9 || + c < CHARCODE_PLUS || + c === CHARCODE_FORWARD_SLASH || + c === CHARCODE_COMMA + ) + } + + parserInput.start = (str, chunkInput, failFunction) => { + input = str + parserInput.i = j = currentPos = furthest = 0 + + // chunking apparently makes things quicker (but my tests indicate + // it might actually make things slower in node at least) + // and it is a non-perfect parse - it can't recognise + // unquoted urls, meaning it can't distinguish comments + // meaning comments with quotes or {}() in them get 'counted' + // and then lead to parse errors. + // In addition if the chunking chunks in the wrong place we might + // not be able to parse a parser statement in one go + // this is officially deprecated but can be switched on via an option + // in the case it causes too much performance issues. + if (chunkInput) { + chunks = chunker(str, failFunction) + } else { + chunks = [str] + } + + current = chunks[0] + + skipWhitespace(0) + } + + parserInput.end = () => { + let message + const isFinished = parserInput.i >= input.length + + if (parserInput.i < furthest) { + message = furthestPossibleErrorMessage + parserInput.i = furthest + } + return { + isFinished, + furthest: parserInput.i, + furthestPossibleErrorMessage: message, + furthestReachedEnd: parserInput.i >= input.length - 1, + furthestChar: input[parserInput.i] + } + } + + return parserInput +} diff --git a/src/less/parser/parser.js b/src/less/parser/parser.js new file mode 100644 index 0000000..4f191d1 --- /dev/null +++ b/src/less/parser/parser.js @@ -0,0 +1,2740 @@ +import LessError from '../less-error' +import tree from '../tree' +import visitors from '../visitors' +import getParserInput from './parser-input' +import * as utils from '../utils' +import functionRegistry from '../functions/function-registry' + +// +// less.js - parser +// +// A relatively straight-forward predictive parser. +// There is no tokenization/lexing stage, the input is parsed +// in one sweep. +// +// To make the parser fast enough to run in the browser, several +// optimization had to be made: +// +// - Matching and slicing on a huge input is often cause of slowdowns. +// The solution is to chunkify the input into smaller strings. +// The chunks are stored in the `chunks` var, +// `j` holds the current chunk index, and `currentPos` holds +// the index of the current chunk in relation to `input`. +// This gives us an almost 4x speed-up. +// +// - In many cases, we don't need to match individual tokens; +// for example, if a value doesn't hold any variables, operations +// or dynamic references, the parser can effectively 'skip' it, +// treating it as a literal. +// An example would be '1px solid #000' - which evaluates to itself, +// we don't need to know what the individual components are. +// The drawback, of course is that you don't get the benefits of +// syntax-checking on the CSS. This gives us a 50% speed-up in the parser, +// and a smaller speed-up in the code-gen. +// +// +// Token matching is done with the `$` function, which either takes +// a terminal string or regexp, or a non-terminal function to call. +// It also takes care of moving all the indices forwards. +// + +const Parser = function Parser(context, imports, fileInfo, currentIndex) { + currentIndex = currentIndex || 0 + let parsers + const parserInput = getParserInput() + + function error(msg, type) { + throw new LessError( + { + index: parserInput.i, + filename: fileInfo.filename, + type: type || 'Syntax', + message: msg + }, + imports + ) + } + + function expect(arg, msg) { + // some older browsers return typeof 'function' for RegExp + const result = + arg instanceof Function ? arg.call(parsers) : parserInput.$re(arg) + if (result) { + return result + } + + error( + msg || + (typeof arg === 'string' + ? `expected '${arg}' got '${parserInput.currentChar()}'` + : 'unexpected token') + ) + } + + // Specialization of expect() + function expectChar(arg, msg) { + if (parserInput.$char(arg)) { + return arg + } + error(msg || `expected '${arg}' got '${parserInput.currentChar()}'`) + } + + function getDebugInfo(index) { + const filename = fileInfo.filename + + return { + lineNumber: utils.getLocation(index, parserInput.getInput()).line + 1, + fileName: filename + } + } + + /** + * Used after initial parsing to create nodes on the fly + * + * @param {String} str - string to parse + * @param {Array} parseList - array of parsers to run input through e.g. ["value", "important"] + * @param {Number} currentIndex - start number to begin indexing + * @param {Object} fileInfo - fileInfo to attach to created nodes + */ + function parseNode(str, parseList, callback) { + let result + const returnNodes = [] + const parser = parserInput + + try { + parser.start(str, false, function fail(msg, index) { + callback({ + message: msg, + index: index + currentIndex + }) + }) + for (let x = 0, p; (p = parseList[x]); x++) { + result = parsers[p]() + returnNodes.push(result || null) + } + + const endInfo = parser.end() + if (endInfo.isFinished) { + callback(null, returnNodes) + } else { + callback(true, null) + } + } catch (e) { + throw new LessError( + { + index: e.index + currentIndex, + message: e.message + }, + imports, + fileInfo.filename + ) + } + } + + // + // The Parser + // + return { + parserInput, + imports, + fileInfo, + parseNode, + // + // Parse an input string into an abstract syntax tree, + // @param str A string containing 'less' markup + // @param callback call `callback` when done. + // @param [additionalData] An optional map which can contains vars - a map (key, value) of variables to apply + // + parse: function (str, callback, additionalData) { + let root + let err = null + let globalVars + let modifyVars + let ignored + let preText = '' + + // Optionally disable @plugin parsing + if (additionalData && additionalData.disablePluginRule) { + parsers.plugin = function () { + var dir = parserInput.$re(/^@plugin?\s+/) + if (dir) { + error( + '@plugin statements are not allowed when disablePluginRule is set to true' + ) + } + } + } + + globalVars = + additionalData && additionalData.globalVars + ? `${Parser.serializeVars(additionalData.globalVars)}\n` + : '' + modifyVars = + additionalData && additionalData.modifyVars + ? `\n${Parser.serializeVars(additionalData.modifyVars)}` + : '' + + if (context.pluginManager) { + const preProcessors = context.pluginManager.getPreProcessors() + for (let i = 0; i < preProcessors.length; i++) { + str = preProcessors[i].process(str, { context, imports, fileInfo }) + } + } + + if (globalVars || (additionalData && additionalData.banner)) { + preText = + (additionalData && additionalData.banner + ? additionalData.banner + : '') + globalVars + ignored = imports.contentsIgnoredChars + ignored[fileInfo.filename] = ignored[fileInfo.filename] || 0 + ignored[fileInfo.filename] += preText.length + } + + str = str.replace(/\r\n?/g, '\n') + // Remove potential UTF Byte Order Mark + str = preText + str.replace(/^\uFEFF/, '') + modifyVars + imports.contents[fileInfo.filename] = str + + // Start with the primary rule. + // The whole syntax tree is held under a Ruleset node, + // with the `root` property set to true, so no `{}` are + // output. The callback is called when the input is parsed. + try { + parserInput.start(str, context.chunkInput, function fail(msg, index) { + throw new LessError( + { + index, + type: 'Parse', + message: msg, + filename: fileInfo.filename + }, + imports + ) + }) + + tree.Node.prototype.parse = this + root = new tree.Ruleset(null, this.parsers.primary()) + tree.Node.prototype.rootNode = root + root.root = true + root.firstRoot = true + root.functionRegistry = functionRegistry.inherit() + } catch (e) { + return callback(new LessError(e, imports, fileInfo.filename)) + } + + // If `i` is smaller than the `input.length - 1`, + // it means the parser wasn't able to parse the whole + // string, so we've got a parsing error. + // + // We try to extract a \n delimited string, + // showing the line where the parse error occurred. + // We split it up into two parts (the part which parsed, + // and the part which didn't), so we can color them differently. + const endInfo = parserInput.end() + if (!endInfo.isFinished) { + let message = endInfo.furthestPossibleErrorMessage + + if (!message) { + message = 'Unrecognised input' + if (endInfo.furthestChar === '}') { + message += ". Possibly missing opening '{'" + } else if (endInfo.furthestChar === ')') { + message += ". Possibly missing opening '('" + } else if (endInfo.furthestReachedEnd) { + message += '. Possibly missing something' + } + } + + err = new LessError( + { + type: 'Parse', + message, + index: endInfo.furthest, + filename: fileInfo.filename + }, + imports + ) + } + + const finish = e => { + e = err || e || imports.error + + if (e) { + if (!(e instanceof LessError)) { + e = new LessError(e, imports, fileInfo.filename) + } + + return callback(e) + } else { + return callback(null, root) + } + } + + if (context.processImports !== false) { + new visitors.ImportVisitor(imports, finish).run(root) + } else { + return finish() + } + }, + + // + // Here in, the parsing rules/functions + // + // The basic structure of the syntax tree generated is as follows: + // + // Ruleset -> Declaration -> Value -> Expression -> Entity + // + // Here's some Less code: + // + // .class { + // color: #fff; + // border: 1px solid #000; + // width: @w + 4px; + // > .child {...} + // } + // + // And here's what the parse tree might look like: + // + // Ruleset (Selector '.class', [ + // Declaration ("color", Value ([Expression [Color #fff]])) + // Declaration ("border", Value ([Expression [Dimension 1px][Keyword "solid"][Color #000]])) + // Declaration ("width", Value ([Expression [Operation " + " [Variable "@w"][Dimension 4px]]])) + // Ruleset (Selector [Element '>', '.child'], [...]) + // ]) + // + // In general, most rules will try to parse a token with the `$re()` function, and if the return + // value is truly, will return a new node, of the relevant type. Sometimes, we need to check + // first, before parsing, that's when we use `peek()`. + // + parsers: (parsers = { + // + // The `primary` rule is the *entry* and *exit* point of the parser. + // The rules here can appear at any level of the parse tree. + // + // The recursive nature of the grammar is an interplay between the `block` + // rule, which represents `{ ... }`, the `ruleset` rule, and this `primary` rule, + // as represented by this simplified grammar: + // + // primary → (ruleset | declaration)+ + // ruleset → selector+ block + // block → '{' primary '}' + // + // Only at one point is the primary rule not called from the + // block rule: at the root level. + // + primary: function () { + const mixin = this.mixin + let root = [] + let node + + while (true) { + while (true) { + node = this.comment() + if (!node) { + break + } + root.push(node) + } + // always process comments before deciding if finished + if (parserInput.finished) { + break + } + if (parserInput.peek('}')) { + break + } + + node = this.extendRule() + if (node) { + root = root.concat(node) + continue + } + + node = + mixin.definition() || + this.declaration() || + mixin.call(false, false) || + this.ruleset() || + this.variableCall() || + this.entities.call() || + this.atrule() + if (node) { + root.push(node) + } else { + let foundSemiColon = false + while (parserInput.$char(';')) { + foundSemiColon = true + } + if (!foundSemiColon) { + break + } + } + } + + return root + }, + + // comments are collected by the main parsing mechanism and then assigned to nodes + // where the current structure allows it + comment: function () { + if (parserInput.commentStore.length) { + const comment = parserInput.commentStore.shift() + return new tree.Comment( + comment.text, + comment.isLineComment, + comment.index + currentIndex, + fileInfo + ) + } + }, + + // + // Entities are tokens which can be found inside an Expression + // + entities: { + mixinLookup: function () { + return parsers.mixin.call(true, true) + }, + // + // A string, which supports escaping " and ' + // + // "milky way" 'he\'s the one!' + // + quoted: function (forceEscaped) { + let str + const index = parserInput.i + let isEscaped = false + + parserInput.save() + if (parserInput.$char('~')) { + isEscaped = true + } else if (forceEscaped) { + parserInput.restore() + return + } + + str = parserInput.$quoted() + if (!str) { + parserInput.restore() + return + } + parserInput.forget() + + return new tree.Quoted( + str.charAt(0), + str.substr(1, str.length - 2), + isEscaped, + index + currentIndex, + fileInfo + ) + }, + + // + // A catch-all word, such as: + // + // black border-collapse + // + keyword: function () { + const k = + parserInput.$char('%') || + parserInput.$re( + /^\[?(?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+\]?/ + ) + if (k) { + return tree.Color.fromKeyword(k) || new tree.Keyword(k) + } + }, + + // + // A function call + // + // rgb(255, 0, 255) + // + // The arguments are parsed with the `entities.arguments` parser. + // + call: function () { + let name + let args + let func + const index = parserInput.i + + // http://jsperf.com/case-insensitive-regex-vs-strtolower-then-regex/18 + if (parserInput.peek(/^url\(/i)) { + return + } + + parserInput.save() + + name = parserInput.$re(/^([\w-]+|%|~|progid:[\w.]+)\(/) + if (!name) { + parserInput.forget() + return + } + + name = name[1] + func = this.customFuncCall(name) + if (func) { + args = func.parse() + if (args && func.stop) { + parserInput.forget() + return args + } + } + + args = this.arguments(args) + + if (!parserInput.$char(')')) { + parserInput.restore("Could not parse call arguments or missing ')'") + return + } + + parserInput.forget() + + return new tree.Call(name, args, index + currentIndex, fileInfo) + }, + + // + // Parsing rules for functions with non-standard args, e.g.: + // + // boolean(not(2 > 1)) + // + // This is a quick prototype, to be modified/improved when + // more custom-parsed funcs come (e.g. `selector(...)`) + // + + customFuncCall: function (name) { + /* Ideally the table is to be moved out of here for faster perf., + but it's quite tricky since it relies on all these `parsers` + and `expect` available only here */ + return { + alpha: f(parsers.ieAlpha, true), + boolean: f(condition), + if: f(condition) + }[name.toLowerCase()] + + function f(parse, stop) { + return { + parse, // parsing function + stop // when true - stop after parse() and return its result, + // otherwise continue for plain args + } + } + + function condition() { + return [expect(parsers.condition, 'expected condition')] + } + }, + + arguments: function (prevArgs) { + let argsComma = prevArgs || [] + const argsSemiColon = [] + let isSemiColonSeparated + let value + + parserInput.save() + + while (true) { + if (prevArgs) { + prevArgs = false + } else { + value = + parsers.detachedRuleset() || + this.assignment() || + parsers.expression() + if (!value) { + break + } + + if (value.value && value.value.length == 1) { + value = value.value[0] + } + + argsComma.push(value) + } + + if (parserInput.$char(',')) { + continue + } + + if (parserInput.$char(';') || isSemiColonSeparated) { + isSemiColonSeparated = true + value = + argsComma.length < 1 ? argsComma[0] : new tree.Value(argsComma) + argsSemiColon.push(value) + argsComma = [] + } + } + + parserInput.forget() + return isSemiColonSeparated ? argsSemiColon : argsComma + }, + literal: function () { + return ( + this.dimension() || + this.color() || + this.quoted() || + this.unicodeDescriptor() + ) + }, + + // Assignments are argument entities for calls. + // They are present in ie filter properties as shown below. + // + // filter: progid:DXImageTransform.Microsoft.Alpha( *opacity=50* ) + // + + assignment: function () { + let key + let value + parserInput.save() + key = parserInput.$re(/^\w+(?=\s?=)/i) + if (!key) { + parserInput.restore() + return + } + if (!parserInput.$char('=')) { + parserInput.restore() + return + } + value = parsers.entity() + if (value) { + parserInput.forget() + return new tree.Assignment(key, value) + } else { + parserInput.restore() + } + }, + + // + // Parse url() tokens + // + // We use a specific rule for urls, because they don't really behave like + // standard function calls. The difference is that the argument doesn't have + // to be enclosed within a string, so it can't be parsed as an Expression. + // + url: function () { + let value + const index = parserInput.i + + parserInput.autoCommentAbsorb = false + + if (!parserInput.$str('url(')) { + parserInput.autoCommentAbsorb = true + return + } + + value = + this.quoted() || + this.variable() || + this.property() || + parserInput.$re(/^(?:(?:\\[()'"])|[^()'"])+/) || + '' + + parserInput.autoCommentAbsorb = true + + expectChar(')') + + return new tree.URL( + value.value !== undefined || + value instanceof tree.Variable || + value instanceof tree.Property + ? value + : new tree.Anonymous(value, index), + index + currentIndex, + fileInfo + ) + }, + + // + // A Variable entity, such as `@fink`, in + // + // width: @fink + 2px + // + // We use a different parser for variable definitions, + // see `parsers.variable`. + // + variable: function () { + let ch + let name + const index = parserInput.i + + parserInput.save() + if ( + parserInput.currentChar() === '@' && + (name = parserInput.$re(/^@@?[\w-]+/)) + ) { + ch = parserInput.currentChar() + if ( + ch === '(' || + (ch === '[' && !parserInput.prevChar().match(/^\s/)) + ) { + // this may be a VariableCall lookup + const result = parsers.variableCall(name) + if (result) { + parserInput.forget() + return result + } + } + parserInput.forget() + return new tree.Variable(name, index + currentIndex, fileInfo) + } + parserInput.restore() + }, + + // A variable entity using the protective {} e.g. @{var} + variableCurly: function () { + let curly + const index = parserInput.i + + if ( + parserInput.currentChar() === '@' && + (curly = parserInput.$re(/^@\{([\w-]+)\}/)) + ) { + return new tree.Variable( + `@${curly[1]}`, + index + currentIndex, + fileInfo + ) + } + }, + // + // A Property accessor, such as `$color`, in + // + // background-color: $color + // + property: function () { + let name + const index = parserInput.i + + if ( + parserInput.currentChar() === '$' && + (name = parserInput.$re(/^\$[\w-]+/)) + ) { + return new tree.Property(name, index + currentIndex, fileInfo) + } + }, + + // A property entity useing the protective {} e.g. ${prop} + propertyCurly: function () { + let curly + const index = parserInput.i + + if ( + parserInput.currentChar() === '$' && + (curly = parserInput.$re(/^\$\{([\w-]+)\}/)) + ) { + return new tree.Property( + `$${curly[1]}`, + index + currentIndex, + fileInfo + ) + } + }, + // + // A Hexadecimal color + // + // #4F3C2F + // + // `rgb` and `hsl` colors are parsed through the `entities.call` parser. + // + color: function () { + let rgb + parserInput.save() + + if ( + parserInput.currentChar() === '#' && + (rgb = parserInput.$re( + /^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3,4})([\w.#[])?/ + )) + ) { + if (!rgb[2]) { + parserInput.forget() + return new tree.Color(rgb[1], undefined, rgb[0]) + } + } + parserInput.restore() + }, + + colorKeyword: function () { + parserInput.save() + const autoCommentAbsorb = parserInput.autoCommentAbsorb + parserInput.autoCommentAbsorb = false + const k = parserInput.$re(/^[_A-Za-z-][_A-Za-z0-9-]+/) + parserInput.autoCommentAbsorb = autoCommentAbsorb + if (!k) { + parserInput.forget() + return + } + parserInput.restore() + const color = tree.Color.fromKeyword(k) + if (color) { + parserInput.$str(k) + return color + } + }, + + // + // A Dimension, that is, a number and a unit + // + // 0.5em 95% + // + dimension: function () { + if (parserInput.peekNotNumeric()) { + return + } + + const value = parserInput.$re(/^([+-]?\d*\.?\d+)(%|[a-z_]+)?/i) + if (value) { + return new tree.Dimension(value[1], value[2]) + } + }, + + // + // A unicode descriptor, as is used in unicode-range + // + // U+0?? or U+00A1-00A9 + // + unicodeDescriptor: function () { + let ud + + ud = parserInput.$re(/^U\+[0-9a-fA-F?]+(-[0-9a-fA-F?]+)?/) + if (ud) { + return new tree.UnicodeDescriptor(ud[0]) + } + }, + + // + // JavaScript code to be evaluated + // + // `window.location.href` + // + javascript: function () { + let js + const index = parserInput.i + + parserInput.save() + + const escape = parserInput.$char('~') + const jsQuote = parserInput.$char('`') + + if (!jsQuote) { + parserInput.restore() + return + } + + js = parserInput.$re(/^[^`]*`/) + if (js) { + parserInput.forget() + return new tree.JavaScript( + js.substr(0, js.length - 1), + Boolean(escape), + index + currentIndex, + fileInfo + ) + } + parserInput.restore('invalid javascript definition') + } + }, + + // + // The variable part of a variable definition. Used in the `rule` parser + // + // @fink: + // + variable: function () { + let name + + if ( + parserInput.currentChar() === '@' && + (name = parserInput.$re(/^(@[\w-]+)\s*:/)) + ) { + return name[1] + } + }, + + // + // Call a variable value to retrieve a detached ruleset + // or a value from a detached ruleset's rules. + // + // @fink(); + // @fink; + // color: @fink[@color]; + // + variableCall: function (parsedName) { + let lookups + const i = parserInput.i + const inValue = !!parsedName + let name = parsedName + + parserInput.save() + + if ( + name || + (parserInput.currentChar() === '@' && + (name = parserInput.$re(/^(@[\w-]+)(\(\s*\))?/))) + ) { + lookups = this.mixin.ruleLookups() + + if ( + !lookups && + ((inValue && parserInput.$str('()') !== '()') || name[2] !== '()') + ) { + parserInput.restore("Missing '[...]' lookup in variable call") + return + } + + if (!inValue) { + name = name[1] + } + + const call = new tree.VariableCall(name, i, fileInfo) + if (!inValue && parsers.end()) { + parserInput.forget() + return call + } else { + parserInput.forget() + return new tree.NamespaceValue(call, lookups, i, fileInfo) + } + } + + parserInput.restore() + }, + + // + // extend syntax - used to extend selectors + // + extend: function (isRule) { + let elements + let e + const index = parserInput.i + let option + let extendList + let extend + + if (!parserInput.$str(isRule ? '&:extend(' : ':extend(')) { + return + } + + do { + option = null + elements = null + while (!(option = parserInput.$re(/^(all)(?=\s*(\)|,))/))) { + e = this.element() + if (!e) { + break + } + if (elements) { + elements.push(e) + } else { + elements = [e] + } + } + + option = option && option[1] + if (!elements) { + error('Missing target selector for :extend().') + } + extend = new tree.Extend( + new tree.Selector(elements), + option, + index + currentIndex, + fileInfo + ) + if (extendList) { + extendList.push(extend) + } else { + extendList = [extend] + } + } while (parserInput.$char(',')) + + expect(/^\)/) + + if (isRule) { + expect(/^;/) + } + + return extendList + }, + + // + // extendRule - used in a rule to extend all the parent selectors + // + extendRule: function () { + return this.extend(true) + }, + + // + // Mixins + // + mixin: { + // + // A Mixin call, with an optional argument list + // + // #mixins > .square(#fff); + // #mixins.square(#fff); + // .rounded(4px, black); + // .button; + // + // We can lookup / return a value using the lookup syntax: + // + // color: #mixin.square(#fff)[@color]; + // + // The `while` loop is there because mixins can be + // namespaced, but we only support the child and descendant + // selector for now. + // + call: function (inValue, getLookup) { + const s = parserInput.currentChar() + let important = false + let lookups + const index = parserInput.i + let elements + let args + let hasParens + + if (s !== '.' && s !== '#') { + return + } + + parserInput.save() // stop us absorbing part of an invalid selector + + elements = this.elements() + + if (elements) { + if (parserInput.$char('(')) { + args = this.args(true).args + expectChar(')') + hasParens = true + } + + if (getLookup !== false) { + lookups = this.ruleLookups() + } + if (getLookup === true && !lookups) { + parserInput.restore() + return + } + + if (inValue && !lookups && !hasParens) { + // This isn't a valid in-value mixin call + parserInput.restore() + return + } + + if (!inValue && parsers.important()) { + important = true + } + + if (inValue || parsers.end()) { + parserInput.forget() + const mixin = new tree.mixin.Call( + elements, + args, + index + currentIndex, + fileInfo, + !lookups && important + ) + if (lookups) { + return new tree.NamespaceValue(mixin, lookups) + } else { + return mixin + } + } + } + + parserInput.restore() + }, + /** + * Matching elements for mixins + * (Start with . or # and can have > ) + */ + elements: function () { + let elements + let e + let c + let elem + let elemIndex + const re = /^[#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/ + while (true) { + elemIndex = parserInput.i + e = parserInput.$re(re) + + if (!e) { + break + } + elem = new tree.Element( + c, + e, + false, + elemIndex + currentIndex, + fileInfo + ) + if (elements) { + elements.push(elem) + } else { + elements = [elem] + } + c = parserInput.$char('>') + } + return elements + }, + args: function (isCall) { + const entities = parsers.entities + const returner = { args: null, variadic: false } + let expressions = [] + const argsSemiColon = [] + const argsComma = [] + let isSemiColonSeparated + let expressionContainsNamed + let name + let nameLoop + let value + let arg + let expand + let hasSep = true + + parserInput.save() + + while (true) { + if (isCall) { + arg = parsers.detachedRuleset() || parsers.expression() + } else { + parserInput.commentStore.length = 0 + if (parserInput.$str('...')) { + returner.variadic = true + if (parserInput.$char(';') && !isSemiColonSeparated) { + isSemiColonSeparated = true + } + ;(isSemiColonSeparated ? argsSemiColon : argsComma).push({ + variadic: true + }) + break + } + arg = + entities.variable() || + entities.property() || + entities.literal() || + entities.keyword() || + this.call(true) + } + + if (!arg || !hasSep) { + break + } + + nameLoop = null + if (arg.throwAwayComments) { + arg.throwAwayComments() + } + value = arg + let val = null + + if (isCall) { + // Variable + if (arg.value && arg.value.length == 1) { + val = arg.value[0] + } + } else { + val = arg + } + + if ( + val && + (val instanceof tree.Variable || val instanceof tree.Property) + ) { + if (parserInput.$char(':')) { + if (expressions.length > 0) { + if (isSemiColonSeparated) { + error('Cannot mix ; and , as delimiter types') + } + expressionContainsNamed = true + } + + value = parsers.detachedRuleset() || parsers.expression() + + if (!value) { + if (isCall) { + error('could not understand value for named argument') + } else { + parserInput.restore() + returner.args = [] + return returner + } + } + nameLoop = name = val.name + } else if (parserInput.$str('...')) { + if (!isCall) { + returner.variadic = true + if (parserInput.$char(';') && !isSemiColonSeparated) { + isSemiColonSeparated = true + } + ;(isSemiColonSeparated ? argsSemiColon : argsComma).push({ + name: arg.name, + variadic: true + }) + break + } else { + expand = true + } + } else if (!isCall) { + name = nameLoop = val.name + value = null + } + } + + if (value) { + expressions.push(value) + } + + argsComma.push({ name: nameLoop, value, expand }) + + if (parserInput.$char(',')) { + hasSep = true + continue + } + hasSep = parserInput.$char(';') === ';' + + if (hasSep || isSemiColonSeparated) { + if (expressionContainsNamed) { + error('Cannot mix ; and , as delimiter types') + } + + isSemiColonSeparated = true + + if (expressions.length > 1) { + value = new tree.Value(expressions) + } + argsSemiColon.push({ name, value, expand }) + + name = null + expressions = [] + expressionContainsNamed = false + } + } + + parserInput.forget() + returner.args = isSemiColonSeparated ? argsSemiColon : argsComma + return returner + }, + // + // A Mixin definition, with a list of parameters + // + // .rounded (@radius: 2px, @color) { + // ... + // } + // + // Until we have a finer grained state-machine, we have to + // do a look-ahead, to make sure we don't have a mixin call. + // See the `rule` function for more information. + // + // We start by matching `.rounded (`, and then proceed on to + // the argument list, which has optional default values. + // We store the parameters in `params`, with a `value` key, + // if there is a value, such as in the case of `@radius`. + // + // Once we've got our params list, and a closing `)`, we parse + // the `{...}` block. + // + definition: function () { + let name + let params = [] + let match + let ruleset + let cond + let variadic = false + if ( + (parserInput.currentChar() !== '.' && + parserInput.currentChar() !== '#') || + parserInput.peek(/^[^{]*\}/) + ) { + return + } + + parserInput.save() + + match = parserInput.$re( + /^([#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+)\s*\(/ + ) + if (match) { + name = match[1] + + const argInfo = this.args(false) + params = argInfo.args + variadic = argInfo.variadic + + // .mixincall("@{a}"); + // looks a bit like a mixin definition.. + // also + // .mixincall(@a: {rule: set;}); + // so we have to be nice and restore + if (!parserInput.$char(')')) { + parserInput.restore("Missing closing ')'") + return + } + + parserInput.commentStore.length = 0 + + if (parserInput.$str('when')) { + // Guard + cond = expect(parsers.conditions, 'expected condition') + } + + ruleset = parsers.block() + + if (ruleset) { + parserInput.forget() + return new tree.mixin.Definition( + name, + params, + ruleset, + cond, + variadic + ) + } else { + parserInput.restore() + } + } else { + parserInput.restore() + } + }, + + ruleLookups: function () { + let rule + const lookups = [] + + if (parserInput.currentChar() !== '[') { + return + } + + while (true) { + parserInput.save() + rule = this.lookupValue() + if (!rule && rule !== '') { + parserInput.restore() + break + } + lookups.push(rule) + parserInput.forget() + } + if (lookups.length > 0) { + return lookups + } + }, + + lookupValue: function () { + parserInput.save() + + if (!parserInput.$char('[')) { + parserInput.restore() + return + } + + const name = parserInput.$re(/^(?:[@$]{0,2})[_a-zA-Z0-9-]*/) + + if (!parserInput.$char(']')) { + parserInput.restore() + return + } + + if (name || name === '') { + parserInput.forget() + return name + } + + parserInput.restore() + } + }, + // + // Entities are the smallest recognized token, + // and can be found inside a rule's value. + // + entity: function () { + const entities = this.entities + + return ( + this.comment() || + entities.literal() || + entities.variable() || + entities.url() || + entities.property() || + entities.call() || + entities.keyword() || + this.mixin.call(true) || + entities.javascript() + ) + }, + + // + // A Declaration terminator. Note that we use `peek()` to check for '}', + // because the `block` rule will be expecting it, but we still need to make sure + // it's there, if ';' was omitted. + // + end: function () { + return parserInput.$char(';') || parserInput.peek('}') + }, + + // + // IE's alpha function + // + // alpha(opacity=88) + // + ieAlpha: function () { + let value + + // http://jsperf.com/case-insensitive-regex-vs-strtolower-then-regex/18 + if (!parserInput.$re(/^opacity=/i)) { + return + } + value = parserInput.$re(/^\d+/) + if (!value) { + value = expect(parsers.entities.variable, 'Could not parse alpha') + value = `@{${value.name.slice(1)}}` + } + expectChar(')') + return new tree.Quoted('', `alpha(opacity=${value})`) + }, + + // + // A Selector Element + // + // div + // + h1 + // #socks + // input[type="text"] + // + // Elements are the building blocks for Selectors, + // they are made out of a `Combinator` (see combinator rule), + // and an element name, such as a tag a class, or `*`. + // + element: function () { + let e + let c + let v + const index = parserInput.i + + c = this.combinator() + + e = + parserInput.$re(/^(?:\d+\.\d+|\d+)%/) || + // eslint-disable-next-line no-control-regex + parserInput.$re( + /^(?:[.#]?|:*)(?:[\w-]|[^\x00-\x9f]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/ + ) || + parserInput.$char('*') || + parserInput.$char('&') || + this.attribute() || + parserInput.$re(/^\([^&()@]+\)/) || + parserInput.$re(/^[.#:](?=@)/) || + this.entities.variableCurly() + + if (!e) { + parserInput.save() + if (parserInput.$char('(')) { + if ((v = this.selector(false)) && parserInput.$char(')')) { + e = new tree.Paren(v) + parserInput.forget() + } else { + parserInput.restore("Missing closing ')'") + } + } else { + parserInput.forget() + } + } + + if (e) { + return new tree.Element( + c, + e, + e instanceof tree.Variable, + index + currentIndex, + fileInfo + ) + } + }, + + // + // Combinators combine elements together, in a Selector. + // + // Because our parser isn't white-space sensitive, special care + // has to be taken, when parsing the descendant combinator, ` `, + // as it's an empty space. We have to check the previous character + // in the input, to see if it's a ` ` character. More info on how + // we deal with this in *combinator.js*. + // + combinator: function () { + let c = parserInput.currentChar() + + if (c === '/') { + parserInput.save() + const slashedCombinator = parserInput.$re(/^\/[a-z]+\//i) + if (slashedCombinator) { + parserInput.forget() + return new tree.Combinator(slashedCombinator) + } + parserInput.restore() + } + + if (c === '>' || c === '+' || c === '~' || c === '|' || c === '^') { + parserInput.i++ + if (c === '^' && parserInput.currentChar() === '^') { + c = '^^' + parserInput.i++ + } + while (parserInput.isWhitespace()) { + parserInput.i++ + } + return new tree.Combinator(c) + } else if (parserInput.isWhitespace(-1)) { + return new tree.Combinator(' ') + } else { + return new tree.Combinator(null) + } + }, + // + // A CSS Selector + // with less extensions e.g. the ability to extend and guard + // + // .class > div + h1 + // li a:hover + // + // Selectors are made out of one or more Elements, see above. + // + selector: function (isLess) { + const index = parserInput.i + let elements + let extendList + let c + let e + let allExtends + let when + let condition + isLess = isLess !== false + while ( + (isLess && (extendList = this.extend())) || + (isLess && (when = parserInput.$str('when'))) || + (e = this.element()) + ) { + if (when) { + condition = expect(this.conditions, 'expected condition') + } else if (condition) { + error('CSS guard can only be used at the end of selector') + } else if (extendList) { + if (allExtends) { + allExtends = allExtends.concat(extendList) + } else { + allExtends = extendList + } + } else { + if (allExtends) { + error('Extend can only be used at the end of selector') + } + c = parserInput.currentChar() + if (elements) { + elements.push(e) + } else { + elements = [e] + } + e = null + } + if (c === '{' || c === '}' || c === ';' || c === ',' || c === ')') { + break + } + } + + if (elements) { + return new tree.Selector( + elements, + allExtends, + condition, + index + currentIndex, + fileInfo + ) + } + if (allExtends) { + error( + 'Extend must be used to extend a selector, it cannot be used on its own' + ) + } + }, + selectors: function () { + let s + let selectors + while (true) { + s = this.selector() + if (!s) { + break + } + if (selectors) { + selectors.push(s) + } else { + selectors = [s] + } + parserInput.commentStore.length = 0 + if (s.condition && selectors.length > 1) { + error('Guards are only currently allowed on a single selector.') + } + if (!parserInput.$char(',')) { + break + } + if (s.condition) { + error('Guards are only currently allowed on a single selector.') + } + parserInput.commentStore.length = 0 + } + return selectors + }, + attribute: function () { + if (!parserInput.$char('[')) { + return + } + + const entities = this.entities + let key + let val + let op + // + // case-insensitive flag + // e.g. [attr operator value i] + // + let cif + + if (!(key = entities.variableCurly())) { + key = expect(/^(?:[_A-Za-z0-9-*]*\|)?(?:[_A-Za-z0-9-]|\\.)+/) + } + + op = parserInput.$re(/^[|~*$^]?=/) + if (op) { + val = + entities.quoted() || + parserInput.$re(/^[0-9]+%/) || + parserInput.$re(/^[\w-]+/) || + entities.variableCurly() + if (val) { + cif = parserInput.$re(/^[iIsS]/) + } + } + + expectChar(']') + + return new tree.Attribute(key, op, val, cif) + }, + + // + // The `block` rule is used by `ruleset` and `mixin.definition`. + // It's a wrapper around the `primary` rule, with added `{}`. + // + block: function () { + let content + if ( + parserInput.$char('{') && + (content = this.primary()) && + parserInput.$char('}') + ) { + return content + } + }, + + blockRuleset: function () { + let block = this.block() + + if (block) { + block = new tree.Ruleset(null, block) + } + return block + }, + + detachedRuleset: function () { + let argInfo + let params + let variadic + + parserInput.save() + if (parserInput.$re(/^[.#]\(/)) { + /** + * DR args currently only implemented for each() function, and not + * yet settable as `@dr: #(@arg) {}` + * This should be done when DRs are merged with mixins. + * See: https://github.com/less/less-meta/issues/16 + */ + argInfo = this.mixin.args(false) + params = argInfo.args + variadic = argInfo.variadic + if (!parserInput.$char(')')) { + parserInput.restore() + return + } + } + const blockRuleset = this.blockRuleset() + if (blockRuleset) { + parserInput.forget() + if (params) { + return new tree.mixin.Definition( + null, + params, + blockRuleset, + null, + variadic + ) + } + return new tree.DetachedRuleset(blockRuleset) + } + parserInput.restore() + }, + + // + // div, .class, body > p {...} + // + ruleset: function () { + let selectors + let rules + let debugInfo + + parserInput.save() + + if (context.dumpLineNumbers) { + debugInfo = getDebugInfo(parserInput.i) + } + + selectors = this.selectors() + + if (selectors && (rules = this.block())) { + parserInput.forget() + const ruleset = new tree.Ruleset( + selectors, + rules, + context.strictImports + ) + if (context.dumpLineNumbers) { + ruleset.debugInfo = debugInfo + } + return ruleset + } else { + parserInput.restore() + } + }, + declaration: function () { + let name + let value + const index = parserInput.i + let hasDR + const c = parserInput.currentChar() + let important + let merge + let isVariable + + if (c === '.' || c === '#' || c === '&' || c === ':') { + return + } + + parserInput.save() + + name = this.variable() || this.ruleProperty() + if (name) { + isVariable = typeof name === 'string' + + if (isVariable) { + value = this.detachedRuleset() + if (value) { + hasDR = true + } + } + + parserInput.commentStore.length = 0 + if (!value) { + // a name returned by this.ruleProperty() is always an array of the form: + // [string-1, ..., string-n, ""] or [string-1, ..., string-n, "+"] + // where each item is a tree.Keyword or tree.Variable + merge = !isVariable && name.length > 1 && name.pop().value + + // Custom property values get permissive parsing + if (name[0].value && name[0].value.slice(0, 2) === '--') { + value = this.permissiveValue(/[;}]/) + } + // Try to store values as anonymous + // If we need the value later we'll re-parse it in ruleset.parseValue + else { + value = this.anonymousValue() + } + if (value) { + parserInput.forget() + // anonymous values absorb the end ';' which is required for them to work + return new tree.Declaration( + name, + value, + false, + merge, + index + currentIndex, + fileInfo + ) + } + + if (!value) { + value = this.value() + } + + if (value) { + important = this.important() + } else if (isVariable) { + // As a last resort, try permissiveValue + value = this.permissiveValue() + } + } + + if (value && (this.end() || hasDR)) { + parserInput.forget() + return new tree.Declaration( + name, + value, + important, + merge, + index + currentIndex, + fileInfo + ) + } else { + parserInput.restore() + } + } else { + parserInput.restore() + } + }, + anonymousValue: function () { + const index = parserInput.i + const match = parserInput.$re(/^([^.#@$+/'"*`(;{}-]*);/) + if (match) { + return new tree.Anonymous(match[1], index + currentIndex) + } + }, + /** + * Used for custom properties, at-rules, and variables (as fallback) + * Parses almost anything inside of {} [] () "" blocks + * until it reaches outer-most tokens. + * + * First, it will try to parse comments and entities to reach + * the end. This is mostly like the Expression parser except no + * math is allowed. + */ + permissiveValue: function (untilTokens) { + let i + let e + let done + let value + const tok = untilTokens || ';' + const index = parserInput.i + const result = [] + + function testCurrentChar() { + const char = parserInput.currentChar() + if (typeof tok === 'string') { + return char === tok + } else { + return tok.test(char) + } + } + if (testCurrentChar()) { + return + } + value = [] + do { + e = this.comment() + if (e) { + value.push(e) + continue + } + e = this.entity() + if (e) { + value.push(e) + } + } while (e) + + done = testCurrentChar() + + if (value.length > 0) { + value = new tree.Expression(value) + if (done) { + return value + } else { + result.push(value) + } + // Preserve space before $parseUntil as it will not + if (parserInput.prevChar() === ' ') { + result.push(new tree.Anonymous(' ', index)) + } + } + parserInput.save() + + value = parserInput.$parseUntil(tok) + + if (value) { + if (typeof value === 'string') { + error(`Expected '${value}'`, 'Parse') + } + if (value.length === 1 && value[0] === ' ') { + parserInput.forget() + return new tree.Anonymous('', index) + } + let item + for (i = 0; i < value.length; i++) { + item = value[i] + if (Array.isArray(item)) { + // Treat actual quotes as normal quoted values + result.push( + new tree.Quoted(item[0], item[1], true, index, fileInfo) + ) + } else { + if (i === value.length - 1) { + item = item.trim() + } + // Treat like quoted values, but replace vars like unquoted expressions + const quote = new tree.Quoted("'", item, true, index, fileInfo) + quote.variableRegex = /@([\w-]+)/g + quote.propRegex = /\$([\w-]+)/g + result.push(quote) + } + } + parserInput.forget() + return new tree.Expression(result, true) + } + parserInput.restore() + }, + + // + // An @import atrule + // + // @import "lib"; + // + // Depending on our environment, importing is done differently: + // In the browser, it's an XHR request, in Node, it would be a + // file-system operation. The function used for importing is + // stored in `import`, which we pass to the Import constructor. + // + import: function () { + let path + let features + const index = parserInput.i + + const dir = parserInput.$re(/^@import\s+/) + + if (dir) { + const options = (dir ? this.importOptions() : null) || {} + + if ((path = this.entities.quoted() || this.entities.url())) { + features = this.mediaFeatures() + + if (!parserInput.$char(';')) { + parserInput.i = index + error( + 'missing semi-colon or unrecognised media features on import' + ) + } + features = features && new tree.Value(features) + return new tree.Import( + path, + features, + options, + index + currentIndex, + fileInfo + ) + } else { + parserInput.i = index + error('malformed import statement') + } + } + }, + + importOptions: function () { + let o + const options = {} + let optionName + let value + + // list of options, surrounded by parens + if (!parserInput.$char('(')) { + return null + } + do { + o = this.importOption() + if (o) { + optionName = o + value = true + switch (optionName) { + case 'css': + optionName = 'less' + value = false + break + case 'once': + optionName = 'multiple' + value = false + break + } + options[optionName] = value + if (!parserInput.$char(',')) { + break + } + } + } while (o) + expectChar(')') + return options + }, + + importOption: function () { + const opt = parserInput.$re( + /^(less|css|multiple|once|inline|reference|optional)/ + ) + if (opt) { + return opt[1] + } + }, + + mediaFeature: function () { + const entities = this.entities + const nodes = [] + let e + let p + parserInput.save() + do { + e = + entities.keyword() || entities.variable() || entities.mixinLookup() + if (e) { + nodes.push(e) + } else if (parserInput.$char('(')) { + p = this.property() + e = this.value() + if (parserInput.$char(')')) { + if (p && e) { + nodes.push( + new tree.Paren( + new tree.Declaration( + p, + e, + null, + null, + parserInput.i + currentIndex, + fileInfo, + true + ) + ) + ) + } else if (e) { + nodes.push(new tree.Paren(e)) + } else { + error('badly formed media feature definition') + } + } else { + error("Missing closing ')'", 'Parse') + } + } + } while (e) + + parserInput.forget() + if (nodes.length > 0) { + return new tree.Expression(nodes) + } + }, + + mediaFeatures: function () { + const entities = this.entities + const features = [] + let e + do { + e = this.mediaFeature() + if (e) { + features.push(e) + if (!parserInput.$char(',')) { + break + } + } else { + e = entities.variable() || entities.mixinLookup() + if (e) { + features.push(e) + if (!parserInput.$char(',')) { + break + } + } + } + } while (e) + + return features.length > 0 ? features : null + }, + + media: function () { + let features + let rules + let media + let debugInfo + const index = parserInput.i + + if (context.dumpLineNumbers) { + debugInfo = getDebugInfo(index) + } + + parserInput.save() + + if (parserInput.$str('@media')) { + features = this.mediaFeatures() + + rules = this.block() + + if (!rules) { + error( + 'media definitions require block statements after any features' + ) + } + + parserInput.forget() + + media = new tree.Media( + rules, + features, + index + currentIndex, + fileInfo + ) + if (context.dumpLineNumbers) { + media.debugInfo = debugInfo + } + + return media + } + + parserInput.restore() + }, + + // + + // A @plugin directive, used to import plugins dynamically. + // + // @plugin (args) "lib"; + // + plugin: function () { + let path + let args + let options + const index = parserInput.i + const dir = parserInput.$re(/^@plugin\s+/) + + if (dir) { + args = this.pluginArgs() + + if (args) { + options = { + pluginArgs: args, + isPlugin: true + } + } else { + options = { isPlugin: true } + } + + if ((path = this.entities.quoted() || this.entities.url())) { + if (!parserInput.$char(';')) { + parserInput.i = index + error('missing semi-colon on @plugin') + } + return new tree.Import( + path, + null, + options, + index + currentIndex, + fileInfo + ) + } else { + parserInput.i = index + error('malformed @plugin statement') + } + } + }, + + pluginArgs: function () { + // list of options, surrounded by parens + parserInput.save() + if (!parserInput.$char('(')) { + parserInput.restore() + return null + } + const args = parserInput.$re(/^\s*([^);]+)\)\s*/) + if (args[1]) { + parserInput.forget() + return args[1].trim() + } else { + parserInput.restore() + return null + } + }, + + // + // A CSS AtRule + // + // @charset "utf-8"; + // + atrule: function () { + const index = parserInput.i + let name + let value + let rules + let nonVendorSpecificName + let hasIdentifier + let hasExpression + let hasUnknown + let hasBlock = true + let isRooted = true + + if (parserInput.currentChar() !== '@') { + return + } + + value = this['import']() || this.plugin() || this.media() + if (value) { + return value + } + + parserInput.save() + + name = parserInput.$re(/^@[a-z-]+/) + + if (!name) { + return + } + + nonVendorSpecificName = name + if (name.charAt(1) == '-' && name.indexOf('-', 2) > 0) { + nonVendorSpecificName = `@${name.slice(name.indexOf('-', 2) + 1)}` + } + + switch (nonVendorSpecificName) { + case '@charset': + hasIdentifier = true + hasBlock = false + break + case '@namespace': + hasExpression = true + hasBlock = false + break + case '@keyframes': + case '@counter-style': + hasIdentifier = true + break + case '@document': + case '@supports': + hasUnknown = true + isRooted = false + break + default: + hasUnknown = true + break + } + + parserInput.commentStore.length = 0 + + if (hasIdentifier) { + value = this.entity() + if (!value) { + error(`expected ${name} identifier`) + } + } else if (hasExpression) { + value = this.expression() + if (!value) { + error(`expected ${name} expression`) + } + } else if (hasUnknown) { + value = this.permissiveValue(/^[{;]/) + hasBlock = parserInput.currentChar() === '{' + if (!value) { + if (!hasBlock && parserInput.currentChar() !== ';') { + error(`${name} rule is missing block or ending semi-colon`) + } + } else if (!value.value) { + value = null + } + } + + if (hasBlock) { + rules = this.blockRuleset() + } + + if (rules || (!hasBlock && value && parserInput.$char(';'))) { + parserInput.forget() + return new tree.AtRule( + name, + value, + rules, + index + currentIndex, + fileInfo, + context.dumpLineNumbers ? getDebugInfo(index) : null, + isRooted + ) + } + + parserInput.restore('at-rule options not recognised') + }, + + // + // A Value is a comma-delimited list of Expressions + // + // font-family: Baskerville, Georgia, serif; + // + // In a Rule, a Value represents everything after the `:`, + // and before the `;`. + // + value: function () { + let e + const expressions = [] + const index = parserInput.i + + do { + e = this.expression() + if (e) { + expressions.push(e) + if (!parserInput.$char(',')) { + break + } + } + } while (e) + + if (expressions.length > 0) { + return new tree.Value(expressions, index + currentIndex) + } + }, + important: function () { + if (parserInput.currentChar() === '!') { + return parserInput.$re(/^! *important/) + } + }, + sub: function () { + let a + let e + + parserInput.save() + if (parserInput.$char('(')) { + a = this.addition() + if (a && parserInput.$char(')')) { + parserInput.forget() + e = new tree.Expression([a]) + e.parens = true + return e + } + parserInput.restore("Expected ')'") + return + } + parserInput.restore() + }, + multiplication: function () { + let m + let a + let op + let operation + let isSpaced + m = this.operand() + if (m) { + isSpaced = parserInput.isWhitespace(-1) + while (true) { + if (parserInput.peek(/^\/[*/]/)) { + break + } + + parserInput.save() + + op = + parserInput.$char('/') || + parserInput.$char('*') || + parserInput.$str('./') + + if (!op) { + parserInput.forget() + break + } + + a = this.operand() + + if (!a) { + parserInput.restore() + break + } + parserInput.forget() + + m.parensInOp = true + a.parensInOp = true + operation = new tree.Operation(op, [operation || m, a], isSpaced) + isSpaced = parserInput.isWhitespace(-1) + } + return operation || m + } + }, + addition: function () { + let m + let a + let op + let operation + let isSpaced + m = this.multiplication() + if (m) { + isSpaced = parserInput.isWhitespace(-1) + while (true) { + op = + parserInput.$re(/^[-+]\s+/) || + (!isSpaced && (parserInput.$char('+') || parserInput.$char('-'))) + if (!op) { + break + } + a = this.multiplication() + if (!a) { + break + } + + m.parensInOp = true + a.parensInOp = true + operation = new tree.Operation(op, [operation || m, a], isSpaced) + isSpaced = parserInput.isWhitespace(-1) + } + return operation || m + } + }, + conditions: function () { + let a + let b + const index = parserInput.i + let condition + + a = this.condition(true) + if (a) { + while (true) { + if ( + !parserInput.peek(/^,\s*(not\s*)?\(/) || + !parserInput.$char(',') + ) { + break + } + b = this.condition(true) + if (!b) { + break + } + condition = new tree.Condition( + 'or', + condition || a, + b, + index + currentIndex + ) + } + return condition || a + } + }, + condition: function (needsParens) { + let result + let logical + let next + function or() { + return parserInput.$str('or') + } + + result = this.conditionAnd(needsParens) + if (!result) { + return + } + logical = or() + if (logical) { + next = this.condition(needsParens) + if (next) { + result = new tree.Condition(logical, result, next) + } else { + return + } + } + return result + }, + conditionAnd: function (needsParens) { + let result + let logical + let next + const self = this + function insideCondition() { + const cond = + self.negatedCondition(needsParens) || + self.parenthesisCondition(needsParens) + if (!cond && !needsParens) { + return self.atomicCondition(needsParens) + } + return cond + } + function and() { + return parserInput.$str('and') + } + + result = insideCondition() + if (!result) { + return + } + logical = and() + if (logical) { + next = this.conditionAnd(needsParens) + if (next) { + result = new tree.Condition(logical, result, next) + } else { + return + } + } + return result + }, + negatedCondition: function (needsParens) { + if (parserInput.$str('not')) { + const result = this.parenthesisCondition(needsParens) + if (result) { + result.negate = !result.negate + } + return result + } + }, + parenthesisCondition: function (needsParens) { + function tryConditionFollowedByParenthesis(me) { + let body + parserInput.save() + body = me.condition(needsParens) + if (!body) { + parserInput.restore() + return + } + if (!parserInput.$char(')')) { + parserInput.restore() + return + } + parserInput.forget() + return body + } + + let body + parserInput.save() + if (!parserInput.$str('(')) { + parserInput.restore() + return + } + body = tryConditionFollowedByParenthesis(this) + if (body) { + parserInput.forget() + return body + } + + body = this.atomicCondition(needsParens) + if (!body) { + parserInput.restore() + return + } + if (!parserInput.$char(')')) { + parserInput.restore(`expected ')' got '${parserInput.currentChar()}'`) + return + } + parserInput.forget() + return body + }, + atomicCondition: function () { + const entities = this.entities + const index = parserInput.i + let a + let b + let c + let op + + const cond = function () { + return ( + this.addition() || + entities.keyword() || + entities.quoted() || + entities.mixinLookup() + ) + }.bind(this) + + a = cond() + if (a) { + if (parserInput.$char('>')) { + if (parserInput.$char('=')) { + op = '>=' + } else { + op = '>' + } + } else if (parserInput.$char('<')) { + if (parserInput.$char('=')) { + op = '<=' + } else { + op = '<' + } + } else if (parserInput.$char('=')) { + if (parserInput.$char('>')) { + op = '=>' + } else if (parserInput.$char('<')) { + op = '=<' + } else { + op = '=' + } + } + if (op) { + b = cond() + if (b) { + c = new tree.Condition(op, a, b, index + currentIndex, false) + } else { + error('expected expression') + } + } else { + c = new tree.Condition( + '=', + a, + new tree.Keyword('true'), + index + currentIndex, + false + ) + } + return c + } + }, + + // + // An operand is anything that can be part of an operation, + // such as a Color, or a Variable + // + operand: function () { + const entities = this.entities + let negate + + if (parserInput.peek(/^-[@$(]/)) { + negate = parserInput.$char('-') + } + + let o = + this.sub() || + entities.dimension() || + entities.color() || + entities.variable() || + entities.property() || + entities.call() || + entities.quoted(true) || + entities.colorKeyword() || + entities.mixinLookup() + + if (negate) { + o.parensInOp = true + o = new tree.Negative(o) + } + + return o + }, + + // + // Expressions either represent mathematical operations, + // or white-space delimited Entities. + // + // 1px solid black + // @var * 2 + // + expression: function () { + const entities = [] + let e + let delim + const index = parserInput.i + + do { + e = this.comment() + if (e) { + entities.push(e) + continue + } + e = this.addition() || this.entity() + + if (e instanceof tree.Comment) { + e = null + } + + if (e) { + entities.push(e) + // operations do not allow keyword "/" dimension (e.g. small/20px) so we support that here + if (!parserInput.peek(/^\/[/*]/)) { + delim = parserInput.$char('/') + if (delim) { + entities.push(new tree.Anonymous(delim, index + currentIndex)) + } + } + } + } while (e) + if (entities.length > 0) { + return new tree.Expression(entities) + } + }, + property: function () { + const name = parserInput.$re(/^(\*?-?[_a-zA-Z0-9-]+)\s*:/) + if (name) { + return name[1] + } + }, + ruleProperty: function () { + let name = [] + const index = [] + let s + let k + + parserInput.save() + + const simpleProperty = parserInput.$re(/^([_a-zA-Z0-9-]+)\s*:/) + if (simpleProperty) { + name = [new tree.Keyword(simpleProperty[1])] + parserInput.forget() + return name + } + + function match(re) { + const i = parserInput.i + const chunk = parserInput.$re(re) + if (chunk) { + index.push(i) + return name.push(chunk[1]) + } + } + + match(/^(\*?)/) + while (true) { + if (!match(/^((?:[\w-]+)|(?:[@$]\{[\w-]+\}))/)) { + break + } + } + + if (name.length > 1 && match(/^((?:\+_|\+)?)\s*:/)) { + parserInput.forget() + + // at last, we have the complete match now. move forward, + // convert name particles to tree objects and return: + if (name[0] === '') { + name.shift() + index.shift() + } + for (k = 0; k < name.length; k++) { + s = name[k] + name[k] = + s.charAt(0) !== '@' && s.charAt(0) !== '$' + ? new tree.Keyword(s) + : s.charAt(0) === '@' + ? new tree.Variable( + `@${s.slice(2, -1)}`, + index[k] + currentIndex, + fileInfo + ) + : new tree.Property( + `$${s.slice(2, -1)}`, + index[k] + currentIndex, + fileInfo + ) + } + return name + } + parserInput.restore() + } + }) + } +} +Parser.serializeVars = vars => { + let s = '' + + for (const name in vars) { + if (Object.hasOwnProperty.call(vars, name)) { + const value = vars[name] + s += `${(name[0] === '@' ? '' : '@') + name}: ${value}${ + String(value).slice(-1) === ';' ? '' : ';' + }` + } + } + + return s +} + +export default Parser diff --git a/src/less/plugin-manager.js b/src/less/plugin-manager.js new file mode 100644 index 0000000..f19355f --- /dev/null +++ b/src/less/plugin-manager.js @@ -0,0 +1,180 @@ +/** + * Plugin Manager + */ +class PluginManager { + constructor(less) { + this.less = less + this.visitors = [] + this.preProcessors = [] + this.postProcessors = [] + this.installedPlugins = [] + this.fileManagers = [] + this.iterator = -1 + this.pluginCache = {} + this.Loader = new less.PluginLoader(less) + } + + /** + * Adds all the plugins in the array + * @param {Array} plugins + */ + addPlugins(plugins) { + if (plugins) { + for (let i = 0; i < plugins.length; i++) { + this.addPlugin(plugins[i]) + } + } + } + + /** + * + * @param plugin + * @param {String} filename + */ + addPlugin(plugin, filename, functionRegistry) { + this.installedPlugins.push(plugin) + if (filename) { + this.pluginCache[filename] = plugin + } + if (plugin.install) { + plugin.install( + this.less, + this, + functionRegistry || this.less.functions.functionRegistry + ) + } + } + + /** + * + * @param filename + */ + get(filename) { + return this.pluginCache[filename] + } + + /** + * Adds a visitor. The visitor object has options on itself to determine + * when it should run. + * @param visitor + */ + addVisitor(visitor) { + this.visitors.push(visitor) + } + + /** + * Adds a pre processor object + * @param {object} preProcessor + * @param {number} priority - guidelines 1 = before import, 1000 = import, 2000 = after import + */ + addPreProcessor(preProcessor, priority) { + let indexToInsertAt + for ( + indexToInsertAt = 0; + indexToInsertAt < this.preProcessors.length; + indexToInsertAt++ + ) { + if (this.preProcessors[indexToInsertAt].priority >= priority) { + break + } + } + this.preProcessors.splice(indexToInsertAt, 0, { preProcessor, priority }) + } + + /** + * Adds a post processor object + * @param {object} postProcessor + * @param {number} priority - guidelines 1 = before compression, 1000 = compression, 2000 = after compression + */ + addPostProcessor(postProcessor, priority) { + let indexToInsertAt + for ( + indexToInsertAt = 0; + indexToInsertAt < this.postProcessors.length; + indexToInsertAt++ + ) { + if (this.postProcessors[indexToInsertAt].priority >= priority) { + break + } + } + this.postProcessors.splice(indexToInsertAt, 0, { postProcessor, priority }) + } + + /** + * + * @param manager + */ + addFileManager(manager) { + this.fileManagers.push(manager) + } + + /** + * + * @returns {Array} + * @private + */ + getPreProcessors() { + const preProcessors = [] + for (let i = 0; i < this.preProcessors.length; i++) { + preProcessors.push(this.preProcessors[i].preProcessor) + } + return preProcessors + } + + /** + * + * @returns {Array} + * @private + */ + getPostProcessors() { + const postProcessors = [] + for (let i = 0; i < this.postProcessors.length; i++) { + postProcessors.push(this.postProcessors[i].postProcessor) + } + return postProcessors + } + + /** + * + * @returns {Array} + * @private + */ + getVisitors() { + return this.visitors + } + + visitor() { + const self = this + return { + first: function () { + self.iterator = -1 + return self.visitors[self.iterator] + }, + get: function () { + self.iterator += 1 + return self.visitors[self.iterator] + } + } + } + + /** + * + * @returns {Array} + * @private + */ + getFileManagers() { + return this.fileManagers + } +} + +let pm + +const PluginManagerFactory = function (less, newFactory) { + if (newFactory || !pm) { + pm = new PluginManager(less) + } + return pm +} + +// +export default PluginManagerFactory diff --git a/src/less/render.js b/src/less/render.js new file mode 100644 index 0000000..9e2f7b6 --- /dev/null +++ b/src/less/render.js @@ -0,0 +1,43 @@ +import * as utils from './utils' + +export default function (environment, ParseTree) { + const render = function (input, options, callback) { + if (typeof options === 'function') { + callback = options + options = utils.copyOptions(this.options, {}) + } else { + options = utils.copyOptions(this.options, options || {}) + } + + if (!callback) { + const self = this + return new Promise(function (resolve, reject) { + render.call(self, input, options, function (err, output) { + if (err) { + reject(err) + } else { + resolve(output) + } + }) + }) + } else { + this.parse(input, options, function (err, root, imports, options) { + if (err) { + return callback(err) + } + + let result + try { + const parseTree = new ParseTree(root, imports) + result = parseTree.toCSS(options) + } catch (err) { + return callback(err) + } + + callback(null, result) + }) + } + } + + return render +} diff --git a/src/less/source-map-builder.js b/src/less/source-map-builder.js new file mode 100644 index 0000000..6d191e6 --- /dev/null +++ b/src/less/source-map-builder.js @@ -0,0 +1,87 @@ +export default function (SourceMapOutput, environment) { + class SourceMapBuilder { + constructor(options) { + this.options = options + } + + toCSS(rootNode, options, imports) { + const sourceMapOutput = new SourceMapOutput({ + contentsIgnoredCharsMap: imports.contentsIgnoredChars, + rootNode, + contentsMap: imports.contents, + sourceMapFilename: this.options.sourceMapFilename, + sourceMapURL: this.options.sourceMapURL, + outputFilename: this.options.sourceMapOutputFilename, + sourceMapBasepath: this.options.sourceMapBasepath, + sourceMapRootpath: this.options.sourceMapRootpath, + outputSourceFiles: this.options.outputSourceFiles, + sourceMapGenerator: this.options.sourceMapGenerator, + sourceMapFileInline: this.options.sourceMapFileInline, + disableSourcemapAnnotation: this.options.disableSourcemapAnnotation + }) + + const css = sourceMapOutput.toCSS(options) + this.sourceMap = sourceMapOutput.sourceMap + this.sourceMapURL = sourceMapOutput.sourceMapURL + if (this.options.sourceMapInputFilename) { + this.sourceMapInputFilename = sourceMapOutput.normalizeFilename( + this.options.sourceMapInputFilename + ) + } + if ( + this.options.sourceMapBasepath !== undefined && + this.sourceMapURL !== undefined + ) { + this.sourceMapURL = sourceMapOutput.removeBasepath(this.sourceMapURL) + } + return css + this.getCSSAppendage() + } + + getCSSAppendage() { + let sourceMapURL = this.sourceMapURL + if (this.options.sourceMapFileInline) { + if (this.sourceMap === undefined) { + return '' + } + sourceMapURL = `data:application/json;base64,${environment.encodeBase64( + this.sourceMap + )}` + } + + if (this.options.disableSourcemapAnnotation) { + return '' + } + + if (sourceMapURL) { + return `/*# sourceMappingURL=${sourceMapURL} */` + } + return '' + } + + getExternalSourceMap() { + return this.sourceMap + } + + setExternalSourceMap(sourceMap) { + this.sourceMap = sourceMap + } + + isInline() { + return this.options.sourceMapFileInline + } + + getSourceMapURL() { + return this.sourceMapURL + } + + getOutputFilename() { + return this.options.sourceMapOutputFilename + } + + getInputFilename() { + return this.sourceMapInputFilename + } + } + + return SourceMapBuilder +} diff --git a/src/less/source-map-output.js b/src/less/source-map-output.js new file mode 100644 index 0000000..2dc3c6d --- /dev/null +++ b/src/less/source-map-output.js @@ -0,0 +1,181 @@ +export default function (environment) { + class SourceMapOutput { + constructor(options) { + this._css = [] + this._rootNode = options.rootNode + this._contentsMap = options.contentsMap + this._contentsIgnoredCharsMap = options.contentsIgnoredCharsMap + if (options.sourceMapFilename) { + this._sourceMapFilename = options.sourceMapFilename.replace(/\\/g, '/') + } + this._outputFilename = options.outputFilename + this.sourceMapURL = options.sourceMapURL + if (options.sourceMapBasepath) { + this._sourceMapBasepath = options.sourceMapBasepath.replace(/\\/g, '/') + } + if (options.sourceMapRootpath) { + this._sourceMapRootpath = options.sourceMapRootpath.replace(/\\/g, '/') + if ( + this._sourceMapRootpath.charAt(this._sourceMapRootpath.length - 1) !== + '/' + ) { + this._sourceMapRootpath += '/' + } + } else { + this._sourceMapRootpath = '' + } + this._outputSourceFiles = options.outputSourceFiles + this._sourceMapGeneratorConstructor = environment.getSourceMapGenerator() + + this._lineNumber = 0 + this._column = 0 + } + + removeBasepath(path) { + if ( + this._sourceMapBasepath && + path.indexOf(this._sourceMapBasepath) === 0 + ) { + path = path.substring(this._sourceMapBasepath.length) + if (path.charAt(0) === '\\' || path.charAt(0) === '/') { + path = path.substring(1) + } + } + + return path + } + + normalizeFilename(filename) { + filename = filename.replace(/\\/g, '/') + filename = this.removeBasepath(filename) + return (this._sourceMapRootpath || '') + filename + } + + add(chunk, fileInfo, index, mapLines) { + // ignore adding empty strings + if (!chunk) { + return + } + + let lines, sourceLines, columns, sourceColumns, i + + if (fileInfo && fileInfo.filename) { + let inputSource = this._contentsMap[fileInfo.filename] + + // remove vars/banner added to the top of the file + if (this._contentsIgnoredCharsMap[fileInfo.filename]) { + // adjust the index + index -= this._contentsIgnoredCharsMap[fileInfo.filename] + if (index < 0) { + index = 0 + } + // adjust the source + inputSource = inputSource.slice( + this._contentsIgnoredCharsMap[fileInfo.filename] + ) + } + + /** + * ignore empty content, or failsafe + * if contents map is incorrect + */ + if (inputSource === undefined) { + this._css.push(chunk) + return + } + + inputSource = inputSource.substring(0, index) + sourceLines = inputSource.split('\n') + sourceColumns = sourceLines[sourceLines.length - 1] + } + + lines = chunk.split('\n') + columns = lines[lines.length - 1] + + if (fileInfo && fileInfo.filename) { + if (!mapLines) { + this._sourceMapGenerator.addMapping({ + generated: { line: this._lineNumber + 1, column: this._column }, + original: { + line: sourceLines.length, + column: sourceColumns.length + }, + source: this.normalizeFilename(fileInfo.filename) + }) + } else { + for (i = 0; i < lines.length; i++) { + this._sourceMapGenerator.addMapping({ + generated: { + line: this._lineNumber + i + 1, + column: i === 0 ? this._column : 0 + }, + original: { + line: sourceLines.length + i, + column: i === 0 ? sourceColumns.length : 0 + }, + source: this.normalizeFilename(fileInfo.filename) + }) + } + } + } + + if (lines.length === 1) { + this._column += columns.length + } else { + this._lineNumber += lines.length - 1 + this._column = columns.length + } + + this._css.push(chunk) + } + + isEmpty() { + return this._css.length === 0 + } + + toCSS(context) { + this._sourceMapGenerator = new this._sourceMapGeneratorConstructor({ + file: this._outputFilename, + sourceRoot: null + }) + + if (this._outputSourceFiles) { + for (const filename in this._contentsMap) { + // eslint-disable-next-line no-prototype-builtins + if (this._contentsMap.hasOwnProperty(filename)) { + let source = this._contentsMap[filename] + if (this._contentsIgnoredCharsMap[filename]) { + source = source.slice(this._contentsIgnoredCharsMap[filename]) + } + this._sourceMapGenerator.setSourceContent( + this.normalizeFilename(filename), + source + ) + } + } + } + + this._rootNode.genCSS(context, this) + + if (this._css.length > 0) { + let sourceMapURL + const sourceMapContent = JSON.stringify( + this._sourceMapGenerator.toJSON() + ) + + if (this.sourceMapURL) { + sourceMapURL = this.sourceMapURL + } else if (this._sourceMapFilename) { + sourceMapURL = this._sourceMapFilename + } + this.sourceMapURL = sourceMapURL + + this.sourceMap = sourceMapContent + } + + return this._css.join('') + } + } + + return SourceMapOutput +} diff --git a/src/less/transform-tree.js b/src/less/transform-tree.js new file mode 100644 index 0000000..471fdeb --- /dev/null +++ b/src/less/transform-tree.js @@ -0,0 +1,95 @@ +import contexts from './contexts' +import visitor from './visitors' +import tree from './tree' + +export default function (root, options) { + options = options || {} + let evaldRoot + let variables = options.variables + const evalEnv = new contexts.Eval(options) + + // + // Allows setting variables with a hash, so: + // + // `{ color: new tree.Color('#f01') }` will become: + // + // new tree.Declaration('@color', + // new tree.Value([ + // new tree.Expression([ + // new tree.Color('#f01') + // ]) + // ]) + // ) + // + if (typeof variables === 'object' && !Array.isArray(variables)) { + variables = Object.keys(variables).map(function (k) { + let value = variables[k] + + if (!(value instanceof tree.Value)) { + if (!(value instanceof tree.Expression)) { + value = new tree.Expression([value]) + } + value = new tree.Value([value]) + } + return new tree.Declaration(`@${k}`, value, false, null, 0) + }) + evalEnv.frames = [new tree.Ruleset(null, variables)] + } + + const visitors = [ + new visitor.JoinSelectorVisitor(), + new visitor.MarkVisibleSelectorsVisitor(true), + new visitor.ExtendVisitor(), + new visitor.ToCSSVisitor({ compress: Boolean(options.compress) }) + ] + + const preEvalVisitors = [] + let v + let visitorIterator + + /** + * first() / get() allows visitors to be added while visiting + * + * @todo Add scoping for visitors just like functions for @plugin; right now they're global + */ + if (options.pluginManager) { + visitorIterator = options.pluginManager.visitor() + for (let i = 0; i < 2; i++) { + visitorIterator.first() + while ((v = visitorIterator.get())) { + if (v.isPreEvalVisitor) { + if (i === 0 || preEvalVisitors.indexOf(v) === -1) { + preEvalVisitors.push(v) + v.run(root) + } + } else { + if (i === 0 || visitors.indexOf(v) === -1) { + if (v.isPreVisitor) { + visitors.unshift(v) + } else { + visitors.push(v) + } + } + } + } + } + } + + evaldRoot = root.eval(evalEnv) + + for (let i = 0; i < visitors.length; i++) { + visitors[i].run(evaldRoot) + } + + // Run any remaining visitors added after eval pass + if (options.pluginManager) { + visitorIterator.first() + while ((v = visitorIterator.get())) { + if (visitors.indexOf(v) === -1 && preEvalVisitors.indexOf(v) === -1) { + v.run(evaldRoot) + } + } + } + + return evaldRoot +} diff --git a/src/less/tree/anonymous.js b/src/less/tree/anonymous.js new file mode 100644 index 0000000..6d48551 --- /dev/null +++ b/src/less/tree/anonymous.js @@ -0,0 +1,46 @@ +import Node from './node' + +const Anonymous = function ( + value, + index, + currentFileInfo, + mapLines, + rulesetLike, + visibilityInfo +) { + this.value = value + this._index = index + this._fileInfo = currentFileInfo + this.mapLines = mapLines + this.rulesetLike = typeof rulesetLike === 'undefined' ? false : rulesetLike + this.allowRoot = true + this.copyVisibilityInfo(visibilityInfo) +} + +Anonymous.prototype = Object.assign(new Node(), { + type: 'Anonymous', + eval() { + return new Anonymous( + this.value, + this._index, + this._fileInfo, + this.mapLines, + this.rulesetLike, + this.visibilityInfo() + ) + }, + compare(other) { + return other.toCSS && this.toCSS() === other.toCSS() ? 0 : undefined + }, + isRulesetLike() { + return this.rulesetLike + }, + genCSS(context, output) { + this.nodeVisible = Boolean(this.value) + if (this.nodeVisible) { + output.add(this.value, this._fileInfo, this._index, this.mapLines) + } + } +}) + +export default Anonymous diff --git a/src/less/tree/assignment.js b/src/less/tree/assignment.js new file mode 100644 index 0000000..c3f181a --- /dev/null +++ b/src/less/tree/assignment.js @@ -0,0 +1,32 @@ +import Node from './node' + +const Assignment = function (key, val) { + this.key = key + this.value = val +} + +Assignment.prototype = Object.assign(new Node(), { + type: 'Assignment', + + accept(visitor) { + this.value = visitor.visit(this.value) + }, + + eval(context) { + if (this.value.eval) { + return new Assignment(this.key, this.value.eval(context)) + } + return this + }, + + genCSS(context, output) { + output.add(`${this.key}=`) + if (this.value.genCSS) { + this.value.genCSS(context, output) + } else { + output.add(this.value) + } + } +}) + +export default Assignment diff --git a/src/less/tree/atrule.js b/src/less/tree/atrule.js new file mode 100644 index 0000000..ab3f96f --- /dev/null +++ b/src/less/tree/atrule.js @@ -0,0 +1,177 @@ +import Node from './node' +import Selector from './selector' +import Ruleset from './ruleset' +import Anonymous from './anonymous' + +const AtRule = function ( + name, + value, + rules, + index, + currentFileInfo, + debugInfo, + isRooted, + visibilityInfo +) { + let i + + this.name = name + this.value = + value instanceof Node ? value : value ? new Anonymous(value) : value + if (rules) { + if (Array.isArray(rules)) { + this.rules = rules + } else { + this.rules = [rules] + this.rules[0].selectors = new Selector( + [], + null, + null, + index, + currentFileInfo + ).createEmptySelectors() + } + for (i = 0; i < this.rules.length; i++) { + this.rules[i].allowImports = true + } + this.setParent(this.rules, this) + } + this._index = index + this._fileInfo = currentFileInfo + this.debugInfo = debugInfo + this.isRooted = isRooted || false + this.copyVisibilityInfo(visibilityInfo) + this.allowRoot = true +} + +AtRule.prototype = Object.assign(new Node(), { + type: 'AtRule', + accept(visitor) { + const value = this.value, + rules = this.rules + if (rules) { + this.rules = visitor.visitArray(rules) + } + if (value) { + this.value = visitor.visit(value) + } + }, + + isRulesetLike() { + return this.rules || !this.isCharset() + }, + + isCharset() { + return '@charset' === this.name + }, + + genCSS(context, output) { + const value = this.value, + rules = this.rules + output.add(this.name, this.fileInfo(), this.getIndex()) + if (value) { + output.add(' ') + value.genCSS(context, output) + } + if (rules) { + this.outputRuleset(context, output, rules) + } else { + output.add(';') + } + }, + + eval(context) { + let mediaPathBackup, + mediaBlocksBackup, + value = this.value, + rules = this.rules + + // media stored inside other atrule should not bubble over it + // backpup media bubbling information + mediaPathBackup = context.mediaPath + mediaBlocksBackup = context.mediaBlocks + // deleted media bubbling information + context.mediaPath = [] + context.mediaBlocks = [] + + if (value) { + value = value.eval(context) + } + if (rules) { + // assuming that there is only one rule at this point - that is how parser constructs the rule + rules = [rules[0].eval(context)] + rules[0].root = true + } + // restore media bubbling information + context.mediaPath = mediaPathBackup + context.mediaBlocks = mediaBlocksBackup + + return new AtRule( + this.name, + value, + rules, + this.getIndex(), + this.fileInfo(), + this.debugInfo, + this.isRooted, + this.visibilityInfo() + ) + }, + + variable(name) { + if (this.rules) { + // assuming that there is only one rule at this point - that is how parser constructs the rule + return Ruleset.prototype.variable.call(this.rules[0], name) + } + }, + + find() { + if (this.rules) { + // assuming that there is only one rule at this point - that is how parser constructs the rule + return Ruleset.prototype.find.apply(this.rules[0], arguments) + } + }, + + rulesets() { + if (this.rules) { + // assuming that there is only one rule at this point - that is how parser constructs the rule + return Ruleset.prototype.rulesets.apply(this.rules[0]) + } + }, + + outputRuleset(context, output, rules) { + const ruleCnt = rules.length + let i + context.tabLevel = (context.tabLevel | 0) + 1 + + // Compressed + if (context.compress) { + output.add('{') + for (i = 0; i < ruleCnt; i++) { + rules[i].genCSS(context, output) + } + output.add('}') + context.tabLevel-- + return + } + + // Non-compressed + const tabSetStr = `\n${Array(context.tabLevel).join(' ')}`, + tabRuleStr = `${tabSetStr} ` + if (!ruleCnt) { + output.add(` {${tabSetStr}}`) + } else { + output.add(` {${tabRuleStr}`) + rules[0].genCSS(context, output) + for (i = 1; i < ruleCnt; i++) { + output.add(tabRuleStr) + rules[i].genCSS(context, output) + } + output.add(`${tabSetStr}}`) + } + + context.tabLevel-- + } +}) + +export default AtRule diff --git a/src/less/tree/attribute.js b/src/less/tree/attribute.js new file mode 100644 index 0000000..98d3384 --- /dev/null +++ b/src/less/tree/attribute.js @@ -0,0 +1,42 @@ +import Node from './node' + +const Attribute = function (key, op, value, cif) { + this.key = key + this.op = op + this.value = value + this.cif = cif +} + +Attribute.prototype = Object.assign(new Node(), { + type: 'Attribute', + + eval(context) { + return new Attribute( + this.key.eval ? this.key.eval(context) : this.key, + this.op, + this.value && this.value.eval ? this.value.eval(context) : this.value, + this.cif + ) + }, + + genCSS(context, output) { + output.add(this.toCSS(context)) + }, + + toCSS(context) { + let value = this.key.toCSS ? this.key.toCSS(context) : this.key + + if (this.op) { + value += this.op + value += this.value.toCSS ? this.value.toCSS(context) : this.value + } + + if (this.cif) { + value = value + ' ' + this.cif + } + + return `[${value}]` + } +}) + +export default Attribute diff --git a/src/less/tree/call.js b/src/less/tree/call.js new file mode 100644 index 0000000..5daddc7 --- /dev/null +++ b/src/less/tree/call.js @@ -0,0 +1,118 @@ +import Node from './node' +import Anonymous from './anonymous' +import FunctionCaller from '../functions/function-caller' + +// +// A function call node. +// +const Call = function (name, args, index, currentFileInfo) { + this.name = name + this.args = args + this.calc = name === 'calc' + this._index = index + this._fileInfo = currentFileInfo +} + +Call.prototype = Object.assign(new Node(), { + type: 'Call', + + accept(visitor) { + if (this.args) { + this.args = visitor.visitArray(this.args) + } + }, + + // + // When evaluating a function call, + // we either find the function in the functionRegistry, + // in which case we call it, passing the evaluated arguments, + // if this returns null or we cannot find the function, we + // simply print it out as it appeared originally [2]. + // + // The reason why we evaluate the arguments, is in the case where + // we try to pass a variable to a function, like: `saturate(@color)`. + // The function should receive the value, not the variable. + // + eval(context) { + /** + * Turn off math for calc(), and switch back on for evaluating nested functions + */ + const currentMathContext = context.mathOn + context.mathOn = !this.calc + if (this.calc || context.inCalc) { + context.enterCalc() + } + + const exitCalc = () => { + if (this.calc || context.inCalc) { + context.exitCalc() + } + context.mathOn = currentMathContext + } + + let result + const funcCaller = new FunctionCaller( + this.name, + context, + this.getIndex(), + this.fileInfo() + ) + + if (funcCaller.isValid()) { + try { + result = funcCaller.call(this.args) + exitCalc() + } catch (e) { + // eslint-disable-next-line no-prototype-builtins + if (e.hasOwnProperty('line') && e.hasOwnProperty('column')) { + throw e + } + throw { + type: e.type || 'Runtime', + message: `Error evaluating function \`${this.name}\`${ + e.message ? `: ${e.message}` : '' + }`, + index: this.getIndex(), + filename: this.fileInfo().filename, + line: e.lineNumber, + column: e.columnNumber + } + } + } + + if (result !== null && result !== undefined) { + // Results that that are not nodes are cast as Anonymous nodes + // Falsy values or booleans are returned as empty nodes + if (!(result instanceof Node)) { + if (!result || result === true) { + result = new Anonymous(null) + } else { + result = new Anonymous(result.toString()) + } + } + result._index = this._index + result._fileInfo = this._fileInfo + return result + } + + const args = this.args.map(a => a.eval(context)) + exitCalc() + + return new Call(this.name, args, this.getIndex(), this.fileInfo()) + }, + + genCSS(context, output) { + output.add(`${this.name}(`, this.fileInfo(), this.getIndex()) + + for (let i = 0; i < this.args.length; i++) { + this.args[i].genCSS(context, output) + if (i + 1 < this.args.length) { + output.add(', ') + } + } + + output.add(')') + } +}) + +export default Call diff --git a/src/less/tree/color.js b/src/less/tree/color.js new file mode 100644 index 0000000..80b857d --- /dev/null +++ b/src/less/tree/color.js @@ -0,0 +1,272 @@ +import Node from './node' +import colors from '../data/colors' + +// +// RGB Colors - #ff0014, #eee +// +const Color = function (rgb, a, originalForm) { + const self = this + // + // The end goal here, is to parse the arguments + // into an integer triplet, such as `128, 255, 0` + // + // This facilitates operations and conversions. + // + if (Array.isArray(rgb)) { + this.rgb = rgb + } else if (rgb.length >= 6) { + this.rgb = [] + rgb.match(/.{2}/g).map(function (c, i) { + if (i < 3) { + self.rgb.push(parseInt(c, 16)) + } else { + self.alpha = parseInt(c, 16) / 255 + } + }) + } else { + this.rgb = [] + rgb.split('').map(function (c, i) { + if (i < 3) { + self.rgb.push(parseInt(c + c, 16)) + } else { + self.alpha = parseInt(c + c, 16) / 255 + } + }) + } + this.alpha = this.alpha || (typeof a === 'number' ? a : 1) + if (typeof originalForm !== 'undefined') { + this.value = originalForm + } +} + +Color.prototype = Object.assign(new Node(), { + type: 'Color', + + luma() { + let r = this.rgb[0] / 255, + g = this.rgb[1] / 255, + b = this.rgb[2] / 255 + + r = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4) + g = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4) + b = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4) + + return 0.2126 * r + 0.7152 * g + 0.0722 * b + }, + + genCSS(context, output) { + output.add(this.toCSS(context)) + }, + + toCSS(context, doNotCompress) { + const compress = context && context.compress && !doNotCompress + let color + let alpha + let colorFunction + let args = [] + + // `value` is set if this color was originally + // converted from a named color string so we need + // to respect this and try to output named color too. + alpha = this.fround(context, this.alpha) + + if (this.value) { + if (this.value.indexOf('rgb') === 0) { + if (alpha < 1) { + colorFunction = 'rgba' + } + } else if (this.value.indexOf('hsl') === 0) { + if (alpha < 1) { + colorFunction = 'hsla' + } else { + colorFunction = 'hsl' + } + } else { + return this.value + } + } else { + if (alpha < 1) { + colorFunction = 'rgba' + } + } + + switch (colorFunction) { + case 'rgba': + args = this.rgb + .map(function (c) { + return clamp(Math.round(c), 255) + }) + .concat(clamp(alpha, 1)) + break + case 'hsla': + args.push(clamp(alpha, 1)) + // eslint-disable-next-line no-fallthrough + case 'hsl': + color = this.toHSL() + args = [ + this.fround(context, color.h), + `${this.fround(context, color.s * 100)}%`, + `${this.fround(context, color.l * 100)}%` + ].concat(args) + } + + if (colorFunction) { + // Values are capped between `0` and `255`, rounded and zero-padded. + return `${colorFunction}(${args.join(`,${compress ? '' : ' '}`)})` + } + + color = this.toRGB() + + if (compress) { + const splitcolor = color.split('') + + // Convert color to short format + if ( + splitcolor[1] === splitcolor[2] && + splitcolor[3] === splitcolor[4] && + splitcolor[5] === splitcolor[6] + ) { + color = `#${splitcolor[1]}${splitcolor[3]}${splitcolor[5]}` + } + } + + return color + }, + + // + // Operations have to be done per-channel, if not, + // channels will spill onto each other. Once we have + // our result, in the form of an integer triplet, + // we create a new Color node to hold the result. + // + operate(context, op, other) { + const rgb = new Array(3) + const alpha = this.alpha * (1 - other.alpha) + other.alpha + for (let c = 0; c < 3; c++) { + rgb[c] = this._operate(context, op, this.rgb[c], other.rgb[c]) + } + return new Color(rgb, alpha) + }, + + toRGB() { + return toHex(this.rgb) + }, + + toHSL() { + const r = this.rgb[0] / 255, + g = this.rgb[1] / 255, + b = this.rgb[2] / 255, + a = this.alpha + + const max = Math.max(r, g, b), + min = Math.min(r, g, b) + let h + let s + const l = (max + min) / 2 + const d = max - min + + if (max === min) { + h = s = 0 + } else { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + return { h: h * 360, s, l, a } + }, + + // Adapted from http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript + toHSV() { + const r = this.rgb[0] / 255, + g = this.rgb[1] / 255, + b = this.rgb[2] / 255, + a = this.alpha + + const max = Math.max(r, g, b), + min = Math.min(r, g, b) + let h + let s + const v = max + + const d = max - min + if (max === 0) { + s = 0 + } else { + s = d / max + } + + if (max === min) { + h = 0 + } else { + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + return { h: h * 360, s, v, a } + }, + + toARGB() { + return toHex([this.alpha * 255].concat(this.rgb)) + }, + + compare(x) { + return x.rgb && + x.rgb[0] === this.rgb[0] && + x.rgb[1] === this.rgb[1] && + x.rgb[2] === this.rgb[2] && + x.alpha === this.alpha + ? 0 + : undefined + } +}) + +Color.fromKeyword = function (keyword) { + let c + const key = keyword.toLowerCase() + // eslint-disable-next-line no-prototype-builtins + if (colors.hasOwnProperty(key)) { + c = new Color(colors[key].slice(1)) + } else if (key === 'transparent') { + c = new Color([0, 0, 0], 0) + } + + if (c) { + c.value = keyword + return c + } +} + +function clamp(v, max) { + return Math.min(Math.max(v, 0), max) +} + +function toHex(v) { + return `#${v + .map(function (c) { + c = clamp(Math.round(c), 255) + return (c < 16 ? '0' : '') + c.toString(16) + }) + .join('')}` +} + +export default Color diff --git a/src/less/tree/combinator.js b/src/less/tree/combinator.js new file mode 100644 index 0000000..ce29136 --- /dev/null +++ b/src/less/tree/combinator.js @@ -0,0 +1,28 @@ +import Node from './node' +const _noSpaceCombinators = { + '': true, + ' ': true, + '|': true +} + +const Combinator = function (value) { + if (value === ' ') { + this.value = ' ' + this.emptyOrWhitespace = true + } else { + this.value = value ? value.trim() : '' + this.emptyOrWhitespace = this.value === '' + } +} + +Combinator.prototype = Object.assign(new Node(), { + type: 'Combinator', + + genCSS(context, output) { + const spaceOrEmpty = + context.compress || _noSpaceCombinators[this.value] ? '' : ' ' + output.add(spaceOrEmpty + this.value + spaceOrEmpty) + } +}) + +export default Combinator diff --git a/src/less/tree/comment.js b/src/less/tree/comment.js new file mode 100644 index 0000000..ea68b56 --- /dev/null +++ b/src/less/tree/comment.js @@ -0,0 +1,28 @@ +import Node from './node' +import getDebugInfo from './debug-info' + +const Comment = function (value, isLineComment, index, currentFileInfo) { + this.value = value + this.isLineComment = isLineComment + this._index = index + this._fileInfo = currentFileInfo + this.allowRoot = true +} + +Comment.prototype = Object.assign(new Node(), { + type: 'Comment', + + genCSS(context, output) { + if (this.debugInfo) { + output.add(getDebugInfo(context, this), this.fileInfo(), this.getIndex()) + } + output.add(this.value) + }, + + isSilent(context) { + const isCompressed = context.compress && this.value[2] !== '!' + return this.isLineComment || isCompressed + } +}) + +export default Comment diff --git a/src/less/tree/condition.js b/src/less/tree/condition.js new file mode 100644 index 0000000..56e5818 --- /dev/null +++ b/src/less/tree/condition.js @@ -0,0 +1,44 @@ +import Node from './node' + +const Condition = function (op, l, r, i, negate) { + this.op = op.trim() + this.lvalue = l + this.rvalue = r + this._index = i + this.negate = negate +} + +Condition.prototype = Object.assign(new Node(), { + type: 'Condition', + + accept(visitor) { + this.lvalue = visitor.visit(this.lvalue) + this.rvalue = visitor.visit(this.rvalue) + }, + + eval(context) { + const result = (function (op, a, b) { + switch (op) { + case 'and': + return a && b + case 'or': + return a || b + default: + switch (Node.compare(a, b)) { + case -1: + return op === '<' || op === '=<' || op === '<=' + case 0: + return op === '=' || op === '>=' || op === '=<' || op === '<=' + case 1: + return op === '>' || op === '>=' + default: + return false + } + } + })(this.op, this.lvalue.eval(context), this.rvalue.eval(context)) + + return this.negate ? !result : result + } +}) + +export default Condition diff --git a/src/less/tree/debug-info.js b/src/less/tree/debug-info.js new file mode 100644 index 0000000..e607c26 --- /dev/null +++ b/src/less/tree/debug-info.js @@ -0,0 +1,39 @@ +function asComment(ctx) { + return `/* line ${ctx.debugInfo.lineNumber}, ${ctx.debugInfo.fileName} */\n` +} + +function asMediaQuery(ctx) { + let filenameWithProtocol = ctx.debugInfo.fileName + if (!/^[a-z]+:\/\//i.test(filenameWithProtocol)) { + filenameWithProtocol = `file://${filenameWithProtocol}` + } + return `@media -sass-debug-info{filename{font-family:${filenameWithProtocol.replace( + /([.:/\\])/g, + function (a) { + if (a == '\\') { + a = '/' + } + return `\\${a}` + } + )}}line{font-family:\\00003${ctx.debugInfo.lineNumber}}}\n` +} + +function debugInfo(context, ctx, lineSeparator) { + let result = '' + if (context.dumpLineNumbers && !context.compress) { + switch (context.dumpLineNumbers) { + case 'comments': + result = asComment(ctx) + break + case 'mediaquery': + result = asMediaQuery(ctx) + break + case 'all': + result = asComment(ctx) + (lineSeparator || '') + asMediaQuery(ctx) + break + } + } + return result +} + +export default debugInfo diff --git a/src/less/tree/declaration.js b/src/less/tree/declaration.js new file mode 100644 index 0000000..ce33b9b --- /dev/null +++ b/src/less/tree/declaration.js @@ -0,0 +1,148 @@ +import Node from './node' +import Value from './value' +import Keyword from './keyword' +import Anonymous from './anonymous' +import * as Constants from '../constants' +const MATH = Constants.Math + +function evalName(context, name) { + let value = '' + let i + const n = name.length + const output = { + add: function (s) { + value += s + } + } + for (i = 0; i < n; i++) { + name[i].eval(context).genCSS(context, output) + } + return value +} + +const Declaration = function ( + name, + value, + important, + merge, + index, + currentFileInfo, + inline, + variable +) { + this.name = name + this.value = + value instanceof Node + ? value + : new Value([value ? new Anonymous(value) : null]) + this.important = important ? ` ${important.trim()}` : '' + this.merge = merge + this._index = index + this._fileInfo = currentFileInfo + this.inline = inline || false + this.variable = + variable !== undefined ? variable : name.charAt && name.charAt(0) === '@' + this.allowRoot = true + this.setParent(this.value, this) +} + +Declaration.prototype = Object.assign(new Node(), { + type: 'Declaration', + + genCSS(context, output) { + output.add( + this.name + (context.compress ? ':' : ': '), + this.fileInfo(), + this.getIndex() + ) + try { + this.value.genCSS(context, output) + } catch (e) { + e.index = this._index + e.filename = this._fileInfo.filename + throw e + } + output.add( + this.important + + (this.inline || (context.lastRule && context.compress) ? '' : ';'), + this._fileInfo, + this._index + ) + }, + + eval(context) { + let mathBypass = false, + prevMath, + name = this.name, + evaldValue, + variable = this.variable + if (typeof name !== 'string') { + // expand 'primitive' name directly to get + // things faster (~10% for benchmark.less): + name = + name.length === 1 && name[0] instanceof Keyword + ? name[0].value + : evalName(context, name) + variable = false // never treat expanded interpolation as new variable name + } + + // @todo remove when parens-division is default + if (name === 'font' && context.math === MATH.ALWAYS) { + mathBypass = true + prevMath = context.math + context.math = MATH.PARENS_DIVISION + } + try { + context.importantScope.push({}) + evaldValue = this.value.eval(context) + + if (!this.variable && evaldValue.type === 'DetachedRuleset') { + throw { + message: 'Rulesets cannot be evaluated on a property.', + index: this.getIndex(), + filename: this.fileInfo().filename + } + } + let important = this.important + const importantResult = context.importantScope.pop() + if (!important && importantResult.important) { + important = importantResult.important + } + + return new Declaration( + name, + evaldValue, + important, + this.merge, + this.getIndex(), + this.fileInfo(), + this.inline, + variable + ) + } catch (e) { + if (typeof e.index !== 'number') { + e.index = this.getIndex() + e.filename = this.fileInfo().filename + } + throw e + } finally { + if (mathBypass) { + context.math = prevMath + } + } + }, + + makeImportant() { + return new Declaration( + this.name, + this.value, + '!important', + this.merge, + this.getIndex(), + this.fileInfo(), + this.inline + ) + } +}) + +export default Declaration diff --git a/src/less/tree/detached-ruleset.js b/src/less/tree/detached-ruleset.js new file mode 100644 index 0000000..e4f9085 --- /dev/null +++ b/src/less/tree/detached-ruleset.js @@ -0,0 +1,33 @@ +import Node from './node' +import contexts from '../contexts' +import * as utils from '../utils' + +const DetachedRuleset = function (ruleset, frames) { + this.ruleset = ruleset + this.frames = frames + this.setParent(this.ruleset, this) +} + +DetachedRuleset.prototype = Object.assign(new Node(), { + type: 'DetachedRuleset', + evalFirst: true, + + accept(visitor) { + this.ruleset = visitor.visit(this.ruleset) + }, + + eval(context) { + const frames = this.frames || utils.copyArray(context.frames) + return new DetachedRuleset(this.ruleset, frames) + }, + + callEval(context) { + return this.ruleset.eval( + this.frames + ? new contexts.Eval(context, this.frames.concat(context.frames)) + : context + ) + } +}) + +export default DetachedRuleset diff --git a/src/less/tree/dimension.js b/src/less/tree/dimension.js new file mode 100644 index 0000000..42b7b43 --- /dev/null +++ b/src/less/tree/dimension.js @@ -0,0 +1,185 @@ +/* eslint-disable no-prototype-builtins */ +import Node from './node' +import unitConversions from '../data/unit-conversions' +import Unit from './unit' +import Color from './color' + +// +// A number with a unit +// +const Dimension = function (value, unit) { + this.value = parseFloat(value) + if (isNaN(this.value)) { + throw new Error('Dimension is not a number.') + } + this.unit = + unit && unit instanceof Unit ? unit : new Unit(unit ? [unit] : undefined) + this.setParent(this.unit, this) +} + +Dimension.prototype = Object.assign(new Node(), { + type: 'Dimension', + + accept(visitor) { + this.unit = visitor.visit(this.unit) + }, + + // remove when Nodes have JSDoc types + // eslint-disable-next-line no-unused-vars + eval(context) { + return this + }, + + toColor() { + return new Color([this.value, this.value, this.value]) + }, + + genCSS(context, output) { + if (context && context.strictUnits && !this.unit.isSingular()) { + throw new Error( + `Multiple units in dimension. Correct the units or use the unit function. Bad unit: ${this.unit.toString()}` + ) + } + + const value = this.fround(context, this.value) + let strValue = String(value) + + if (value !== 0 && value < 0.000001 && value > -0.000001) { + // would be output 1e-6 etc. + strValue = value.toFixed(20).replace(/0+$/, '') + } + + if (context && context.compress) { + // Zero values doesn't need a unit + if (value === 0 && this.unit.isLength()) { + output.add(strValue) + return + } + + // Float values doesn't need a leading zero + if (value > 0 && value < 1) { + strValue = strValue.substr(1) + } + } + + output.add(strValue) + this.unit.genCSS(context, output) + }, + + // In an operation between two Dimensions, + // we default to the first Dimension's unit, + // so `1px + 2` will yield `3px`. + operate(context, op, other) { + /* jshint noempty:false */ + let value = this._operate(context, op, this.value, other.value) + let unit = this.unit.clone() + + if (op === '+' || op === '-') { + if (unit.numerator.length === 0 && unit.denominator.length === 0) { + unit = other.unit.clone() + if (this.unit.backupUnit) { + unit.backupUnit = this.unit.backupUnit + } + } else if ( + other.unit.numerator.length === 0 && + unit.denominator.length === 0 + ) { + // do nothing + } else { + other = other.convertTo(this.unit.usedUnits()) + + if (context.strictUnits && other.unit.toString() !== unit.toString()) { + throw new Error( + 'Incompatible units. Change the units or use the unit function. ' + + `Bad units: '${unit.toString()}' and '${other.unit.toString()}'.` + ) + } + + value = this._operate(context, op, this.value, other.value) + } + } else if (op === '*') { + unit.numerator = unit.numerator.concat(other.unit.numerator).sort() + unit.denominator = unit.denominator.concat(other.unit.denominator).sort() + unit.cancel() + } else if (op === '/') { + unit.numerator = unit.numerator.concat(other.unit.denominator).sort() + unit.denominator = unit.denominator.concat(other.unit.numerator).sort() + unit.cancel() + } + return new Dimension(value, unit) + }, + + compare(other) { + let a, b + + if (!(other instanceof Dimension)) { + return undefined + } + + if (this.unit.isEmpty() || other.unit.isEmpty()) { + a = this + b = other + } else { + a = this.unify() + b = other.unify() + if (a.unit.compare(b.unit) !== 0) { + return undefined + } + } + + return Node.numericCompare(a.value, b.value) + }, + + unify() { + return this.convertTo({ length: 'px', duration: 's', angle: 'rad' }) + }, + + convertTo(conversions) { + let value = this.value + const unit = this.unit.clone() + let i + let groupName + let group + let targetUnit + let derivedConversions = {} + let applyUnit + + if (typeof conversions === 'string') { + for (i in unitConversions) { + if (unitConversions[i].hasOwnProperty(conversions)) { + derivedConversions = {} + derivedConversions[i] = conversions + } + } + conversions = derivedConversions + } + applyUnit = function (atomicUnit, denominator) { + if (group.hasOwnProperty(atomicUnit)) { + if (denominator) { + value = value / (group[atomicUnit] / group[targetUnit]) + } else { + value = value * (group[atomicUnit] / group[targetUnit]) + } + + return targetUnit + } + + return atomicUnit + } + + for (groupName in conversions) { + if (conversions.hasOwnProperty(groupName)) { + targetUnit = conversions[groupName] + group = unitConversions[groupName] + + unit.map(applyUnit) + } + } + + unit.cancel() + + return new Dimension(value, unit) + } +}) + +export default Dimension diff --git a/src/less/tree/element.js b/src/less/tree/element.js new file mode 100644 index 0000000..f76db2b --- /dev/null +++ b/src/less/tree/element.js @@ -0,0 +1,86 @@ +import Node from './node' +import Paren from './paren' +import Combinator from './combinator' + +const Element = function ( + combinator, + value, + isVariable, + index, + currentFileInfo, + visibilityInfo +) { + this.combinator = + combinator instanceof Combinator ? combinator : new Combinator(combinator) + + if (typeof value === 'string') { + this.value = value.trim() + } else if (value) { + this.value = value + } else { + this.value = '' + } + this.isVariable = isVariable + this._index = index + this._fileInfo = currentFileInfo + this.copyVisibilityInfo(visibilityInfo) + this.setParent(this.combinator, this) +} + +Element.prototype = Object.assign(new Node(), { + type: 'Element', + + accept(visitor) { + const value = this.value + this.combinator = visitor.visit(this.combinator) + if (typeof value === 'object') { + this.value = visitor.visit(value) + } + }, + + eval(context) { + return new Element( + this.combinator, + this.value.eval ? this.value.eval(context) : this.value, + this.isVariable, + this.getIndex(), + this.fileInfo(), + this.visibilityInfo() + ) + }, + + clone() { + return new Element( + this.combinator, + this.value, + this.isVariable, + this.getIndex(), + this.fileInfo(), + this.visibilityInfo() + ) + }, + + genCSS(context, output) { + output.add(this.toCSS(context), this.fileInfo(), this.getIndex()) + }, + + toCSS(context) { + context = context || {} + let value = this.value + const firstSelector = context.firstSelector + if (value instanceof Paren) { + // selector in parens should not be affected by outer selector + // flags (breaks only interpolated selectors - see #1973) + context.firstSelector = true + } + value = value.toCSS ? value.toCSS(context) : value + context.firstSelector = firstSelector + if (value === '' && this.combinator.value.charAt(0) === '&') { + return '' + } else { + return this.combinator.toCSS(context) + value + } + } +}) + +export default Element diff --git a/src/less/tree/expression.js b/src/less/tree/expression.js new file mode 100644 index 0000000..3be0e9c --- /dev/null +++ b/src/less/tree/expression.js @@ -0,0 +1,83 @@ +import Node from './node' +import Paren from './paren' +import Comment from './comment' +import Dimension from './dimension' + +const Expression = function (value, noSpacing) { + this.value = value + this.noSpacing = noSpacing + if (!value) { + throw new Error('Expression requires an array parameter') + } +} + +Expression.prototype = Object.assign(new Node(), { + type: 'Expression', + + accept(visitor) { + this.value = visitor.visitArray(this.value) + }, + + eval(context) { + let returnValue + const mathOn = context.isMathOn() + const inParenthesis = this.parens + + let doubleParen = false + if (inParenthesis) { + context.inParenthesis() + } + if (this.value.length > 1) { + returnValue = new Expression( + this.value.map(function (e) { + if (!e.eval) { + return e + } + return e.eval(context) + }), + this.noSpacing + ) + } else if (this.value.length === 1) { + if ( + this.value[0].parens && + !this.value[0].parensInOp && + !context.inCalc + ) { + doubleParen = true + } + returnValue = this.value[0].eval(context) + } else { + returnValue = this + } + if (inParenthesis) { + context.outOfParenthesis() + } + if ( + this.parens && + this.parensInOp && + !mathOn && + !doubleParen && + !(returnValue instanceof Dimension) + ) { + returnValue = new Paren(returnValue) + } + return returnValue + }, + + genCSS(context, output) { + for (let i = 0; i < this.value.length; i++) { + this.value[i].genCSS(context, output) + if (!this.noSpacing && i + 1 < this.value.length) { + output.add(' ') + } + } + }, + + throwAwayComments() { + this.value = this.value.filter(function (v) { + return !(v instanceof Comment) + }) + } +}) + +export default Expression diff --git a/src/less/tree/extend.js b/src/less/tree/extend.js new file mode 100644 index 0000000..d0019e8 --- /dev/null +++ b/src/less/tree/extend.js @@ -0,0 +1,88 @@ +import Node from './node' +import Selector from './selector' + +const Extend = function ( + selector, + option, + index, + currentFileInfo, + visibilityInfo +) { + this.selector = selector + this.option = option + this.object_id = Extend.next_id++ + this.parent_ids = [this.object_id] + this._index = index + this._fileInfo = currentFileInfo + this.copyVisibilityInfo(visibilityInfo) + this.allowRoot = true + + switch (option) { + case 'all': + this.allowBefore = true + this.allowAfter = true + break + default: + this.allowBefore = false + this.allowAfter = false + break + } + this.setParent(this.selector, this) +} + +Extend.prototype = Object.assign(new Node(), { + type: 'Extend', + + accept(visitor) { + this.selector = visitor.visit(this.selector) + }, + + eval(context) { + return new Extend( + this.selector.eval(context), + this.option, + this.getIndex(), + this.fileInfo(), + this.visibilityInfo() + ) + }, + + // remove when Nodes have JSDoc types + // eslint-disable-next-line no-unused-vars + clone(context) { + return new Extend( + this.selector, + this.option, + this.getIndex(), + this.fileInfo(), + this.visibilityInfo() + ) + }, + + // it concatenates (joins) all selectors in selector array + findSelfSelectors(selectors) { + let selfElements = [], + i, + selectorElements + + for (i = 0; i < selectors.length; i++) { + selectorElements = selectors[i].elements + // duplicate the logic in genCSS function inside the selector node. + // future TODO - move both logics into the selector joiner visitor + if ( + i > 0 && + selectorElements.length && + selectorElements[0].combinator.value === '' + ) { + selectorElements[0].combinator.value = ' ' + } + selfElements = selfElements.concat(selectors[i].elements) + } + + this.selfSelectors = [new Selector(selfElements)] + this.selfSelectors[0].copyVisibilityInfo(this.visibilityInfo()) + } +}) + +Extend.next_id = 0 +export default Extend diff --git a/src/less/tree/import.js b/src/less/tree/import.js new file mode 100644 index 0000000..a09e797 --- /dev/null +++ b/src/less/tree/import.js @@ -0,0 +1,208 @@ +import Node from './node' +import Media from './media' +import URL from './url' +import Quoted from './quoted' +import Ruleset from './ruleset' +import Anonymous from './anonymous' +import * as utils from '../utils' +import LessError from '../less-error' + +// +// CSS @import node +// +// The general strategy here is that we don't want to wait +// for the parsing to be completed, before we start importing +// the file. That's because in the context of a browser, +// most of the time will be spent waiting for the server to respond. +// +// On creation, we push the import path to our import queue, though +// `import,push`, we also pass it a callback, which it'll call once +// the file has been fetched, and parsed. +// +const Import = function ( + path, + features, + options, + index, + currentFileInfo, + visibilityInfo +) { + this.options = options + this._index = index + this._fileInfo = currentFileInfo + this.path = path + this.features = features + this.allowRoot = true + + if (this.options.less !== undefined || this.options.inline) { + this.css = !this.options.less || this.options.inline + } else { + const pathValue = this.getPath() + if (pathValue && /[#.&?]css([?;].*)?$/.test(pathValue)) { + this.css = true + } + } + this.copyVisibilityInfo(visibilityInfo) + this.setParent(this.features, this) + this.setParent(this.path, this) +} + +Import.prototype = Object.assign(new Node(), { + type: 'Import', + + accept(visitor) { + if (this.features) { + this.features = visitor.visit(this.features) + } + this.path = visitor.visit(this.path) + if (!this.options.isPlugin && !this.options.inline && this.root) { + this.root = visitor.visit(this.root) + } + }, + + genCSS(context, output) { + if (this.css && this.path._fileInfo.reference === undefined) { + output.add('@import ', this._fileInfo, this._index) + this.path.genCSS(context, output) + if (this.features) { + output.add(' ') + this.features.genCSS(context, output) + } + output.add(';') + } + }, + + getPath() { + return this.path instanceof URL ? this.path.value.value : this.path.value + }, + + isVariableImport() { + let path = this.path + if (path instanceof URL) { + path = path.value + } + if (path instanceof Quoted) { + return path.containsVariables() + } + + return true + }, + + evalForImport(context) { + let path = this.path + + if (path instanceof URL) { + path = path.value + } + + return new Import( + path.eval(context), + this.features, + this.options, + this._index, + this._fileInfo, + this.visibilityInfo() + ) + }, + + evalPath(context) { + const path = this.path.eval(context) + const fileInfo = this._fileInfo + + if (!(path instanceof URL)) { + // Add the rootpath if the URL requires a rewrite + const pathValue = path.value + if (fileInfo && pathValue && context.pathRequiresRewrite(pathValue)) { + path.value = context.rewritePath(pathValue, fileInfo.rootpath) + } else { + path.value = context.normalizePath(path.value) + } + } + + return path + }, + + eval(context) { + const result = this.doEval(context) + if (this.options.reference || this.blocksVisibility()) { + if (result.length || result.length === 0) { + result.forEach(function (node) { + node.addVisibilityBlock() + }) + } else { + result.addVisibilityBlock() + } + } + return result + }, + + doEval(context) { + let ruleset + let registry + const features = this.features && this.features.eval(context) + + if (this.options.isPlugin) { + if (this.root && this.root.eval) { + try { + this.root.eval(context) + } catch (e) { + e.message = 'Plugin error during evaluation' + throw new LessError(e, this.root.imports, this.root.filename) + } + } + registry = context.frames[0] && context.frames[0].functionRegistry + if (registry && this.root && this.root.functions) { + registry.addMultiple(this.root.functions) + } + + return [] + } + + if (this.skip) { + if (typeof this.skip === 'function') { + this.skip = this.skip() + } + if (this.skip) { + return [] + } + } + if (this.options.inline) { + const contents = new Anonymous( + this.root, + 0, + { + filename: this.importedFilename, + reference: this.path._fileInfo && this.path._fileInfo.reference + }, + true, + true + ) + + return this.features + ? new Media([contents], this.features.value) + : [contents] + } else if (this.css) { + const newImport = new Import( + this.evalPath(context), + features, + this.options, + this._index + ) + if (!newImport.css && this.error) { + throw this.error + } + return newImport + } else if (this.root) { + ruleset = new Ruleset(null, utils.copyArray(this.root.rules)) + ruleset.evalImports(context) + + return this.features + ? new Media(ruleset.rules, this.features.value) + : ruleset.rules + } else { + return [] + } + } +}) + +export default Import diff --git a/src/less/tree/index.js b/src/less/tree/index.js new file mode 100644 index 0000000..7733bb8 --- /dev/null +++ b/src/less/tree/index.js @@ -0,0 +1,79 @@ +import Node from './node' +import Color from './color' +import AtRule from './atrule' +import DetachedRuleset from './detached-ruleset' +import Operation from './operation' +import Dimension from './dimension' +import Unit from './unit' +import Keyword from './keyword' +import Variable from './variable' +import Property from './property' +import Ruleset from './ruleset' +import Element from './element' +import Attribute from './attribute' +import Combinator from './combinator' +import Selector from './selector' +import Quoted from './quoted' +import Expression from './expression' +import Declaration from './declaration' +import Call from './call' +import URL from './url' +import Import from './import' +import Comment from './comment' +import Anonymous from './anonymous' +import Value from './value' +import JavaScript from './javascript' +import Assignment from './assignment' +import Condition from './condition' +import Paren from './paren' +import Media from './media' +import UnicodeDescriptor from './unicode-descriptor' +import Negative from './negative' +import Extend from './extend' +import VariableCall from './variable-call' +import NamespaceValue from './namespace-value' + +// mixins +import MixinCall from './mixin-call' +import MixinDefinition from './mixin-definition' + +export default { + Node, + Color, + AtRule, + DetachedRuleset, + Operation, + Dimension, + Unit, + Keyword, + Variable, + Property, + Ruleset, + Element, + Attribute, + Combinator, + Selector, + Quoted, + Expression, + Declaration, + Call, + URL, + Import, + Comment, + Anonymous, + Value, + JavaScript, + Assignment, + Condition, + Paren, + Media, + UnicodeDescriptor, + Negative, + Extend, + VariableCall, + NamespaceValue, + mixin: { + Call: MixinCall, + Definition: MixinDefinition + } +} diff --git a/src/less/tree/javascript.js b/src/less/tree/javascript.js new file mode 100644 index 0000000..5de887b --- /dev/null +++ b/src/less/tree/javascript.js @@ -0,0 +1,32 @@ +import JsEvalNode from './js-eval-node' +import Dimension from './dimension' +import Quoted from './quoted' +import Anonymous from './anonymous' + +const JavaScript = function (string, escaped, index, currentFileInfo) { + this.escaped = escaped + this.expression = string + this._index = index + this._fileInfo = currentFileInfo +} + +JavaScript.prototype = Object.assign(new JsEvalNode(), { + type: 'JavaScript', + + eval(context) { + const result = this.evaluateJavaScript(this.expression, context) + const type = typeof result + + if (type === 'number' && !isNaN(result)) { + return new Dimension(result) + } else if (type === 'string') { + return new Quoted(`"${result}"`, result, this.escaped, this._index) + } else if (Array.isArray(result)) { + return new Anonymous(result.join(', ')) + } else { + return new Anonymous(result) + } + } +}) + +export default JavaScript diff --git a/src/less/tree/js-eval-node.js b/src/less/tree/js-eval-node.js new file mode 100644 index 0000000..6aae160 --- /dev/null +++ b/src/less/tree/js-eval-node.js @@ -0,0 +1,77 @@ +import Node from './node' +import Variable from './variable' + +const JsEvalNode = function () {} + +JsEvalNode.prototype = Object.assign(new Node(), { + evaluateJavaScript(expression, context) { + let result + const that = this + const evalContext = {} + + if (!context.javascriptEnabled) { + throw { + message: 'Inline JavaScript is not enabled. Is it set in your options?', + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + + expression = expression.replace(/@\{([\w-]+)\}/g, function (_, name) { + return that.jsify( + new Variable(`@${name}`, that.getIndex(), that.fileInfo()).eval(context) + ) + }) + + try { + expression = new Function(`return (${expression})`) + } catch (e) { + throw { + message: `JavaScript evaluation error: ${e.message} from \`${expression}\``, + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + + const variables = context.frames[0].variables() + for (const k in variables) { + // eslint-disable-next-line no-prototype-builtins + if (variables.hasOwnProperty(k)) { + evalContext[k.slice(1)] = { + value: variables[k].value, + toJS: function () { + return this.value.eval(context).toCSS() + } + } + } + } + + try { + result = expression.call(evalContext) + } catch (e) { + throw { + message: `JavaScript evaluation error: '${e.name}: ${e.message.replace( + /["]/g, + "'" + )}'`, + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + return result + }, + + jsify(obj) { + if (Array.isArray(obj.value) && obj.value.length > 1) { + return `[${obj.value + .map(function (v) { + return v.toCSS() + }) + .join(', ')}]` + } else { + return obj.toCSS() + } + } +}) + +export default JsEvalNode diff --git a/src/less/tree/keyword.js b/src/less/tree/keyword.js new file mode 100644 index 0000000..9666b9d --- /dev/null +++ b/src/less/tree/keyword.js @@ -0,0 +1,21 @@ +import Node from './node' + +const Keyword = function (value) { + this.value = value +} + +Keyword.prototype = Object.assign(new Node(), { + type: 'Keyword', + + genCSS(context, output) { + if (this.value === '%') { + throw { type: 'Syntax', message: 'Invalid % without number' } + } + output.add(this.value) + } +}) + +Keyword.True = new Keyword('true') +Keyword.False = new Keyword('false') + +export default Keyword diff --git a/src/less/tree/media.js b/src/less/tree/media.js new file mode 100644 index 0000000..b62f4d0 --- /dev/null +++ b/src/less/tree/media.js @@ -0,0 +1,185 @@ +import Ruleset from './ruleset' +import Value from './value' +import Selector from './selector' +import Anonymous from './anonymous' +import Expression from './expression' +import AtRule from './atrule' +import * as utils from '../utils' + +const Media = function ( + value, + features, + index, + currentFileInfo, + visibilityInfo +) { + this._index = index + this._fileInfo = currentFileInfo + + const selectors = new Selector( + [], + null, + null, + this._index, + this._fileInfo + ).createEmptySelectors() + + this.features = new Value(features) + this.rules = [new Ruleset(selectors, value)] + this.rules[0].allowImports = true + this.copyVisibilityInfo(visibilityInfo) + this.allowRoot = true + this.setParent(selectors, this) + this.setParent(this.features, this) + this.setParent(this.rules, this) +} + +Media.prototype = Object.assign(new AtRule(), { + type: 'Media', + + isRulesetLike() { + return true + }, + + accept(visitor) { + if (this.features) { + this.features = visitor.visit(this.features) + } + if (this.rules) { + this.rules = visitor.visitArray(this.rules) + } + }, + + genCSS(context, output) { + output.add('@media ', this._fileInfo, this._index) + this.features.genCSS(context, output) + this.outputRuleset(context, output, this.rules) + }, + + eval(context) { + if (!context.mediaBlocks) { + context.mediaBlocks = [] + context.mediaPath = [] + } + + const media = new Media( + null, + [], + this._index, + this._fileInfo, + this.visibilityInfo() + ) + if (this.debugInfo) { + this.rules[0].debugInfo = this.debugInfo + media.debugInfo = this.debugInfo + } + + media.features = this.features.eval(context) + + context.mediaPath.push(media) + context.mediaBlocks.push(media) + + this.rules[0].functionRegistry = + context.frames[0].functionRegistry.inherit() + context.frames.unshift(this.rules[0]) + media.rules = [this.rules[0].eval(context)] + context.frames.shift() + + context.mediaPath.pop() + + return context.mediaPath.length === 0 + ? media.evalTop(context) + : media.evalNested(context) + }, + + evalTop(context) { + let result = this + + // Render all dependent Media blocks. + if (context.mediaBlocks.length > 1) { + const selectors = new Selector( + [], + null, + null, + this.getIndex(), + this.fileInfo() + ).createEmptySelectors() + result = new Ruleset(selectors, context.mediaBlocks) + result.multiMedia = true + result.copyVisibilityInfo(this.visibilityInfo()) + this.setParent(result, this) + } + + delete context.mediaBlocks + delete context.mediaPath + + return result + }, + + evalNested(context) { + let i + let value + const path = context.mediaPath.concat([this]) + + // Extract the media-query conditions separated with `,` (OR). + for (i = 0; i < path.length; i++) { + value = + path[i].features instanceof Value + ? path[i].features.value + : path[i].features + path[i] = Array.isArray(value) ? value : [value] + } + + // Trace all permutations to generate the resulting media-query. + // + // (a, b and c) with nested (d, e) -> + // a and d + // a and e + // b and c and d + // b and c and e + this.features = new Value( + this.permute(path).map(path => { + path = path.map(fragment => + fragment.toCSS ? fragment : new Anonymous(fragment) + ) + + for (i = path.length - 1; i > 0; i--) { + path.splice(i, 0, new Anonymous('and')) + } + + return new Expression(path) + }) + ) + this.setParent(this.features, this) + + // Fake a tree-node that doesn't output anything. + return new Ruleset([], []) + }, + + permute(arr) { + if (arr.length === 0) { + return [] + } else if (arr.length === 1) { + return arr[0] + } else { + const result = [] + const rest = this.permute(arr.slice(1)) + for (let i = 0; i < rest.length; i++) { + for (let j = 0; j < arr[0].length; j++) { + result.push([arr[0][j]].concat(rest[i])) + } + } + return result + } + }, + + bubbleSelectors(selectors) { + if (!selectors) { + return + } + this.rules = [new Ruleset(utils.copyArray(selectors), [this.rules[0]])] + this.setParent(this.rules, this) + } +}) + +export default Media diff --git a/src/less/tree/mixin-call.js b/src/less/tree/mixin-call.js new file mode 100644 index 0000000..44989fa --- /dev/null +++ b/src/less/tree/mixin-call.js @@ -0,0 +1,259 @@ +import Node from './node' +import Selector from './selector' +import MixinDefinition from './mixin-definition' +import defaultFunc from '../functions/default' + +const MixinCall = function (elements, args, index, currentFileInfo, important) { + this.selector = new Selector(elements) + this.arguments = args || [] + this._index = index + this._fileInfo = currentFileInfo + this.important = important + this.allowRoot = true + this.setParent(this.selector, this) +} + +MixinCall.prototype = Object.assign(new Node(), { + type: 'MixinCall', + + accept(visitor) { + if (this.selector) { + this.selector = visitor.visit(this.selector) + } + if (this.arguments.length) { + this.arguments = visitor.visitArray(this.arguments) + } + }, + + eval(context) { + let mixins + let mixin + let mixinPath + const args = [] + let arg + let argValue + const rules = [] + let match = false + let i + let m + let f + let isRecursive + let isOneFound + const candidates = [] + let candidate + const conditionResult = [] + let defaultResult + const defFalseEitherCase = -1 + const defNone = 0 + const defTrue = 1 + const defFalse = 2 + let count + let originalRuleset + let noArgumentsFilter + + this.selector = this.selector.eval(context) + + function calcDefGroup(mixin, mixinPath) { + let f, p, namespace + + for (f = 0; f < 2; f++) { + conditionResult[f] = true + defaultFunc.value(f) + for (p = 0; p < mixinPath.length && conditionResult[f]; p++) { + namespace = mixinPath[p] + if (namespace.matchCondition) { + conditionResult[f] = + conditionResult[f] && namespace.matchCondition(null, context) + } + } + if (mixin.matchCondition) { + conditionResult[f] = + conditionResult[f] && mixin.matchCondition(args, context) + } + } + if (conditionResult[0] || conditionResult[1]) { + if (conditionResult[0] != conditionResult[1]) { + return conditionResult[1] ? defTrue : defFalse + } + + return defNone + } + return defFalseEitherCase + } + + for (i = 0; i < this.arguments.length; i++) { + arg = this.arguments[i] + argValue = arg.value.eval(context) + if (arg.expand && Array.isArray(argValue.value)) { + argValue = argValue.value + for (m = 0; m < argValue.length; m++) { + args.push({ value: argValue[m] }) + } + } else { + args.push({ name: arg.name, value: argValue }) + } + } + + noArgumentsFilter = function (rule) { + return rule.matchArgs(null, context) + } + + for (i = 0; i < context.frames.length; i++) { + if ( + (mixins = context.frames[i].find( + this.selector, + null, + noArgumentsFilter + )).length > 0 + ) { + isOneFound = true + + // To make `default()` function independent of definition order we have two "subpasses" here. + // At first we evaluate each guard *twice* (with `default() == true` and `default() == false`), + // and build candidate list with corresponding flags. Then, when we know all possible matches, + // we make a final decision. + + for (m = 0; m < mixins.length; m++) { + mixin = mixins[m].rule + mixinPath = mixins[m].path + isRecursive = false + for (f = 0; f < context.frames.length; f++) { + if ( + !(mixin instanceof MixinDefinition) && + mixin === (context.frames[f].originalRuleset || context.frames[f]) + ) { + isRecursive = true + break + } + } + if (isRecursive) { + continue + } + + if (mixin.matchArgs(args, context)) { + candidate = { mixin, group: calcDefGroup(mixin, mixinPath) } + + if (candidate.group !== defFalseEitherCase) { + candidates.push(candidate) + } + + match = true + } + } + + defaultFunc.reset() + + count = [0, 0, 0] + for (m = 0; m < candidates.length; m++) { + count[candidates[m].group]++ + } + + if (count[defNone] > 0) { + defaultResult = defFalse + } else { + defaultResult = defTrue + if (count[defTrue] + count[defFalse] > 1) { + throw { + type: 'Runtime', + message: `Ambiguous use of \`default()\` found when matching for \`${this.format( + args + )}\``, + index: this.getIndex(), + filename: this.fileInfo().filename + } + } + } + + for (m = 0; m < candidates.length; m++) { + candidate = candidates[m].group + if (candidate === defNone || candidate === defaultResult) { + try { + mixin = candidates[m].mixin + if (!(mixin instanceof MixinDefinition)) { + originalRuleset = mixin.originalRuleset || mixin + mixin = new MixinDefinition( + '', + [], + mixin.rules, + null, + false, + null, + originalRuleset.visibilityInfo() + ) + mixin.originalRuleset = originalRuleset + } + const newRules = mixin.evalCall( + context, + args, + this.important + ).rules + this._setVisibilityToReplacement(newRules) + Array.prototype.push.apply(rules, newRules) + } catch (e) { + throw { + message: e.message, + index: this.getIndex(), + filename: this.fileInfo().filename, + stack: e.stack + } + } + } + } + + if (match) { + return rules + } + } + } + if (isOneFound) { + throw { + type: 'Runtime', + message: `No matching definition was found for \`${this.format( + args + )}\``, + index: this.getIndex(), + filename: this.fileInfo().filename + } + } else { + throw { + type: 'Name', + message: `${this.selector.toCSS().trim()} is undefined`, + index: this.getIndex(), + filename: this.fileInfo().filename + } + } + }, + + _setVisibilityToReplacement(replacement) { + let i, rule + if (this.blocksVisibility()) { + for (i = 0; i < replacement.length; i++) { + rule = replacement[i] + rule.addVisibilityBlock() + } + } + }, + + format(args) { + return `${this.selector.toCSS().trim()}(${ + args + ? args + .map(function (a) { + let argValue = '' + if (a.name) { + argValue += `${a.name}:` + } + if (a.value.toCSS) { + argValue += a.value.toCSS() + } else { + argValue += '???' + } + return argValue + }) + .join(', ') + : '' + })` + } +}) + +export default MixinCall diff --git a/src/less/tree/mixin-definition.js b/src/less/tree/mixin-definition.js new file mode 100644 index 0000000..17b0b7b --- /dev/null +++ b/src/less/tree/mixin-definition.js @@ -0,0 +1,300 @@ +import Selector from './selector' +import Element from './element' +import Ruleset from './ruleset' +import Declaration from './declaration' +import DetachedRuleset from './detached-ruleset' +import Expression from './expression' +import contexts from '../contexts' +import * as utils from '../utils' + +const Definition = function ( + name, + params, + rules, + condition, + variadic, + frames, + visibilityInfo +) { + this.name = name || 'anonymous mixin' + this.selectors = [ + new Selector([new Element(null, name, false, this._index, this._fileInfo)]) + ] + this.params = params + this.condition = condition + this.variadic = variadic + this.arity = params.length + this.rules = rules + this._lookups = {} + const optionalParameters = [] + this.required = params.reduce(function (count, p) { + if (!p.name || (p.name && !p.value)) { + return count + 1 + } else { + optionalParameters.push(p.name) + return count + } + }, 0) + this.optionalParameters = optionalParameters + this.frames = frames + this.copyVisibilityInfo(visibilityInfo) + this.allowRoot = true +} + +Definition.prototype = Object.assign(new Ruleset(), { + type: 'MixinDefinition', + evalFirst: true, + + accept(visitor) { + if (this.params && this.params.length) { + this.params = visitor.visitArray(this.params) + } + this.rules = visitor.visitArray(this.rules) + if (this.condition) { + this.condition = visitor.visit(this.condition) + } + }, + + evalParams(context, mixinEnv, args, evaldArguments) { + /* jshint boss:true */ + const frame = new Ruleset(null, null) + + let varargs + let arg + const params = utils.copyArray(this.params) + let i + let j + let val + let name + let isNamedFound + let argIndex + let argsLength = 0 + + if ( + mixinEnv.frames && + mixinEnv.frames[0] && + mixinEnv.frames[0].functionRegistry + ) { + frame.functionRegistry = mixinEnv.frames[0].functionRegistry.inherit() + } + mixinEnv = new contexts.Eval(mixinEnv, [frame].concat(mixinEnv.frames)) + + if (args) { + args = utils.copyArray(args) + argsLength = args.length + + for (i = 0; i < argsLength; i++) { + arg = args[i] + if ((name = arg && arg.name)) { + isNamedFound = false + for (j = 0; j < params.length; j++) { + if (!evaldArguments[j] && name === params[j].name) { + evaldArguments[j] = arg.value.eval(context) + frame.prependRule(new Declaration(name, arg.value.eval(context))) + isNamedFound = true + break + } + } + if (isNamedFound) { + args.splice(i, 1) + i-- + continue + } else { + throw { + type: 'Runtime', + message: `Named argument for ${this.name} ${args[i].name} not found` + } + } + } + } + } + argIndex = 0 + for (i = 0; i < params.length; i++) { + if (evaldArguments[i]) { + continue + } + + arg = args && args[argIndex] + + if ((name = params[i].name)) { + if (params[i].variadic) { + varargs = [] + for (j = argIndex; j < argsLength; j++) { + varargs.push(args[j].value.eval(context)) + } + frame.prependRule( + new Declaration(name, new Expression(varargs).eval(context)) + ) + } else { + val = arg && arg.value + if (val) { + // This was a mixin call, pass in a detached ruleset of it's eval'd rules + if (Array.isArray(val)) { + val = new DetachedRuleset(new Ruleset('', val)) + } else { + val = val.eval(context) + } + } else if (params[i].value) { + val = params[i].value.eval(mixinEnv) + frame.resetCache() + } else { + throw { + type: 'Runtime', + message: `wrong number of arguments for ${this.name} (${argsLength} for ${this.arity})` + } + } + + frame.prependRule(new Declaration(name, val)) + evaldArguments[i] = val + } + } + + if (params[i].variadic && args) { + for (j = argIndex; j < argsLength; j++) { + evaldArguments[j] = args[j].value.eval(context) + } + } + argIndex++ + } + + return frame + }, + + makeImportant() { + const rules = !this.rules + ? this.rules + : this.rules.map(function (r) { + if (r.makeImportant) { + return r.makeImportant(true) + } else { + return r + } + }) + const result = new Definition( + this.name, + this.params, + rules, + this.condition, + this.variadic, + this.frames + ) + return result + }, + + eval(context) { + return new Definition( + this.name, + this.params, + this.rules, + this.condition, + this.variadic, + this.frames || utils.copyArray(context.frames) + ) + }, + + evalCall(context, args, important) { + const _arguments = [] + const mixinFrames = this.frames + ? this.frames.concat(context.frames) + : context.frames + const frame = this.evalParams( + context, + new contexts.Eval(context, mixinFrames), + args, + _arguments + ) + let rules + let ruleset + + frame.prependRule( + new Declaration('@arguments', new Expression(_arguments).eval(context)) + ) + + rules = utils.copyArray(this.rules) + + ruleset = new Ruleset(null, rules) + ruleset.originalRuleset = this + ruleset = ruleset.eval( + new contexts.Eval(context, [this, frame].concat(mixinFrames)) + ) + if (important) { + ruleset = ruleset.makeImportant() + } + return ruleset + }, + + matchCondition(args, context) { + if ( + this.condition && + !this.condition.eval( + new contexts.Eval( + context, + [ + this.evalParams( + context /* the parameter variables */, + new contexts.Eval( + context, + this.frames + ? this.frames.concat(context.frames) + : context.frames + ), + args, + [] + ) + ] + .concat(this.frames || []) // the parent namespace/mixin frames + .concat(context.frames) + ) + ) + ) { + // the current environment frames + return false + } + return true + }, + + matchArgs(args, context) { + const allArgsCnt = (args && args.length) || 0 + let len + const optionalParameters = this.optionalParameters + const requiredArgsCnt = !args + ? 0 + : args.reduce(function (count, p) { + if (optionalParameters.indexOf(p.name) < 0) { + return count + 1 + } else { + return count + } + }, 0) + + if (!this.variadic) { + if (requiredArgsCnt < this.required) { + return false + } + if (allArgsCnt > this.params.length) { + return false + } + } else { + if (requiredArgsCnt < this.required - 1) { + return false + } + } + + // check patterns + len = Math.min(requiredArgsCnt, this.arity) + + for (let i = 0; i < len; i++) { + if (!this.params[i].name && !this.params[i].variadic) { + if ( + args[i].value.eval(context).toCSS() != + this.params[i].value.eval(context).toCSS() + ) { + return false + } + } + } + return true + } +}) + +export default Definition diff --git a/src/less/tree/namespace-value.js b/src/less/tree/namespace-value.js new file mode 100644 index 0000000..bbd2120 --- /dev/null +++ b/src/less/tree/namespace-value.js @@ -0,0 +1,85 @@ +import Node from './node' +import Variable from './variable' +import Ruleset from './ruleset' +import Selector from './selector' + +const NamespaceValue = function (ruleCall, lookups, index, fileInfo) { + this.value = ruleCall + this.lookups = lookups + this._index = index + this._fileInfo = fileInfo +} + +NamespaceValue.prototype = Object.assign(new Node(), { + type: 'NamespaceValue', + + eval(context) { + let i, + name, + rules = this.value.eval(context) + + for (i = 0; i < this.lookups.length; i++) { + name = this.lookups[i] + + /** + * Eval'd DRs return rulesets. + * Eval'd mixins return rules, so let's make a ruleset if we need it. + * We need to do this because of late parsing of values + */ + if (Array.isArray(rules)) { + rules = new Ruleset([new Selector()], rules) + } + + if (name === '') { + rules = rules.lastDeclaration() + } else if (name.charAt(0) === '@') { + if (name.charAt(1) === '@') { + name = `@${new Variable(name.substr(1)).eval(context).value}` + } + if (rules.variables) { + rules = rules.variable(name) + } + + if (!rules) { + throw { + type: 'Name', + message: `variable ${name} not found`, + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + } else { + if (name.substring(0, 2) === '$@') { + name = `$${new Variable(name.substr(1)).eval(context).value}` + } else { + name = name.charAt(0) === '$' ? name : `$${name}` + } + if (rules.properties) { + rules = rules.property(name) + } + + if (!rules) { + throw { + type: 'Name', + message: `property "${name.substr(1)}" not found`, + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + // Properties are an array of values, since a ruleset can have multiple props. + // We pick the last one (the "cascaded" value) + rules = rules[rules.length - 1] + } + + if (rules.value) { + rules = rules.eval(context).value + } + if (rules.ruleset) { + rules = rules.ruleset.eval(context) + } + } + return rules + } +}) + +export default NamespaceValue diff --git a/src/less/tree/negative.js b/src/less/tree/negative.js new file mode 100644 index 0000000..aa87ecb --- /dev/null +++ b/src/less/tree/negative.js @@ -0,0 +1,25 @@ +import Node from './node' +import Operation from './operation' +import Dimension from './dimension' + +const Negative = function (node) { + this.value = node +} + +Negative.prototype = Object.assign(new Node(), { + type: 'Negative', + + genCSS(context, output) { + output.add('-') + this.value.genCSS(context, output) + }, + + eval(context) { + if (context.isMathOn()) { + return new Operation('*', [new Dimension(-1), this.value]).eval(context) + } + return new Negative(this.value.eval(context)) + } +}) + +export default Negative diff --git a/src/less/tree/node.js b/src/less/tree/node.js new file mode 100644 index 0000000..662a0e0 --- /dev/null +++ b/src/less/tree/node.js @@ -0,0 +1,193 @@ +/** + * The reason why Node is a class and other nodes simply do not extend + * from Node (since we're transpiling) is due to this issue: + * + * @see https://github.com/less/less.js/issues/3434 + */ +class Node { + constructor() { + this.parent = null + this.visibilityBlocks = undefined + this.nodeVisible = undefined + this.rootNode = null + this.parsed = null + } + + get currentFileInfo() { + return this.fileInfo() + } + + get index() { + return this.getIndex() + } + + setParent(nodes, parent) { + function set(node) { + if (node && node instanceof Node) { + node.parent = parent + } + } + if (Array.isArray(nodes)) { + nodes.forEach(set) + } else { + set(nodes) + } + } + + getIndex() { + return this._index || (this.parent && this.parent.getIndex()) || 0 + } + + fileInfo() { + return this._fileInfo || (this.parent && this.parent.fileInfo()) || {} + } + + isRulesetLike() { + return false + } + + toCSS(context) { + const strs = [] + this.genCSS(context, { + // remove when genCSS has JSDoc types + // eslint-disable-next-line no-unused-vars + add: function (chunk, fileInfo, index) { + strs.push(chunk) + }, + isEmpty: function () { + return strs.length === 0 + } + }) + return strs.join('') + } + + genCSS(context, output) { + output.add(this.value) + } + + accept(visitor) { + this.value = visitor.visit(this.value) + } + + eval() { + return this + } + + _operate(context, op, a, b) { + switch (op) { + case '+': + return a + b + case '-': + return a - b + case '*': + return a * b + case '/': + return a / b + } + } + + fround(context, value) { + const precision = context && context.numPrecision + // add "epsilon" to ensure numbers like 1.000000005 (represented as 1.000000004999...) are properly rounded: + return precision ? Number((value + 2e-16).toFixed(precision)) : value + } + + static compare(a, b) { + /* returns: + -1: a < b + 0: a = b + 1: a > b + and *any* other value for a != b (e.g. undefined, NaN, -2 etc.) */ + + if ( + a.compare && + // for "symmetric results" force toCSS-based comparison + // of Quoted or Anonymous if either value is one of those + !(b.type === 'Quoted' || b.type === 'Anonymous') + ) { + return a.compare(b) + } else if (b.compare) { + return -b.compare(a) + } else if (a.type !== b.type) { + return undefined + } + + a = a.value + b = b.value + if (!Array.isArray(a)) { + return a === b ? 0 : undefined + } + if (a.length !== b.length) { + return undefined + } + for (let i = 0; i < a.length; i++) { + if (Node.compare(a[i], b[i]) !== 0) { + return undefined + } + } + return 0 + } + + static numericCompare(a, b) { + return a < b ? -1 : a === b ? 0 : a > b ? 1 : undefined + } + + // Returns true if this node represents root of ast imported by reference + blocksVisibility() { + if (this.visibilityBlocks === undefined) { + this.visibilityBlocks = 0 + } + return this.visibilityBlocks !== 0 + } + + addVisibilityBlock() { + if (this.visibilityBlocks === undefined) { + this.visibilityBlocks = 0 + } + this.visibilityBlocks = this.visibilityBlocks + 1 + } + + removeVisibilityBlock() { + if (this.visibilityBlocks === undefined) { + this.visibilityBlocks = 0 + } + this.visibilityBlocks = this.visibilityBlocks - 1 + } + + // Turns on node visibility - if called node will be shown in output regardless + // of whether it comes from import by reference or not + ensureVisibility() { + this.nodeVisible = true + } + + // Turns off node visibility - if called node will NOT be shown in output regardless + // of whether it comes from import by reference or not + ensureInvisibility() { + this.nodeVisible = false + } + + // return values: + // false - the node must not be visible + // true - the node must be visible + // undefined or null - the node has the same visibility as its parent + isVisible() { + return this.nodeVisible + } + + visibilityInfo() { + return { + visibilityBlocks: this.visibilityBlocks, + nodeVisible: this.nodeVisible + } + } + + copyVisibilityInfo(info) { + if (!info) { + return + } + this.visibilityBlocks = info.visibilityBlocks + this.nodeVisible = info.nodeVisible + } +} + +export default Node diff --git a/src/less/tree/operation.js b/src/less/tree/operation.js new file mode 100644 index 0000000..9258e23 --- /dev/null +++ b/src/less/tree/operation.js @@ -0,0 +1,63 @@ +import Node from './node' +import Color from './color' +import Dimension from './dimension' +import * as Constants from '../constants' +const MATH = Constants.Math + +const Operation = function (op, operands, isSpaced) { + this.op = op.trim() + this.operands = operands + this.isSpaced = isSpaced +} + +Operation.prototype = Object.assign(new Node(), { + type: 'Operation', + + accept(visitor) { + this.operands = visitor.visitArray(this.operands) + }, + + eval(context) { + let a = this.operands[0].eval(context), + b = this.operands[1].eval(context), + op + + if (context.isMathOn(this.op)) { + op = this.op === './' ? '/' : this.op + if (a instanceof Dimension && b instanceof Color) { + a = a.toColor() + } + if (b instanceof Dimension && a instanceof Color) { + b = b.toColor() + } + if (!a.operate || !b.operate) { + if ( + (a instanceof Operation || b instanceof Operation) && + a.op === '/' && + context.math === MATH.PARENS_DIVISION + ) { + return new Operation(this.op, [a, b], this.isSpaced) + } + throw { type: 'Operation', message: 'Operation on an invalid type' } + } + + return a.operate(context, op, b) + } else { + return new Operation(this.op, [a, b], this.isSpaced) + } + }, + + genCSS(context, output) { + this.operands[0].genCSS(context, output) + if (this.isSpaced) { + output.add(' ') + } + output.add(this.op) + if (this.isSpaced) { + output.add(' ') + } + this.operands[1].genCSS(context, output) + } +}) + +export default Operation diff --git a/src/less/tree/paren.js b/src/less/tree/paren.js new file mode 100644 index 0000000..e049c0f --- /dev/null +++ b/src/less/tree/paren.js @@ -0,0 +1,21 @@ +import Node from './node' + +const Paren = function (node) { + this.value = node +} + +Paren.prototype = Object.assign(new Node(), { + type: 'Paren', + + genCSS(context, output) { + output.add('(') + this.value.genCSS(context, output) + output.add(')') + }, + + eval(context) { + return new Paren(this.value.eval(context)) + } +}) + +export default Paren diff --git a/src/less/tree/property.js b/src/less/tree/property.js new file mode 100644 index 0000000..748df0c --- /dev/null +++ b/src/less/tree/property.js @@ -0,0 +1,85 @@ +import Node from './node' +import Declaration from './declaration' + +const Property = function (name, index, currentFileInfo) { + this.name = name + this._index = index + this._fileInfo = currentFileInfo +} + +Property.prototype = Object.assign(new Node(), { + type: 'Property', + + eval(context) { + let property + const name = this.name + // TODO: shorten this reference + const mergeRules = + context.pluginManager.less.visitors.ToCSSVisitor.prototype._mergeRules + + if (this.evaluating) { + throw { + type: 'Name', + message: `Recursive property reference for ${name}`, + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + + this.evaluating = true + + property = this.find(context.frames, function (frame) { + let v + const vArr = frame.property(name) + if (vArr) { + for (let i = 0; i < vArr.length; i++) { + v = vArr[i] + + vArr[i] = new Declaration( + v.name, + v.value, + v.important, + v.merge, + v.index, + v.currentFileInfo, + v.inline, + v.variable + ) + } + mergeRules(vArr) + + v = vArr[vArr.length - 1] + if (v.important) { + const importantScope = + context.importantScope[context.importantScope.length - 1] + importantScope.important = v.important + } + v = v.value.eval(context) + return v + } + }) + if (property) { + this.evaluating = false + return property + } else { + throw { + type: 'Name', + message: `Property '${name}' is undefined`, + filename: this.currentFileInfo.filename, + index: this.index + } + } + }, + + find(obj, fun) { + for (let i = 0, r; i < obj.length; i++) { + r = fun.call(obj, obj[i]) + if (r) { + return r + } + } + return null + } +}) + +export default Property diff --git a/src/less/tree/quoted.js b/src/less/tree/quoted.js new file mode 100644 index 0000000..111faf2 --- /dev/null +++ b/src/less/tree/quoted.js @@ -0,0 +1,79 @@ +import Node from './node' +import Variable from './variable' +import Property from './property' + +const Quoted = function (str, content, escaped, index, currentFileInfo) { + this.escaped = escaped === undefined ? true : escaped + this.value = content || '' + this.quote = str.charAt(0) + this._index = index + this._fileInfo = currentFileInfo + this.variableRegex = /@\{([\w-]+)\}/g + this.propRegex = /\$\{([\w-]+)\}/g + this.allowRoot = escaped +} + +Quoted.prototype = Object.assign(new Node(), { + type: 'Quoted', + + genCSS(context, output) { + if (!this.escaped) { + output.add(this.quote, this.fileInfo(), this.getIndex()) + } + output.add(this.value) + if (!this.escaped) { + output.add(this.quote) + } + }, + + containsVariables() { + return this.value.match(this.variableRegex) + }, + + eval(context) { + const that = this + let value = this.value + const variableReplacement = function (_, name) { + const v = new Variable(`@${name}`, that.getIndex(), that.fileInfo()).eval( + context, + true + ) + return v instanceof Quoted ? v.value : v.toCSS() + } + const propertyReplacement = function (_, name) { + const v = new Property(`$${name}`, that.getIndex(), that.fileInfo()).eval( + context, + true + ) + return v instanceof Quoted ? v.value : v.toCSS() + } + function iterativeReplace(value, regexp, replacementFnc) { + let evaluatedValue = value + do { + value = evaluatedValue.toString() + evaluatedValue = value.replace(regexp, replacementFnc) + } while (value !== evaluatedValue) + return evaluatedValue + } + value = iterativeReplace(value, this.variableRegex, variableReplacement) + value = iterativeReplace(value, this.propRegex, propertyReplacement) + return new Quoted( + this.quote + value + this.quote, + value, + this.escaped, + this.getIndex(), + this.fileInfo() + ) + }, + + compare(other) { + // when comparing quoted strings allow the quote to differ + if (other.type === 'Quoted' && !this.escaped && !other.escaped) { + return Node.numericCompare(this.value, other.value) + } else { + return other.toCSS && this.toCSS() === other.toCSS() ? 0 : undefined + } + } +}) + +export default Quoted diff --git a/src/less/tree/ruleset.js b/src/less/tree/ruleset.js new file mode 100644 index 0000000..27ac29e --- /dev/null +++ b/src/less/tree/ruleset.js @@ -0,0 +1,965 @@ +import Node from './node' +import Declaration from './declaration' +import Keyword from './keyword' +import Comment from './comment' +import Paren from './paren' +import Selector from './selector' +import Element from './element' +import Anonymous from './anonymous' +import contexts from '../contexts' +import globalFunctionRegistry from '../functions/function-registry' +import defaultFunc from '../functions/default' +import getDebugInfo from './debug-info' +import * as utils from '../utils' +import Parser from '../parser/parser' + +const Ruleset = function (selectors, rules, strictImports, visibilityInfo) { + this.selectors = selectors + this.rules = rules + this._lookups = {} + this._variables = null + this._properties = null + this.strictImports = strictImports + this.copyVisibilityInfo(visibilityInfo) + this.allowRoot = true + + this.setParent(this.selectors, this) + this.setParent(this.rules, this) +} + +Ruleset.prototype = Object.assign(new Node(), { + type: 'Ruleset', + isRuleset: true, + + isRulesetLike() { + return true + }, + + accept(visitor) { + if (this.paths) { + this.paths = visitor.visitArray(this.paths, true) + } else if (this.selectors) { + this.selectors = visitor.visitArray(this.selectors) + } + if (this.rules && this.rules.length) { + this.rules = visitor.visitArray(this.rules) + } + }, + + eval(context) { + let selectors + let selCnt + let selector + let i + let hasVariable + let hasOnePassingSelector = false + + if (this.selectors && (selCnt = this.selectors.length)) { + selectors = new Array(selCnt) + defaultFunc.error({ + type: 'Syntax', + message: 'it is currently only allowed in parametric mixin guards,' + }) + + for (i = 0; i < selCnt; i++) { + selector = this.selectors[i].eval(context) + for (let j = 0; j < selector.elements.length; j++) { + if (selector.elements[j].isVariable) { + hasVariable = true + break + } + } + selectors[i] = selector + if (selector.evaldCondition) { + hasOnePassingSelector = true + } + } + + if (hasVariable) { + const toParseSelectors = new Array(selCnt) + for (i = 0; i < selCnt; i++) { + selector = selectors[i] + toParseSelectors[i] = selector.toCSS(context) + } + const startingIndex = selectors[0].getIndex() + const selectorFileInfo = selectors[0].fileInfo() + new Parser( + context, + this.parse.importManager, + selectorFileInfo, + startingIndex + ).parseNode( + toParseSelectors.join(','), + ['selectors'], + function (err, result) { + if (result) { + selectors = utils.flattenArray(result) + } + } + ) + } + + defaultFunc.reset() + } else { + hasOnePassingSelector = true + } + + let rules = this.rules ? utils.copyArray(this.rules) : null + const ruleset = new Ruleset( + selectors, + rules, + this.strictImports, + this.visibilityInfo() + ) + let rule + let subRule + + ruleset.originalRuleset = this + ruleset.root = this.root + ruleset.firstRoot = this.firstRoot + ruleset.allowImports = this.allowImports + + if (this.debugInfo) { + ruleset.debugInfo = this.debugInfo + } + + if (!hasOnePassingSelector) { + rules.length = 0 + } + + // inherit a function registry from the frames stack when possible; + // otherwise from the global registry + ruleset.functionRegistry = (function (frames) { + let i = 0 + const n = frames.length + let found + for (; i !== n; ++i) { + found = frames[i].functionRegistry + if (found) { + return found + } + } + return globalFunctionRegistry + })(context.frames).inherit() + + // push the current ruleset to the frames stack + const ctxFrames = context.frames + ctxFrames.unshift(ruleset) + + // currrent selectors + let ctxSelectors = context.selectors + if (!ctxSelectors) { + context.selectors = ctxSelectors = [] + } + ctxSelectors.unshift(this.selectors) + + // Evaluate imports + if (ruleset.root || ruleset.allowImports || !ruleset.strictImports) { + ruleset.evalImports(context) + } + + // Store the frames around mixin definitions, + // so they can be evaluated like closures when the time comes. + const rsRules = ruleset.rules + for (i = 0; (rule = rsRules[i]); i++) { + if (rule.evalFirst) { + rsRules[i] = rule.eval(context) + } + } + + const mediaBlockCount = + (context.mediaBlocks && context.mediaBlocks.length) || 0 + + // Evaluate mixin calls. + for (i = 0; (rule = rsRules[i]); i++) { + if (rule.type === 'MixinCall') { + /* jshint loopfunc:true */ + rules = rule.eval(context).filter(function (r) { + if (r instanceof Declaration && r.variable) { + // do not pollute the scope if the variable is + // already there. consider returning false here + // but we need a way to "return" variable from mixins + return !ruleset.variable(r.name) + } + return true + }) + rsRules.splice.apply(rsRules, [i, 1].concat(rules)) + i += rules.length - 1 + ruleset.resetCache() + } else if (rule.type === 'VariableCall') { + /* jshint loopfunc:true */ + rules = rule.eval(context).rules.filter(function (r) { + if (r instanceof Declaration && r.variable) { + // do not pollute the scope at all + return false + } + return true + }) + rsRules.splice.apply(rsRules, [i, 1].concat(rules)) + i += rules.length - 1 + ruleset.resetCache() + } + } + + // Evaluate everything else + for (i = 0; (rule = rsRules[i]); i++) { + if (!rule.evalFirst) { + rsRules[i] = rule = rule.eval ? rule.eval(context) : rule + } + } + + // Evaluate everything else + for (i = 0; (rule = rsRules[i]); i++) { + // for rulesets, check if it is a css guard and can be removed + if ( + rule instanceof Ruleset && + rule.selectors && + rule.selectors.length === 1 + ) { + // check if it can be folded in (e.g. & where) + if (rule.selectors[0] && rule.selectors[0].isJustParentSelector()) { + rsRules.splice(i--, 1) + + for (let j = 0; (subRule = rule.rules[j]); j++) { + if (subRule instanceof Node) { + subRule.copyVisibilityInfo(rule.visibilityInfo()) + if (!(subRule instanceof Declaration) || !subRule.variable) { + rsRules.splice(++i, 0, subRule) + } + } + } + } + } + } + + // Pop the stack + ctxFrames.shift() + ctxSelectors.shift() + + if (context.mediaBlocks) { + for (i = mediaBlockCount; i < context.mediaBlocks.length; i++) { + context.mediaBlocks[i].bubbleSelectors(selectors) + } + } + + return ruleset + }, + + evalImports(context) { + const rules = this.rules + let i + let importRules + if (!rules) { + return + } + + for (i = 0; i < rules.length; i++) { + if (rules[i].type === 'Import') { + importRules = rules[i].eval(context) + if (importRules && (importRules.length || importRules.length === 0)) { + rules.splice.apply(rules, [i, 1].concat(importRules)) + i += importRules.length - 1 + } else { + rules.splice(i, 1, importRules) + } + this.resetCache() + } + } + }, + + makeImportant() { + const result = new Ruleset( + this.selectors, + this.rules.map(function (r) { + if (r.makeImportant) { + return r.makeImportant() + } else { + return r + } + }), + this.strictImports, + this.visibilityInfo() + ) + + return result + }, + + matchArgs(args) { + return !args || args.length === 0 + }, + + // lets you call a css selector with a guard + matchCondition(args, context) { + const lastSelector = this.selectors[this.selectors.length - 1] + if (!lastSelector.evaldCondition) { + return false + } + if ( + lastSelector.condition && + !lastSelector.condition.eval(new contexts.Eval(context, context.frames)) + ) { + return false + } + return true + }, + + resetCache() { + this._rulesets = null + this._variables = null + this._properties = null + this._lookups = {} + }, + + variables() { + if (!this._variables) { + this._variables = !this.rules + ? {} + : this.rules.reduce(function (hash, r) { + if (r instanceof Declaration && r.variable === true) { + hash[r.name] = r + } + // when evaluating variables in an import statement, imports have not been eval'd + // so we need to go inside import statements. + // guard against root being a string (in the case of inlined less) + if (r.type === 'Import' && r.root && r.root.variables) { + const vars = r.root.variables() + for (const name in vars) { + // eslint-disable-next-line no-prototype-builtins + if (vars.hasOwnProperty(name)) { + hash[name] = r.root.variable(name) + } + } + } + return hash + }, {}) + } + return this._variables + }, + + properties() { + if (!this._properties) { + this._properties = !this.rules + ? {} + : this.rules.reduce(function (hash, r) { + if (r instanceof Declaration && r.variable !== true) { + const name = + r.name.length === 1 && r.name[0] instanceof Keyword + ? r.name[0].value + : r.name + // Properties don't overwrite as they can merge + if (!hash[`$${name}`]) { + hash[`$${name}`] = [r] + } else { + hash[`$${name}`].push(r) + } + } + return hash + }, {}) + } + return this._properties + }, + + variable(name) { + const decl = this.variables()[name] + if (decl) { + return this.parseValue(decl) + } + }, + + property(name) { + const decl = this.properties()[name] + if (decl) { + return this.parseValue(decl) + } + }, + + lastDeclaration() { + for (let i = this.rules.length; i > 0; i--) { + const decl = this.rules[i - 1] + if (decl instanceof Declaration) { + return this.parseValue(decl) + } + } + }, + + parseValue(toParse) { + const self = this + function transformDeclaration(decl) { + if (decl.value instanceof Anonymous && !decl.parsed) { + if (typeof decl.value.value === 'string') { + new Parser( + this.parse.context, + this.parse.importManager, + decl.fileInfo(), + decl.value.getIndex() + ).parseNode( + decl.value.value, + ['value', 'important'], + function (err, result) { + if (err) { + decl.parsed = true + } + if (result) { + decl.value = result[0] + decl.important = result[1] || '' + decl.parsed = true + } + } + ) + } else { + decl.parsed = true + } + + return decl + } else { + return decl + } + } + if (!Array.isArray(toParse)) { + return transformDeclaration.call(self, toParse) + } else { + const nodes = [] + toParse.forEach(function (n) { + nodes.push(transformDeclaration.call(self, n)) + }) + return nodes + } + }, + + rulesets() { + if (!this.rules) { + return [] + } + + const filtRules = [] + const rules = this.rules + let i + let rule + + for (i = 0; (rule = rules[i]); i++) { + if (rule.isRuleset) { + filtRules.push(rule) + } + } + + return filtRules + }, + + prependRule(rule) { + const rules = this.rules + if (rules) { + rules.unshift(rule) + } else { + this.rules = [rule] + } + this.setParent(rule, this) + }, + + find(selector, self, filter) { + self = self || this + const rules = [] + let match + let foundMixins + const key = selector.toCSS() + + if (key in this._lookups) { + return this._lookups[key] + } + + this.rulesets().forEach(function (rule) { + if (rule !== self) { + for (let j = 0; j < rule.selectors.length; j++) { + match = selector.match(rule.selectors[j]) + if (match) { + if (selector.elements.length > match) { + if (!filter || filter(rule)) { + foundMixins = rule.find( + new Selector(selector.elements.slice(match)), + self, + filter + ) + for (let i = 0; i < foundMixins.length; ++i) { + foundMixins[i].path.push(rule) + } + Array.prototype.push.apply(rules, foundMixins) + } + } else { + rules.push({ rule, path: [] }) + } + break + } + } + } + }) + this._lookups[key] = rules + return rules + }, + + genCSS(context, output) { + let i + let j + const charsetRuleNodes = [] + let ruleNodes = [] + + let // Line number debugging + debugInfo + + let rule + let path + + context.tabLevel = context.tabLevel || 0 + + if (!this.root) { + context.tabLevel++ + } + + const tabRuleStr = context.compress + ? '' + : Array(context.tabLevel + 1).join(' ') + const tabSetStr = context.compress ? '' : Array(context.tabLevel).join(' ') + let sep + + let charsetNodeIndex = 0 + let importNodeIndex = 0 + for (i = 0; (rule = this.rules[i]); i++) { + if (rule instanceof Comment) { + if (importNodeIndex === i) { + importNodeIndex++ + } + ruleNodes.push(rule) + } else if (rule.isCharset && rule.isCharset()) { + ruleNodes.splice(charsetNodeIndex, 0, rule) + charsetNodeIndex++ + importNodeIndex++ + } else if (rule.type === 'Import') { + ruleNodes.splice(importNodeIndex, 0, rule) + importNodeIndex++ + } else { + ruleNodes.push(rule) + } + } + ruleNodes = charsetRuleNodes.concat(ruleNodes) + + // If this is the root node, we don't render + // a selector, or {}. + if (!this.root) { + debugInfo = getDebugInfo(context, this, tabSetStr) + + if (debugInfo) { + output.add(debugInfo) + output.add(tabSetStr) + } + + const paths = this.paths + const pathCnt = paths.length + let pathSubCnt + + sep = context.compress ? ',' : `,\n${tabSetStr}` + + for (i = 0; i < pathCnt; i++) { + path = paths[i] + if (!(pathSubCnt = path.length)) { + continue + } + if (i > 0) { + output.add(sep) + } + + context.firstSelector = true + path[0].genCSS(context, output) + + context.firstSelector = false + for (j = 1; j < pathSubCnt; j++) { + path[j].genCSS(context, output) + } + } + + output.add((context.compress ? '{' : ' {\n') + tabRuleStr) + } + + // Compile rules and rulesets + for (i = 0; (rule = ruleNodes[i]); i++) { + if (i + 1 === ruleNodes.length) { + context.lastRule = true + } + + const currentLastRule = context.lastRule + if (rule.isRulesetLike(rule)) { + context.lastRule = false + } + + if (rule.genCSS) { + rule.genCSS(context, output) + } else if (rule.value) { + output.add(rule.value.toString()) + } + + context.lastRule = currentLastRule + + if (!context.lastRule && rule.isVisible()) { + output.add(context.compress ? '' : `\n${tabRuleStr}`) + } else { + context.lastRule = false + } + } + + if (!this.root) { + output.add(context.compress ? '}' : `\n${tabSetStr}}`) + context.tabLevel-- + } + + if (!output.isEmpty() && !context.compress && this.firstRoot) { + output.add('\n') + } + }, + + joinSelectors(paths, context, selectors) { + for (let s = 0; s < selectors.length; s++) { + this.joinSelector(paths, context, selectors[s]) + } + }, + + joinSelector(paths, context, selector) { + function createParenthesis(elementsToPak, originalElement) { + let replacementParen, j + if (elementsToPak.length === 0) { + replacementParen = new Paren(elementsToPak[0]) + } else { + const insideParent = new Array(elementsToPak.length) + for (j = 0; j < elementsToPak.length; j++) { + insideParent[j] = new Element( + null, + elementsToPak[j], + originalElement.isVariable, + originalElement._index, + originalElement._fileInfo + ) + } + replacementParen = new Paren(new Selector(insideParent)) + } + return replacementParen + } + + function createSelector(containedElement, originalElement) { + let element, selector + element = new Element( + null, + containedElement, + originalElement.isVariable, + originalElement._index, + originalElement._fileInfo + ) + selector = new Selector([element]) + return selector + } + + // joins selector path from `beginningPath` with selector path in `addPath` + // `replacedElement` contains element that is being replaced by `addPath` + // returns concatenated path + function addReplacementIntoPath( + beginningPath, + addPath, + replacedElement, + originalSelector + ) { + let newSelectorPath, lastSelector, newJoinedSelector + // our new selector path + newSelectorPath = [] + + // construct the joined selector - if & is the first thing this will be empty, + // if not newJoinedSelector will be the last set of elements in the selector + if (beginningPath.length > 0) { + newSelectorPath = utils.copyArray(beginningPath) + lastSelector = newSelectorPath.pop() + newJoinedSelector = originalSelector.createDerived( + utils.copyArray(lastSelector.elements) + ) + } else { + newJoinedSelector = originalSelector.createDerived([]) + } + + if (addPath.length > 0) { + // /deep/ is a CSS4 selector - (removed, so should deprecate) + // that is valid without anything in front of it + // so if the & does not have a combinator that is "" or " " then + // and there is a combinator on the parent, then grab that. + // this also allows + a { & .b { .a & { ... though not sure why you would want to do that + let combinator = replacedElement.combinator + + const parentEl = addPath[0].elements[0] + if ( + combinator.emptyOrWhitespace && + !parentEl.combinator.emptyOrWhitespace + ) { + combinator = parentEl.combinator + } + // join the elements so far with the first part of the parent + newJoinedSelector.elements.push( + new Element( + combinator, + parentEl.value, + replacedElement.isVariable, + replacedElement._index, + replacedElement._fileInfo + ) + ) + newJoinedSelector.elements = newJoinedSelector.elements.concat( + addPath[0].elements.slice(1) + ) + } + + // now add the joined selector - but only if it is not empty + if (newJoinedSelector.elements.length !== 0) { + newSelectorPath.push(newJoinedSelector) + } + + // put together the parent selectors after the join (e.g. the rest of the parent) + if (addPath.length > 1) { + let restOfPath = addPath.slice(1) + restOfPath = restOfPath.map(function (selector) { + return selector.createDerived(selector.elements, []) + }) + newSelectorPath = newSelectorPath.concat(restOfPath) + } + return newSelectorPath + } + + // joins selector path from `beginningPath` with every selector path in `addPaths` array + // `replacedElement` contains element that is being replaced by `addPath` + // returns array with all concatenated paths + function addAllReplacementsIntoPath( + beginningPath, + addPaths, + replacedElement, + originalSelector, + result + ) { + let j + for (j = 0; j < beginningPath.length; j++) { + const newSelectorPath = addReplacementIntoPath( + beginningPath[j], + addPaths, + replacedElement, + originalSelector + ) + result.push(newSelectorPath) + } + return result + } + + function mergeElementsOnToSelectors(elements, selectors) { + let i, sel + + if (elements.length === 0) { + return + } + if (selectors.length === 0) { + selectors.push([new Selector(elements)]) + return + } + + for (i = 0; (sel = selectors[i]); i++) { + // if the previous thing in sel is a parent this needs to join on to it + if (sel.length > 0) { + sel[sel.length - 1] = sel[sel.length - 1].createDerived( + sel[sel.length - 1].elements.concat(elements) + ) + } else { + sel.push(new Selector(elements)) + } + } + } + + // replace all parent selectors inside `inSelector` by content of `context` array + // resulting selectors are returned inside `paths` array + // returns true if `inSelector` contained at least one parent selector + function replaceParentSelector(paths, context, inSelector) { + // The paths are [[Selector]] + // The first list is a list of comma separated selectors + // The inner list is a list of inheritance separated selectors + // e.g. + // .a, .b { + // .c { + // } + // } + // == [[.a] [.c]] [[.b] [.c]] + // + let i, + j, + k, + currentElements, + newSelectors, + selectorsMultiplied, + sel, + el, + hadParentSelector = false, + length, + lastSelector + function findNestedSelector(element) { + let maybeSelector + if (!(element.value instanceof Paren)) { + return null + } + + maybeSelector = element.value.value + if (!(maybeSelector instanceof Selector)) { + return null + } + + return maybeSelector + } + + // the elements from the current selector so far + currentElements = [] + // the current list of new selectors to add to the path. + // We will build it up. We initiate it with one empty selector as we "multiply" the new selectors + // by the parents + newSelectors = [[]] + + for (i = 0; (el = inSelector.elements[i]); i++) { + // non parent reference elements just get added + if (el.value !== '&') { + const nestedSelector = findNestedSelector(el) + if (nestedSelector !== null) { + // merge the current list of non parent selector elements + // on to the current list of selectors to add + mergeElementsOnToSelectors(currentElements, newSelectors) + + const nestedPaths = [] + let replaced + const replacedNewSelectors = [] + replaced = replaceParentSelector( + nestedPaths, + context, + nestedSelector + ) + hadParentSelector = hadParentSelector || replaced + // the nestedPaths array should have only one member - replaceParentSelector does not multiply selectors + for (k = 0; k < nestedPaths.length; k++) { + const replacementSelector = createSelector( + createParenthesis(nestedPaths[k], el), + el + ) + addAllReplacementsIntoPath( + newSelectors, + [replacementSelector], + el, + inSelector, + replacedNewSelectors + ) + } + newSelectors = replacedNewSelectors + currentElements = [] + } else { + currentElements.push(el) + } + } else { + hadParentSelector = true + // the new list of selectors to add + selectorsMultiplied = [] + + // merge the current list of non parent selector elements + // on to the current list of selectors to add + mergeElementsOnToSelectors(currentElements, newSelectors) + + // loop through our current selectors + for (j = 0; j < newSelectors.length; j++) { + sel = newSelectors[j] + // if we don't have any parent paths, the & might be in a mixin so that it can be used + // whether there are parents or not + if (context.length === 0) { + // the combinator used on el should now be applied to the next element instead so that + // it is not lost + if (sel.length > 0) { + sel[0].elements.push( + new Element( + el.combinator, + '', + el.isVariable, + el._index, + el._fileInfo + ) + ) + } + selectorsMultiplied.push(sel) + } else { + // and the parent selectors + for (k = 0; k < context.length; k++) { + // We need to put the current selectors + // then join the last selector's elements on to the parents selectors + const newSelectorPath = addReplacementIntoPath( + sel, + context[k], + el, + inSelector + ) + // add that to our new set of selectors + selectorsMultiplied.push(newSelectorPath) + } + } + } + + // our new selectors has been multiplied, so reset the state + newSelectors = selectorsMultiplied + currentElements = [] + } + } + + // if we have any elements left over (e.g. .a& .b == .b) + // add them on to all the current selectors + mergeElementsOnToSelectors(currentElements, newSelectors) + + for (i = 0; i < newSelectors.length; i++) { + length = newSelectors[i].length + if (length > 0) { + paths.push(newSelectors[i]) + lastSelector = newSelectors[i][length - 1] + newSelectors[i][length - 1] = lastSelector.createDerived( + lastSelector.elements, + inSelector.extendList + ) + } + } + + return hadParentSelector + } + + function deriveSelector(visibilityInfo, deriveFrom) { + const newSelector = deriveFrom.createDerived( + deriveFrom.elements, + deriveFrom.extendList, + deriveFrom.evaldCondition + ) + newSelector.copyVisibilityInfo(visibilityInfo) + return newSelector + } + + // joinSelector code follows + let i, newPaths, hadParentSelector + + newPaths = [] + hadParentSelector = replaceParentSelector(newPaths, context, selector) + + if (!hadParentSelector) { + if (context.length > 0) { + newPaths = [] + for (i = 0; i < context.length; i++) { + const concatenated = context[i].map( + deriveSelector.bind(this, selector.visibilityInfo()) + ) + + concatenated.push(selector) + newPaths.push(concatenated) + } + } else { + newPaths = [[selector]] + } + } + + for (i = 0; i < newPaths.length; i++) { + paths.push(newPaths[i]) + } + } +}) + +export default Ruleset diff --git a/src/less/tree/selector.js b/src/less/tree/selector.js new file mode 100644 index 0000000..9110620 --- /dev/null +++ b/src/less/tree/selector.js @@ -0,0 +1,184 @@ +import Node from './node' +import Element from './element' +import LessError from '../less-error' +import * as utils from '../utils' +import Parser from '../parser/parser' + +const Selector = function ( + elements, + extendList, + condition, + index, + currentFileInfo, + visibilityInfo +) { + this.extendList = extendList + this.condition = condition + this.evaldCondition = !condition + this._index = index + this._fileInfo = currentFileInfo + this.elements = this.getElements(elements) + this.mixinElements_ = undefined + this.copyVisibilityInfo(visibilityInfo) + this.setParent(this.elements, this) +} + +Selector.prototype = Object.assign(new Node(), { + type: 'Selector', + + accept(visitor) { + if (this.elements) { + this.elements = visitor.visitArray(this.elements) + } + if (this.extendList) { + this.extendList = visitor.visitArray(this.extendList) + } + if (this.condition) { + this.condition = visitor.visit(this.condition) + } + }, + + createDerived(elements, extendList, evaldCondition) { + elements = this.getElements(elements) + const newSelector = new Selector( + elements, + extendList || this.extendList, + null, + this.getIndex(), + this.fileInfo(), + this.visibilityInfo() + ) + newSelector.evaldCondition = !utils.isNullOrUndefined(evaldCondition) + ? evaldCondition + : this.evaldCondition + newSelector.mediaEmpty = this.mediaEmpty + return newSelector + }, + + getElements(els) { + if (!els) { + return [new Element('', '&', false, this._index, this._fileInfo)] + } + if (typeof els === 'string') { + new Parser( + this.parse.context, + this.parse.importManager, + this._fileInfo, + this._index + ).parseNode(els, ['selector'], function (err, result) { + if (err) { + throw new LessError( + { + index: err.index, + message: err.message + }, + this.parse.imports, + this._fileInfo.filename + ) + } + els = result[0].elements + }) + } + return els + }, + + createEmptySelectors() { + const el = new Element('', '&', false, this._index, this._fileInfo), + sels = [new Selector([el], null, null, this._index, this._fileInfo)] + sels[0].mediaEmpty = true + return sels + }, + + match(other) { + const elements = this.elements + const len = elements.length + let olen + let i + + other = other.mixinElements() + olen = other.length + if (olen === 0 || len < olen) { + return 0 + } else { + for (i = 0; i < olen; i++) { + if (elements[i].value !== other[i]) { + return 0 + } + } + } + + return olen // return number of matched elements + }, + + mixinElements() { + if (this.mixinElements_) { + return this.mixinElements_ + } + + let elements = this.elements + .map(function (v) { + return v.combinator.value + (v.value.value || v.value) + }) + .join('') + .match(/[,&#*.\w-]([\w-]|(\\.))*/g) + + if (elements) { + if (elements[0] === '&') { + elements.shift() + } + } else { + elements = [] + } + + return (this.mixinElements_ = elements) + }, + + isJustParentSelector() { + return ( + !this.mediaEmpty && + this.elements.length === 1 && + this.elements[0].value === '&' && + (this.elements[0].combinator.value === ' ' || + this.elements[0].combinator.value === '') + ) + }, + + eval(context) { + const evaldCondition = this.condition && this.condition.eval(context) + let elements = this.elements + let extendList = this.extendList + + elements = + elements && + elements.map(function (e) { + return e.eval(context) + }) + extendList = + extendList && + extendList.map(function (extend) { + return extend.eval(context) + }) + + return this.createDerived(elements, extendList, evaldCondition) + }, + + genCSS(context, output) { + let i, element + if ( + (!context || !context.firstSelector) && + this.elements[0].combinator.value === '' + ) { + output.add(' ', this.fileInfo(), this.getIndex()) + } + for (i = 0; i < this.elements.length; i++) { + element = this.elements[i] + element.genCSS(context, output) + } + }, + + getIsOutput() { + return this.evaldCondition + } +}) + +export default Selector diff --git a/src/less/tree/unicode-descriptor.js b/src/less/tree/unicode-descriptor.js new file mode 100644 index 0000000..81b5ff3 --- /dev/null +++ b/src/less/tree/unicode-descriptor.js @@ -0,0 +1,11 @@ +import Node from './node' + +const UnicodeDescriptor = function (value) { + this.value = value +} + +UnicodeDescriptor.prototype = Object.assign(new Node(), { + type: 'UnicodeDescriptor' +}) + +export default UnicodeDescriptor diff --git a/src/less/tree/unit.js b/src/less/tree/unit.js new file mode 100644 index 0000000..fc26dd5 --- /dev/null +++ b/src/less/tree/unit.js @@ -0,0 +1,149 @@ +import Node from './node' +import unitConversions from '../data/unit-conversions' +import * as utils from '../utils' + +const Unit = function (numerator, denominator, backupUnit) { + this.numerator = numerator ? utils.copyArray(numerator).sort() : [] + this.denominator = denominator ? utils.copyArray(denominator).sort() : [] + if (backupUnit) { + this.backupUnit = backupUnit + } else if (numerator && numerator.length) { + this.backupUnit = numerator[0] + } +} + +Unit.prototype = Object.assign(new Node(), { + type: 'Unit', + + clone() { + return new Unit( + utils.copyArray(this.numerator), + utils.copyArray(this.denominator), + this.backupUnit + ) + }, + + genCSS(context, output) { + // Dimension checks the unit is singular and throws an error if in strict math mode. + const strictUnits = context && context.strictUnits + if (this.numerator.length === 1) { + output.add(this.numerator[0]) // the ideal situation + } else if (!strictUnits && this.backupUnit) { + output.add(this.backupUnit) + } else if (!strictUnits && this.denominator.length) { + output.add(this.denominator[0]) + } + }, + + toString() { + let i, + returnStr = this.numerator.join('*') + for (i = 0; i < this.denominator.length; i++) { + returnStr += `/${this.denominator[i]}` + } + return returnStr + }, + + compare(other) { + return this.is(other.toString()) ? 0 : undefined + }, + + is(unitString) { + return this.toString().toUpperCase() === unitString.toUpperCase() + }, + + isLength() { + return RegExp( + '^(px|em|ex|ch|rem|in|cm|mm|pc|pt|ex|vw|vh|vmin|vmax)$', + 'gi' + ).test(this.toCSS()) + }, + + isEmpty() { + return this.numerator.length === 0 && this.denominator.length === 0 + }, + + isSingular() { + return this.numerator.length <= 1 && this.denominator.length === 0 + }, + + map(callback) { + let i + + for (i = 0; i < this.numerator.length; i++) { + this.numerator[i] = callback(this.numerator[i], false) + } + + for (i = 0; i < this.denominator.length; i++) { + this.denominator[i] = callback(this.denominator[i], true) + } + }, + + usedUnits() { + let group + const result = {} + let mapUnit + let groupName + + mapUnit = function (atomicUnit) { + // eslint-disable-next-line no-prototype-builtins + if (group.hasOwnProperty(atomicUnit) && !result[groupName]) { + result[groupName] = atomicUnit + } + + return atomicUnit + } + + for (groupName in unitConversions) { + // eslint-disable-next-line no-prototype-builtins + if (unitConversions.hasOwnProperty(groupName)) { + group = unitConversions[groupName] + + this.map(mapUnit) + } + } + + return result + }, + + cancel() { + const counter = {} + let atomicUnit + let i + + for (i = 0; i < this.numerator.length; i++) { + atomicUnit = this.numerator[i] + counter[atomicUnit] = (counter[atomicUnit] || 0) + 1 + } + + for (i = 0; i < this.denominator.length; i++) { + atomicUnit = this.denominator[i] + counter[atomicUnit] = (counter[atomicUnit] || 0) - 1 + } + + this.numerator = [] + this.denominator = [] + + for (atomicUnit in counter) { + // eslint-disable-next-line no-prototype-builtins + if (counter.hasOwnProperty(atomicUnit)) { + const count = counter[atomicUnit] + + if (count > 0) { + for (i = 0; i < count; i++) { + this.numerator.push(atomicUnit) + } + } else if (count < 0) { + for (i = 0; i < -count; i++) { + this.denominator.push(atomicUnit) + } + } + } + } + + this.numerator.sort() + this.denominator.sort() + } +}) + +export default Unit diff --git a/src/less/tree/url.js b/src/less/tree/url.js new file mode 100644 index 0000000..132796c --- /dev/null +++ b/src/less/tree/url.js @@ -0,0 +1,67 @@ +import Node from './node' + +function escapePath(path) { + return path.replace(/[()'"\s]/g, function (match) { + return `\\${match}` + }) +} + +const URL = function (val, index, currentFileInfo, isEvald) { + this.value = val + this._index = index + this._fileInfo = currentFileInfo + this.isEvald = isEvald +} + +URL.prototype = Object.assign(new Node(), { + type: 'Url', + + accept(visitor) { + this.value = visitor.visit(this.value) + }, + + genCSS(context, output) { + output.add('url(') + this.value.genCSS(context, output) + output.add(')') + }, + + eval(context) { + const val = this.value.eval(context) + let rootpath + + if (!this.isEvald) { + // Add the rootpath if the URL requires a rewrite + rootpath = this.fileInfo() && this.fileInfo().rootpath + if ( + typeof rootpath === 'string' && + typeof val.value === 'string' && + context.pathRequiresRewrite(val.value) + ) { + if (!val.quote) { + rootpath = escapePath(rootpath) + } + val.value = context.rewritePath(val.value, rootpath) + } else { + val.value = context.normalizePath(val.value) + } + + // Add url args if enabled + if (context.urlArgs) { + if (!val.value.match(/^\s*data:/)) { + const delimiter = val.value.indexOf('?') === -1 ? '?' : '&' + const urlArgs = delimiter + context.urlArgs + if (val.value.indexOf('#') !== -1) { + val.value = val.value.replace('#', `${urlArgs}#`) + } else { + val.value += urlArgs + } + } + } + } + + return new URL(val, this.getIndex(), this.fileInfo(), true) + } +}) + +export default URL diff --git a/src/less/tree/value.js b/src/less/tree/value.js new file mode 100644 index 0000000..8b1cfb6 --- /dev/null +++ b/src/less/tree/value.js @@ -0,0 +1,46 @@ +import Node from './node' + +const Value = function (value) { + if (!value) { + throw new Error('Value requires an array argument') + } + if (!Array.isArray(value)) { + this.value = [value] + } else { + this.value = value + } +} + +Value.prototype = Object.assign(new Node(), { + type: 'Value', + + accept(visitor) { + if (this.value) { + this.value = visitor.visitArray(this.value) + } + }, + + eval(context) { + if (this.value.length === 1) { + return this.value[0].eval(context) + } else { + return new Value( + this.value.map(function (v) { + return v.eval(context) + }) + ) + } + }, + + genCSS(context, output) { + let i + for (i = 0; i < this.value.length; i++) { + this.value[i].genCSS(context, output) + if (i + 1 < this.value.length) { + output.add(context && context.compress ? ',' : ', ') + } + } + } +}) + +export default Value diff --git a/src/less/tree/variable-call.js b/src/less/tree/variable-call.js new file mode 100644 index 0000000..33ab402 --- /dev/null +++ b/src/less/tree/variable-call.js @@ -0,0 +1,48 @@ +import Node from './node' +import Variable from './variable' +import Ruleset from './ruleset' +import DetachedRuleset from './detached-ruleset' +import LessError from '../less-error' + +const VariableCall = function (variable, index, currentFileInfo) { + this.variable = variable + this._index = index + this._fileInfo = currentFileInfo + this.allowRoot = true +} + +VariableCall.prototype = Object.assign(new Node(), { + type: 'VariableCall', + + eval(context) { + let rules + let detachedRuleset = new Variable( + this.variable, + this.getIndex(), + this.fileInfo() + ).eval(context) + const error = new LessError({ + message: `Could not evaluate variable call ${this.variable}` + }) + + if (!detachedRuleset.ruleset) { + if (detachedRuleset.rules) { + rules = detachedRuleset + } else if (Array.isArray(detachedRuleset)) { + rules = new Ruleset('', detachedRuleset) + } else if (Array.isArray(detachedRuleset.value)) { + rules = new Ruleset('', detachedRuleset.value) + } else { + throw error + } + detachedRuleset = new DetachedRuleset(rules) + } + + if (detachedRuleset.ruleset) { + return detachedRuleset.callEval(context) + } + throw error + } +}) + +export default VariableCall diff --git a/src/less/tree/variable.js b/src/less/tree/variable.js new file mode 100644 index 0000000..e1b5774 --- /dev/null +++ b/src/less/tree/variable.js @@ -0,0 +1,76 @@ +import Node from './node' +import Call from './call' + +const Variable = function (name, index, currentFileInfo) { + this.name = name + this._index = index + this._fileInfo = currentFileInfo +} + +Variable.prototype = Object.assign(new Node(), { + type: 'Variable', + + eval(context) { + let variable, + name = this.name + + if (name.indexOf('@@') === 0) { + name = `@${ + new Variable(name.slice(1), this.getIndex(), this.fileInfo()).eval( + context + ).value + }` + } + + if (this.evaluating) { + throw { + type: 'Name', + message: `Recursive variable definition for ${name}`, + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + + this.evaluating = true + + variable = this.find(context.frames, function (frame) { + const v = frame.variable(name) + if (v) { + if (v.important) { + const importantScope = + context.importantScope[context.importantScope.length - 1] + importantScope.important = v.important + } + // If in calc, wrap vars in a function call to cascade evaluate args first + if (context.inCalc) { + return new Call('_SELF', [v.value]).eval(context) + } else { + return v.value.eval(context) + } + } + }) + if (variable) { + this.evaluating = false + return variable + } else { + throw { + type: 'Name', + message: `variable ${name} is undefined`, + filename: this.fileInfo().filename, + index: this.getIndex() + } + } + }, + + find(obj, fun) { + for (let i = 0, r; i < obj.length; i++) { + r = fun.call(obj, obj[i]) + if (r) { + return r + } + } + return null + } +}) + +export default Variable diff --git a/src/less/utils.js b/src/less/utils.js new file mode 100644 index 0000000..fb990f9 --- /dev/null +++ b/src/less/utils.js @@ -0,0 +1,126 @@ +/* jshint proto: true */ +import * as Constants from './constants' +import { copy } from 'copy-anything' + +export function getLocation(index, inputStream) { + let n = index + 1 + let line = null + let column = -1 + + while (--n >= 0 && inputStream.charAt(n) !== '\n') { + column++ + } + + if (typeof index === 'number') { + line = (inputStream.slice(0, index).match(/\n/g) || '').length + } + + return { + line, + column + } +} + +export function copyArray(arr) { + let i + const length = arr.length + const copy = new Array(length) + + for (i = 0; i < length; i++) { + copy[i] = arr[i] + } + return copy +} + +export function clone(obj) { + const cloned = {} + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + cloned[prop] = obj[prop] + } + } + return cloned +} + +export function defaults(obj1, obj2) { + let newObj = obj2 || {} + if (!obj2._defaults) { + newObj = {} + const defaults = copy(obj1) + newObj._defaults = defaults + const cloned = obj2 ? copy(obj2) : {} + Object.assign(newObj, defaults, cloned) + } + return newObj +} + +export function copyOptions(obj1, obj2) { + if (obj2 && obj2._defaults) { + return obj2 + } + const opts = defaults(obj1, obj2) + if (opts.strictMath) { + opts.math = Constants.Math.PARENS + } + // Back compat with changed relativeUrls option + if (opts.relativeUrls) { + opts.rewriteUrls = Constants.RewriteUrls.ALL + } + if (typeof opts.math === 'string') { + switch (opts.math.toLowerCase()) { + case 'always': + opts.math = Constants.Math.ALWAYS + break + case 'parens-division': + opts.math = Constants.Math.PARENS_DIVISION + break + case 'strict': + case 'parens': + opts.math = Constants.Math.PARENS + break + default: + opts.math = Constants.Math.PARENS + } + } + if (typeof opts.rewriteUrls === 'string') { + switch (opts.rewriteUrls.toLowerCase()) { + case 'off': + opts.rewriteUrls = Constants.RewriteUrls.OFF + break + case 'local': + opts.rewriteUrls = Constants.RewriteUrls.LOCAL + break + case 'all': + opts.rewriteUrls = Constants.RewriteUrls.ALL + break + } + } + return opts +} + +export function merge(obj1, obj2) { + for (const prop in obj2) { + if (Object.prototype.hasOwnProperty.call(obj2, prop)) { + obj1[prop] = obj2[prop] + } + } + return obj1 +} + +export function flattenArray(arr, result = []) { + for (let i = 0, length = arr.length; i < length; i++) { + const value = arr[i] + if (Array.isArray(value)) { + flattenArray(value, result) + } else { + if (value !== undefined) { + result.push(value) + } + } + } + return result +} + +export function isNullOrUndefined(val) { + return val === null || val === undefined +} diff --git a/src/less/visitors/extend-visitor.js b/src/less/visitors/extend-visitor.js new file mode 100644 index 0000000..bb649c3 --- /dev/null +++ b/src/less/visitors/extend-visitor.js @@ -0,0 +1,627 @@ +/* eslint-disable no-unused-vars */ +/** + * @todo - Remove unused when JSDoc types are added for visitor methods + */ +import tree from '../tree' +import Visitor from './visitor' +import logger from '../logger' +import * as utils from '../utils' + +/* jshint loopfunc:true */ + +class ExtendFinderVisitor { + constructor() { + this._visitor = new Visitor(this) + this.contexts = [] + this.allExtendsStack = [[]] + } + + run(root) { + root = this._visitor.visit(root) + root.allExtends = this.allExtendsStack[0] + return root + } + + visitDeclaration(declNode, visitArgs) { + visitArgs.visitDeeper = false + } + + visitMixinDefinition(mixinDefinitionNode, visitArgs) { + visitArgs.visitDeeper = false + } + + visitRuleset(rulesetNode, visitArgs) { + if (rulesetNode.root) { + return + } + + let i + let j + let extend + const allSelectorsExtendList = [] + let extendList + + // get &:extend(.a); rules which apply to all selectors in this ruleset + const rules = rulesetNode.rules, + ruleCnt = rules ? rules.length : 0 + for (i = 0; i < ruleCnt; i++) { + if (rulesetNode.rules[i] instanceof tree.Extend) { + allSelectorsExtendList.push(rules[i]) + rulesetNode.extendOnEveryPath = true + } + } + + // now find every selector and apply the extends that apply to all extends + // and the ones which apply to an individual extend + const paths = rulesetNode.paths + for (i = 0; i < paths.length; i++) { + const selectorPath = paths[i], + selector = selectorPath[selectorPath.length - 1], + selExtendList = selector.extendList + + extendList = selExtendList + ? utils.copyArray(selExtendList).concat(allSelectorsExtendList) + : allSelectorsExtendList + + if (extendList) { + extendList = extendList.map(function (allSelectorsExtend) { + return allSelectorsExtend.clone() + }) + } + + for (j = 0; j < extendList.length; j++) { + this.foundExtends = true + extend = extendList[j] + extend.findSelfSelectors(selectorPath) + extend.ruleset = rulesetNode + if (j === 0) { + extend.firstExtendOnThisSelectorPath = true + } + this.allExtendsStack[this.allExtendsStack.length - 1].push(extend) + } + } + + this.contexts.push(rulesetNode.selectors) + } + + visitRulesetOut(rulesetNode) { + if (!rulesetNode.root) { + this.contexts.length = this.contexts.length - 1 + } + } + + visitMedia(mediaNode, visitArgs) { + mediaNode.allExtends = [] + this.allExtendsStack.push(mediaNode.allExtends) + } + + visitMediaOut(mediaNode) { + this.allExtendsStack.length = this.allExtendsStack.length - 1 + } + + visitAtRule(atRuleNode, visitArgs) { + atRuleNode.allExtends = [] + this.allExtendsStack.push(atRuleNode.allExtends) + } + + visitAtRuleOut(atRuleNode) { + this.allExtendsStack.length = this.allExtendsStack.length - 1 + } +} + +class ProcessExtendsVisitor { + constructor() { + this._visitor = new Visitor(this) + } + + run(root) { + const extendFinder = new ExtendFinderVisitor() + this.extendIndices = {} + extendFinder.run(root) + if (!extendFinder.foundExtends) { + return root + } + root.allExtends = root.allExtends.concat( + this.doExtendChaining(root.allExtends, root.allExtends) + ) + this.allExtendsStack = [root.allExtends] + const newRoot = this._visitor.visit(root) + this.checkExtendsForNonMatched(root.allExtends) + return newRoot + } + + checkExtendsForNonMatched(extendList) { + const indices = this.extendIndices + extendList + .filter(function (extend) { + return !extend.hasFoundMatches && extend.parent_ids.length == 1 + }) + .forEach(function (extend) { + let selector = '_unknown_' + try { + selector = extend.selector.toCSS({}) + } catch (_) {} + + if (!indices[`${extend.index} ${selector}`]) { + indices[`${extend.index} ${selector}`] = true + logger.warn(`extend '${selector}' has no matches`) + } + }) + } + + doExtendChaining(extendsList, extendsListTarget, iterationCount) { + // + // chaining is different from normal extension.. if we extend an extend then we are not just copying, altering + // and pasting the selector we would do normally, but we are also adding an extend with the same target selector + // this means this new extend can then go and alter other extends + // + // this method deals with all the chaining work - without it, extend is flat and doesn't work on other extend selectors + // this is also the most expensive.. and a match on one selector can cause an extension of a selector we had already + // processed if we look at each selector at a time, as is done in visitRuleset + + let extendIndex + + let targetExtendIndex + let matches + const extendsToAdd = [] + let newSelector + const extendVisitor = this + let selectorPath + let extend + let targetExtend + let newExtend + + iterationCount = iterationCount || 0 + + // loop through comparing every extend with every target extend. + // a target extend is the one on the ruleset we are looking at copy/edit/pasting in place + // e.g. .a:extend(.b) {} and .b:extend(.c) {} then the first extend extends the second one + // and the second is the target. + // the separation into two lists allows us to process a subset of chains with a bigger set, as is the + // case when processing media queries + for (extendIndex = 0; extendIndex < extendsList.length; extendIndex++) { + for ( + targetExtendIndex = 0; + targetExtendIndex < extendsListTarget.length; + targetExtendIndex++ + ) { + extend = extendsList[extendIndex] + targetExtend = extendsListTarget[targetExtendIndex] + + // look for circular references + if (extend.parent_ids.indexOf(targetExtend.object_id) >= 0) { + continue + } + + // find a match in the target extends self selector (the bit before :extend) + selectorPath = [targetExtend.selfSelectors[0]] + matches = extendVisitor.findMatch(extend, selectorPath) + + if (matches.length) { + extend.hasFoundMatches = true + + // we found a match, so for each self selector.. + extend.selfSelectors.forEach(function (selfSelector) { + const info = targetExtend.visibilityInfo() + + // process the extend as usual + newSelector = extendVisitor.extendSelector( + matches, + selectorPath, + selfSelector, + extend.isVisible() + ) + + // but now we create a new extend from it + newExtend = new tree.Extend( + targetExtend.selector, + targetExtend.option, + 0, + targetExtend.fileInfo(), + info + ) + newExtend.selfSelectors = newSelector + + // add the extend onto the list of extends for that selector + newSelector[newSelector.length - 1].extendList = [newExtend] + + // record that we need to add it. + extendsToAdd.push(newExtend) + newExtend.ruleset = targetExtend.ruleset + + // remember its parents for circular references + newExtend.parent_ids = newExtend.parent_ids.concat( + targetExtend.parent_ids, + extend.parent_ids + ) + + // only process the selector once.. if we have :extend(.a,.b) then multiple + // extends will look at the same selector path, so when extending + // we know that any others will be duplicates in terms of what is added to the css + if (targetExtend.firstExtendOnThisSelectorPath) { + newExtend.firstExtendOnThisSelectorPath = true + targetExtend.ruleset.paths.push(newSelector) + } + }) + } + } + } + + if (extendsToAdd.length) { + // try to detect circular references to stop a stack overflow. + // may no longer be needed. + this.extendChainCount++ + if (iterationCount > 100) { + let selectorOne = '{unable to calculate}' + let selectorTwo = '{unable to calculate}' + try { + selectorOne = extendsToAdd[0].selfSelectors[0].toCSS() + selectorTwo = extendsToAdd[0].selector.toCSS() + } catch (e) {} + throw { + message: `extend circular reference detected. One of the circular extends is currently:${selectorOne}:extend(${selectorTwo})` + } + } + + // now process the new extends on the existing rules so that we can handle a extending b extending c extending + // d extending e... + return extendsToAdd.concat( + extendVisitor.doExtendChaining( + extendsToAdd, + extendsListTarget, + iterationCount + 1 + ) + ) + } else { + return extendsToAdd + } + } + + visitDeclaration(ruleNode, visitArgs) { + visitArgs.visitDeeper = false + } + + visitMixinDefinition(mixinDefinitionNode, visitArgs) { + visitArgs.visitDeeper = false + } + + visitSelector(selectorNode, visitArgs) { + visitArgs.visitDeeper = false + } + + visitRuleset(rulesetNode, visitArgs) { + if (rulesetNode.root) { + return + } + let matches + let pathIndex + let extendIndex + const allExtends = this.allExtendsStack[this.allExtendsStack.length - 1] + const selectorsToAdd = [] + const extendVisitor = this + let selectorPath + + // look at each selector path in the ruleset, find any extend matches and then copy, find and replace + + for (extendIndex = 0; extendIndex < allExtends.length; extendIndex++) { + for (pathIndex = 0; pathIndex < rulesetNode.paths.length; pathIndex++) { + selectorPath = rulesetNode.paths[pathIndex] + + // extending extends happens initially, before the main pass + if (rulesetNode.extendOnEveryPath) { + continue + } + const extendList = selectorPath[selectorPath.length - 1].extendList + if (extendList && extendList.length) { + continue + } + + matches = this.findMatch(allExtends[extendIndex], selectorPath) + + if (matches.length) { + allExtends[extendIndex].hasFoundMatches = true + + allExtends[extendIndex].selfSelectors.forEach(function ( + selfSelector + ) { + let extendedSelectors + extendedSelectors = extendVisitor.extendSelector( + matches, + selectorPath, + selfSelector, + allExtends[extendIndex].isVisible() + ) + selectorsToAdd.push(extendedSelectors) + }) + } + } + } + rulesetNode.paths = rulesetNode.paths.concat(selectorsToAdd) + } + + findMatch(extend, haystackSelectorPath) { + // + // look through the haystack selector path to try and find the needle - extend.selector + // returns an array of selector matches that can then be replaced + // + let haystackSelectorIndex + + let hackstackSelector + let hackstackElementIndex + let haystackElement + let targetCombinator + let i + const extendVisitor = this + const needleElements = extend.selector.elements + const potentialMatches = [] + let potentialMatch + const matches = [] + + // loop through the haystack elements + for ( + haystackSelectorIndex = 0; + haystackSelectorIndex < haystackSelectorPath.length; + haystackSelectorIndex++ + ) { + hackstackSelector = haystackSelectorPath[haystackSelectorIndex] + + for ( + hackstackElementIndex = 0; + hackstackElementIndex < hackstackSelector.elements.length; + hackstackElementIndex++ + ) { + haystackElement = hackstackSelector.elements[hackstackElementIndex] + + // if we allow elements before our match we can add a potential match every time. otherwise only at the first element. + if ( + extend.allowBefore || + (haystackSelectorIndex === 0 && hackstackElementIndex === 0) + ) { + potentialMatches.push({ + pathIndex: haystackSelectorIndex, + index: hackstackElementIndex, + matched: 0, + initialCombinator: haystackElement.combinator + }) + } + + for (i = 0; i < potentialMatches.length; i++) { + potentialMatch = potentialMatches[i] + + // selectors add " " onto the first element. When we use & it joins the selectors together, but if we don't + // then each selector in haystackSelectorPath has a space before it added in the toCSS phase. so we need to + // work out what the resulting combinator will be + targetCombinator = haystackElement.combinator.value + if (targetCombinator === '' && hackstackElementIndex === 0) { + targetCombinator = ' ' + } + + // if we don't match, null our match to indicate failure + if ( + !extendVisitor.isElementValuesEqual( + needleElements[potentialMatch.matched].value, + haystackElement.value + ) || + (potentialMatch.matched > 0 && + needleElements[potentialMatch.matched].combinator.value !== + targetCombinator) + ) { + potentialMatch = null + } else { + potentialMatch.matched++ + } + + // if we are still valid and have finished, test whether we have elements after and whether these are allowed + if (potentialMatch) { + potentialMatch.finished = + potentialMatch.matched === needleElements.length + if ( + potentialMatch.finished && + !extend.allowAfter && + (hackstackElementIndex + 1 < hackstackSelector.elements.length || + haystackSelectorIndex + 1 < haystackSelectorPath.length) + ) { + potentialMatch = null + } + } + // if null we remove, if not, we are still valid, so either push as a valid match or continue + if (potentialMatch) { + if (potentialMatch.finished) { + potentialMatch.length = needleElements.length + potentialMatch.endPathIndex = haystackSelectorIndex + potentialMatch.endPathElementIndex = hackstackElementIndex + 1 // index after end of match + potentialMatches.length = 0 // we don't allow matches to overlap, so start matching again + matches.push(potentialMatch) + } + } else { + potentialMatches.splice(i, 1) + i-- + } + } + } + } + return matches + } + + isElementValuesEqual(elementValue1, elementValue2) { + if ( + typeof elementValue1 === 'string' || + typeof elementValue2 === 'string' + ) { + return elementValue1 === elementValue2 + } + if (elementValue1 instanceof tree.Attribute) { + if ( + elementValue1.op !== elementValue2.op || + elementValue1.key !== elementValue2.key + ) { + return false + } + if (!elementValue1.value || !elementValue2.value) { + if (elementValue1.value || elementValue2.value) { + return false + } + return true + } + elementValue1 = elementValue1.value.value || elementValue1.value + elementValue2 = elementValue2.value.value || elementValue2.value + return elementValue1 === elementValue2 + } + elementValue1 = elementValue1.value + elementValue2 = elementValue2.value + if (elementValue1 instanceof tree.Selector) { + if ( + !(elementValue2 instanceof tree.Selector) || + elementValue1.elements.length !== elementValue2.elements.length + ) { + return false + } + for (let i = 0; i < elementValue1.elements.length; i++) { + if ( + elementValue1.elements[i].combinator.value !== + elementValue2.elements[i].combinator.value + ) { + if ( + i !== 0 || + (elementValue1.elements[i].combinator.value || ' ') !== + (elementValue2.elements[i].combinator.value || ' ') + ) { + return false + } + } + if ( + !this.isElementValuesEqual( + elementValue1.elements[i].value, + elementValue2.elements[i].value + ) + ) { + return false + } + } + return true + } + return false + } + + extendSelector(matches, selectorPath, replacementSelector, isVisible) { + // for a set of matches, replace each match with the replacement selector + + let currentSelectorPathIndex = 0, + currentSelectorPathElementIndex = 0, + path = [], + matchIndex, + selector, + firstElement, + match, + newElements + + for (matchIndex = 0; matchIndex < matches.length; matchIndex++) { + match = matches[matchIndex] + selector = selectorPath[match.pathIndex] + firstElement = new tree.Element( + match.initialCombinator, + replacementSelector.elements[0].value, + replacementSelector.elements[0].isVariable, + replacementSelector.elements[0].getIndex(), + replacementSelector.elements[0].fileInfo() + ) + + if ( + match.pathIndex > currentSelectorPathIndex && + currentSelectorPathElementIndex > 0 + ) { + path[path.length - 1].elements = path[path.length - 1].elements.concat( + selectorPath[currentSelectorPathIndex].elements.slice( + currentSelectorPathElementIndex + ) + ) + currentSelectorPathElementIndex = 0 + currentSelectorPathIndex++ + } + + newElements = selector.elements + .slice(currentSelectorPathElementIndex, match.index) + .concat([firstElement]) + .concat(replacementSelector.elements.slice(1)) + + if (currentSelectorPathIndex === match.pathIndex && matchIndex > 0) { + path[path.length - 1].elements = + path[path.length - 1].elements.concat(newElements) + } else { + path = path.concat( + selectorPath.slice(currentSelectorPathIndex, match.pathIndex) + ) + + path.push(new tree.Selector(newElements)) + } + currentSelectorPathIndex = match.endPathIndex + currentSelectorPathElementIndex = match.endPathElementIndex + if ( + currentSelectorPathElementIndex >= + selectorPath[currentSelectorPathIndex].elements.length + ) { + currentSelectorPathElementIndex = 0 + currentSelectorPathIndex++ + } + } + + if ( + currentSelectorPathIndex < selectorPath.length && + currentSelectorPathElementIndex > 0 + ) { + path[path.length - 1].elements = path[path.length - 1].elements.concat( + selectorPath[currentSelectorPathIndex].elements.slice( + currentSelectorPathElementIndex + ) + ) + currentSelectorPathIndex++ + } + + path = path.concat( + selectorPath.slice(currentSelectorPathIndex, selectorPath.length) + ) + path = path.map(function (currentValue) { + // we can re-use elements here, because the visibility property matters only for selectors + const derived = currentValue.createDerived(currentValue.elements) + if (isVisible) { + derived.ensureVisibility() + } else { + derived.ensureInvisibility() + } + return derived + }) + return path + } + + visitMedia(mediaNode, visitArgs) { + let newAllExtends = mediaNode.allExtends.concat( + this.allExtendsStack[this.allExtendsStack.length - 1] + ) + newAllExtends = newAllExtends.concat( + this.doExtendChaining(newAllExtends, mediaNode.allExtends) + ) + this.allExtendsStack.push(newAllExtends) + } + + visitMediaOut(mediaNode) { + const lastIndex = this.allExtendsStack.length - 1 + this.allExtendsStack.length = lastIndex + } + + visitAtRule(atRuleNode, visitArgs) { + let newAllExtends = atRuleNode.allExtends.concat( + this.allExtendsStack[this.allExtendsStack.length - 1] + ) + newAllExtends = newAllExtends.concat( + this.doExtendChaining(newAllExtends, atRuleNode.allExtends) + ) + this.allExtendsStack.push(newAllExtends) + } + + visitAtRuleOut(atRuleNode) { + const lastIndex = this.allExtendsStack.length - 1 + this.allExtendsStack.length = lastIndex + } +} + +export default ProcessExtendsVisitor diff --git a/src/less/visitors/import-sequencer.js b/src/less/visitors/import-sequencer.js new file mode 100644 index 0000000..a0b533a --- /dev/null +++ b/src/less/visitors/import-sequencer.js @@ -0,0 +1,56 @@ +class ImportSequencer { + constructor(onSequencerEmpty) { + this.imports = [] + this.variableImports = [] + this._onSequencerEmpty = onSequencerEmpty + this._currentDepth = 0 + } + + addImport(callback) { + const importSequencer = this, + importItem = { + callback, + args: null, + isReady: false + } + this.imports.push(importItem) + return function () { + importItem.args = Array.prototype.slice.call(arguments, 0) + importItem.isReady = true + importSequencer.tryRun() + } + } + + addVariableImport(callback) { + this.variableImports.push(callback) + } + + tryRun() { + this._currentDepth++ + try { + while (true) { + while (this.imports.length > 0) { + const importItem = this.imports[0] + if (!importItem.isReady) { + return + } + this.imports = this.imports.slice(1) + importItem.callback.apply(null, importItem.args) + } + if (this.variableImports.length === 0) { + break + } + const variableImport = this.variableImports[0] + this.variableImports = this.variableImports.slice(1) + variableImport() + } + } finally { + this._currentDepth-- + } + if (this._currentDepth === 0 && this._onSequencerEmpty) { + this._onSequencerEmpty() + } + } +} + +export default ImportSequencer diff --git a/src/less/visitors/import-visitor.js b/src/less/visitors/import-visitor.js new file mode 100644 index 0000000..a3428dc --- /dev/null +++ b/src/less/visitors/import-visitor.js @@ -0,0 +1,216 @@ +/* eslint-disable no-unused-vars */ +/** + * @todo - Remove unused when JSDoc types are added for visitor methods + */ +import contexts from '../contexts' +import Visitor from './visitor' +import ImportSequencer from './import-sequencer' +import * as utils from '../utils' + +const ImportVisitor = function (importer, finish) { + this._visitor = new Visitor(this) + this._importer = importer + this._finish = finish + this.context = new contexts.Eval() + this.importCount = 0 + this.onceFileDetectionMap = {} + this.recursionDetector = {} + this._sequencer = new ImportSequencer(this._onSequencerEmpty.bind(this)) +} + +ImportVisitor.prototype = { + isReplacing: false, + run: function (root) { + try { + // process the contents + this._visitor.visit(root) + } catch (e) { + this.error = e + } + + this.isFinished = true + this._sequencer.tryRun() + }, + _onSequencerEmpty: function () { + if (!this.isFinished) { + return + } + this._finish(this.error) + }, + visitImport: function (importNode, visitArgs) { + const inlineCSS = importNode.options.inline + + if (!importNode.css || inlineCSS) { + const context = new contexts.Eval( + this.context, + utils.copyArray(this.context.frames) + ) + const importParent = context.frames[0] + + this.importCount++ + if (importNode.isVariableImport()) { + this._sequencer.addVariableImport( + this.processImportNode.bind(this, importNode, context, importParent) + ) + } else { + this.processImportNode(importNode, context, importParent) + } + } + visitArgs.visitDeeper = false + }, + processImportNode: function (importNode, context, importParent) { + let evaldImportNode + const inlineCSS = importNode.options.inline + + try { + evaldImportNode = importNode.evalForImport(context) + } catch (e) { + if (!e.filename) { + e.index = importNode.getIndex() + e.filename = importNode.fileInfo().filename + } + // attempt to eval properly and treat as css + importNode.css = true + // if that fails, this error will be thrown + importNode.error = e + } + + if (evaldImportNode && (!evaldImportNode.css || inlineCSS)) { + if (evaldImportNode.options.multiple) { + context.importMultiple = true + } + + // try appending if we haven't determined if it is css or not + const tryAppendLessExtension = evaldImportNode.css === undefined + + for (let i = 0; i < importParent.rules.length; i++) { + if (importParent.rules[i] === importNode) { + importParent.rules[i] = evaldImportNode + break + } + } + + const onImported = this.onImported.bind(this, evaldImportNode, context), + sequencedOnImported = this._sequencer.addImport(onImported) + + this._importer.push( + evaldImportNode.getPath(), + tryAppendLessExtension, + evaldImportNode.fileInfo(), + evaldImportNode.options, + sequencedOnImported + ) + } else { + this.importCount-- + if (this.isFinished) { + this._sequencer.tryRun() + } + } + }, + onImported: function ( + importNode, + context, + e, + root, + importedAtRoot, + fullPath + ) { + if (e) { + if (!e.filename) { + e.index = importNode.getIndex() + e.filename = importNode.fileInfo().filename + } + this.error = e + } + + const importVisitor = this, + inlineCSS = importNode.options.inline, + isPlugin = importNode.options.isPlugin, + isOptional = importNode.options.optional, + duplicateImport = + importedAtRoot || fullPath in importVisitor.recursionDetector + + if (!context.importMultiple) { + if (duplicateImport) { + importNode.skip = true + } else { + importNode.skip = function () { + if (fullPath in importVisitor.onceFileDetectionMap) { + return true + } + importVisitor.onceFileDetectionMap[fullPath] = true + return false + } + } + } + + if (!fullPath && isOptional) { + importNode.skip = true + } + + if (root) { + importNode.root = root + importNode.importedFilename = fullPath + + if ( + !inlineCSS && + !isPlugin && + (context.importMultiple || !duplicateImport) + ) { + importVisitor.recursionDetector[fullPath] = true + + const oldContext = this.context + this.context = context + try { + this._visitor.visit(root) + } catch (e) { + this.error = e + } + this.context = oldContext + } + } + + importVisitor.importCount-- + + if (importVisitor.isFinished) { + importVisitor._sequencer.tryRun() + } + }, + visitDeclaration: function (declNode, visitArgs) { + if (declNode.value.type === 'DetachedRuleset') { + this.context.frames.unshift(declNode) + } else { + visitArgs.visitDeeper = false + } + }, + visitDeclarationOut: function (declNode) { + if (declNode.value.type === 'DetachedRuleset') { + this.context.frames.shift() + } + }, + visitAtRule: function (atRuleNode, visitArgs) { + this.context.frames.unshift(atRuleNode) + }, + visitAtRuleOut: function (atRuleNode) { + this.context.frames.shift() + }, + visitMixinDefinition: function (mixinDefinitionNode, visitArgs) { + this.context.frames.unshift(mixinDefinitionNode) + }, + visitMixinDefinitionOut: function (mixinDefinitionNode) { + this.context.frames.shift() + }, + visitRuleset: function (rulesetNode, visitArgs) { + this.context.frames.unshift(rulesetNode) + }, + visitRulesetOut: function (rulesetNode) { + this.context.frames.shift() + }, + visitMedia: function (mediaNode, visitArgs) { + this.context.frames.unshift(mediaNode.rules[0]) + }, + visitMediaOut: function (mediaNode) { + this.context.frames.shift() + } +} +export default ImportVisitor diff --git a/src/less/visitors/index.js b/src/less/visitors/index.js new file mode 100644 index 0000000..4afc87a --- /dev/null +++ b/src/less/visitors/index.js @@ -0,0 +1,15 @@ +import Visitor from './visitor' +import ImportVisitor from './import-visitor' +import MarkVisibleSelectorsVisitor from './set-tree-visibility-visitor' +import ExtendVisitor from './extend-visitor' +import JoinSelectorVisitor from './join-selector-visitor' +import ToCSSVisitor from './to-css-visitor' + +export default { + Visitor, + ImportVisitor, + MarkVisibleSelectorsVisitor, + ExtendVisitor, + JoinSelectorVisitor, + ToCSSVisitor +} diff --git a/src/less/visitors/join-selector-visitor.js b/src/less/visitors/join-selector-visitor.js new file mode 100644 index 0000000..1dd47e9 --- /dev/null +++ b/src/less/visitors/join-selector-visitor.js @@ -0,0 +1,61 @@ +/* eslint-disable no-unused-vars */ +/** + * @todo - Remove unused when JSDoc types are added for visitor methods + */ +import Visitor from './visitor'; + +class JoinSelectorVisitor { + constructor() { + this.contexts = [[]]; + this._visitor = new Visitor(this); + } + + run(root) { + return this._visitor.visit(root); + } + + visitDeclaration(declNode, visitArgs) { + visitArgs.visitDeeper = false; + } + + visitMixinDefinition(mixinDefinitionNode, visitArgs) { + visitArgs.visitDeeper = false; + } + + visitRuleset(rulesetNode, visitArgs) { + const context = this.contexts[this.contexts.length - 1]; + const paths = []; + let selectors; + + this.contexts.push(paths); + + if (!rulesetNode.root) { + selectors = rulesetNode.selectors; + if (selectors) { + selectors = selectors.filter(function(selector) { return selector.getIsOutput(); }); + rulesetNode.selectors = selectors.length ? selectors : (selectors = null); + if (selectors) { rulesetNode.joinSelectors(paths, context, selectors); } + } + if (!selectors) { rulesetNode.rules = null; } + rulesetNode.paths = paths; + } + } + + visitRulesetOut(rulesetNode) { + this.contexts.length = this.contexts.length - 1; + } + + visitMedia(mediaNode, visitArgs) { + const context = this.contexts[this.contexts.length - 1]; + mediaNode.rules[0].root = (context.length === 0 || context[0].multiMedia); + } + + visitAtRule(atRuleNode, visitArgs) { + const context = this.contexts[this.contexts.length - 1]; + if (atRuleNode.rules && atRuleNode.rules.length) { + atRuleNode.rules[0].root = (atRuleNode.isRooted || context.length === 0 || null); + } + } +} + +export default JoinSelectorVisitor; diff --git a/src/less/visitors/set-tree-visibility-visitor.js b/src/less/visitors/set-tree-visibility-visitor.js new file mode 100644 index 0000000..8a213c0 --- /dev/null +++ b/src/less/visitors/set-tree-visibility-visitor.js @@ -0,0 +1,45 @@ +class SetTreeVisibilityVisitor { + constructor(visible) { + this.visible = visible + } + + run(root) { + this.visit(root) + } + + visitArray(nodes) { + if (!nodes) { + return nodes + } + + const cnt = nodes.length + let i + for (i = 0; i < cnt; i++) { + this.visit(nodes[i]) + } + return nodes + } + + visit(node) { + if (!node) { + return node + } + if (node.constructor === Array) { + return this.visitArray(node) + } + + if (!node.blocksVisibility || node.blocksVisibility()) { + return node + } + if (this.visible) { + node.ensureVisibility() + } else { + node.ensureInvisibility() + } + + node.accept(this) + return node + } +} + +export default SetTreeVisibilityVisitor diff --git a/src/less/visitors/to-css-visitor.js b/src/less/visitors/to-css-visitor.js new file mode 100644 index 0000000..d2a1690 --- /dev/null +++ b/src/less/visitors/to-css-visitor.js @@ -0,0 +1,392 @@ +/* eslint-disable no-unused-vars */ +/** + * @todo - Remove unused when JSDoc types are added for visitor methods + */ +import tree from '../tree' +import Visitor from './visitor' + +class CSSVisitorUtils { + constructor(context) { + this._visitor = new Visitor(this) + this._context = context + } + + containsSilentNonBlockedChild(bodyRules) { + let rule + if (!bodyRules) { + return false + } + for (let r = 0; r < bodyRules.length; r++) { + rule = bodyRules[r] + if ( + rule.isSilent && + rule.isSilent(this._context) && + !rule.blocksVisibility() + ) { + // the atrule contains something that was referenced (likely by extend) + // therefore it needs to be shown in output too + return true + } + } + return false + } + + keepOnlyVisibleChilds(owner) { + if (owner && owner.rules) { + owner.rules = owner.rules.filter(thing => thing.isVisible()) + } + } + + isEmpty(owner) { + return owner && owner.rules ? owner.rules.length === 0 : true + } + + hasVisibleSelector(rulesetNode) { + return rulesetNode && rulesetNode.paths + ? rulesetNode.paths.length > 0 + : false + } + + resolveVisibility(node) { + if (!node.blocksVisibility()) { + if (this.isEmpty(node)) { + return + } + + return node + } + + const compiledRulesBody = node.rules[0] + this.keepOnlyVisibleChilds(compiledRulesBody) + + if (this.isEmpty(compiledRulesBody)) { + return + } + + node.ensureVisibility() + node.removeVisibilityBlock() + + return node + } + + isVisibleRuleset(rulesetNode) { + if (rulesetNode.firstRoot) { + return true + } + + if (this.isEmpty(rulesetNode)) { + return false + } + + if (!rulesetNode.root && !this.hasVisibleSelector(rulesetNode)) { + return false + } + + return true + } +} + +const ToCSSVisitor = function (context) { + this._visitor = new Visitor(this) + this._context = context + this.utils = new CSSVisitorUtils(context) +} + +ToCSSVisitor.prototype = { + isReplacing: true, + run: function (root) { + return this._visitor.visit(root) + }, + + visitDeclaration: function (declNode, visitArgs) { + if (declNode.blocksVisibility() || declNode.variable) { + return + } + return declNode + }, + + visitMixinDefinition: function (mixinNode, visitArgs) { + // mixin definitions do not get eval'd - this means they keep state + // so we have to clear that state here so it isn't used if toCSS is called twice + mixinNode.frames = [] + }, + + visitExtend: function (extendNode, visitArgs) {}, + + visitComment: function (commentNode, visitArgs) { + if (commentNode.blocksVisibility() || commentNode.isSilent(this._context)) { + return + } + return commentNode + }, + + visitMedia: function (mediaNode, visitArgs) { + const originalRules = mediaNode.rules[0].rules + mediaNode.accept(this._visitor) + visitArgs.visitDeeper = false + + return this.utils.resolveVisibility(mediaNode, originalRules) + }, + + visitImport: function (importNode, visitArgs) { + if (importNode.blocksVisibility()) { + return + } + return importNode + }, + + visitAtRule: function (atRuleNode, visitArgs) { + if (atRuleNode.rules && atRuleNode.rules.length) { + return this.visitAtRuleWithBody(atRuleNode, visitArgs) + } else { + return this.visitAtRuleWithoutBody(atRuleNode, visitArgs) + } + }, + + visitAnonymous: function (anonymousNode, visitArgs) { + if (!anonymousNode.blocksVisibility()) { + anonymousNode.accept(this._visitor) + return anonymousNode + } + }, + + visitAtRuleWithBody: function (atRuleNode, visitArgs) { + // if there is only one nested ruleset and that one has no path, then it is + // just fake ruleset + function hasFakeRuleset(atRuleNode) { + const bodyRules = atRuleNode.rules + return ( + bodyRules.length === 1 && + (!bodyRules[0].paths || bodyRules[0].paths.length === 0) + ) + } + function getBodyRules(atRuleNode) { + const nodeRules = atRuleNode.rules + if (hasFakeRuleset(atRuleNode)) { + return nodeRules[0].rules + } + + return nodeRules + } + // it is still true that it is only one ruleset in array + // this is last such moment + // process childs + const originalRules = getBodyRules(atRuleNode) + atRuleNode.accept(this._visitor) + visitArgs.visitDeeper = false + + if (!this.utils.isEmpty(atRuleNode)) { + this._mergeRules(atRuleNode.rules[0].rules) + } + + return this.utils.resolveVisibility(atRuleNode, originalRules) + }, + + visitAtRuleWithoutBody: function (atRuleNode, visitArgs) { + if (atRuleNode.blocksVisibility()) { + return + } + + if (atRuleNode.name === '@charset') { + // Only output the debug info together with subsequent @charset definitions + // a comment (or @media statement) before the actual @charset atrule would + // be considered illegal css as it has to be on the first line + if (this.charset) { + if (atRuleNode.debugInfo) { + const comment = new tree.Comment( + `/* ${atRuleNode.toCSS(this._context).replace(/\n/g, '')} */\n` + ) + comment.debugInfo = atRuleNode.debugInfo + return this._visitor.visit(comment) + } + return + } + this.charset = true + } + + return atRuleNode + }, + + checkValidNodes: function (rules, isRoot) { + if (!rules) { + return + } + + for (let i = 0; i < rules.length; i++) { + const ruleNode = rules[i] + if ( + isRoot && + ruleNode instanceof tree.Declaration && + !ruleNode.variable + ) { + throw { + message: + 'Properties must be inside selector blocks. They cannot be in the root', + index: ruleNode.getIndex(), + filename: ruleNode.fileInfo() && ruleNode.fileInfo().filename + } + } + if (ruleNode instanceof tree.Call) { + throw { + message: `Function '${ruleNode.name}' did not return a root node`, + index: ruleNode.getIndex(), + filename: ruleNode.fileInfo() && ruleNode.fileInfo().filename + } + } + if (ruleNode.type && !ruleNode.allowRoot) { + throw { + message: `${ruleNode.type} node returned by a function is not valid here`, + index: ruleNode.getIndex(), + filename: ruleNode.fileInfo() && ruleNode.fileInfo().filename + } + } + } + }, + + visitRuleset: function (rulesetNode, visitArgs) { + // at this point rulesets are nested into each other + let rule + + const rulesets = [] + + this.checkValidNodes(rulesetNode.rules, rulesetNode.firstRoot) + + if (!rulesetNode.root) { + // remove invisible paths + this._compileRulesetPaths(rulesetNode) + + // remove rulesets from this ruleset body and compile them separately + const nodeRules = rulesetNode.rules + + let nodeRuleCnt = nodeRules ? nodeRules.length : 0 + for (let i = 0; i < nodeRuleCnt; ) { + rule = nodeRules[i] + if (rule && rule.rules) { + // visit because we are moving them out from being a child + rulesets.push(this._visitor.visit(rule)) + nodeRules.splice(i, 1) + nodeRuleCnt-- + continue + } + i++ + } + // accept the visitor to remove rules and refactor itself + // then we can decide nogw whether we want it or not + // compile body + if (nodeRuleCnt > 0) { + rulesetNode.accept(this._visitor) + } else { + rulesetNode.rules = null + } + visitArgs.visitDeeper = false + } else { + // if (! rulesetNode.root) { + rulesetNode.accept(this._visitor) + visitArgs.visitDeeper = false + } + + if (rulesetNode.rules) { + this._mergeRules(rulesetNode.rules) + this._removeDuplicateRules(rulesetNode.rules) + } + + // now decide whether we keep the ruleset + if (this.utils.isVisibleRuleset(rulesetNode)) { + rulesetNode.ensureVisibility() + rulesets.splice(0, 0, rulesetNode) + } + + if (rulesets.length === 1) { + return rulesets[0] + } + return rulesets + }, + + _compileRulesetPaths: function (rulesetNode) { + if (rulesetNode.paths) { + rulesetNode.paths = rulesetNode.paths.filter(p => { + let i + if (p[0].elements[0].combinator.value === ' ') { + p[0].elements[0].combinator = new tree.Combinator('') + } + for (i = 0; i < p.length; i++) { + if (p[i].isVisible() && p[i].getIsOutput()) { + return true + } + } + return false + }) + } + }, + + _removeDuplicateRules: function (rules) { + if (!rules) { + return + } + + // remove duplicates + const ruleCache = {} + + let ruleList + let rule + let i + + for (i = rules.length - 1; i >= 0; i--) { + rule = rules[i] + if (rule instanceof tree.Declaration) { + if (!ruleCache[rule.name]) { + ruleCache[rule.name] = rule + } else { + ruleList = ruleCache[rule.name] + if (ruleList instanceof tree.Declaration) { + ruleList = ruleCache[rule.name] = [ + ruleCache[rule.name].toCSS(this._context) + ] + } + const ruleCSS = rule.toCSS(this._context) + if (ruleList.indexOf(ruleCSS) !== -1) { + rules.splice(i, 1) + } else { + ruleList.push(ruleCSS) + } + } + } + } + }, + + _mergeRules: function (rules) { + if (!rules) { + return + } + + const groups = {} + const groupsArr = [] + + for (let i = 0; i < rules.length; i++) { + const rule = rules[i] + if (rule.merge) { + const key = rule.name + groups[key] ? rules.splice(i--, 1) : groupsArr.push((groups[key] = [])) + groups[key].push(rule) + } + } + + groupsArr.forEach(group => { + if (group.length > 0) { + const result = group[0] + let space = [] + const comma = [new tree.Expression(space)] + group.forEach(rule => { + if (rule.merge === '+' && space.length > 0) { + comma.push(new tree.Expression((space = []))) + } + space.push(rule.value) + result.important = result.important || rule.important + }) + result.value = new tree.Value(comma) + } + }) + } +} + +export default ToCSSVisitor diff --git a/src/less/visitors/visitor.js b/src/less/visitors/visitor.js new file mode 100644 index 0000000..fb62ed8 --- /dev/null +++ b/src/less/visitors/visitor.js @@ -0,0 +1,166 @@ +import tree from '../tree' + +const _visitArgs = { visitDeeper: true } +let _hasIndexed = false + +function _noop(node) { + return node +} + +function indexNodeTypes(parent, ticker) { + // add .typeIndex to tree node types for lookup table + let key, child + for (key in parent) { + /* eslint guard-for-in: 0 */ + child = parent[key] + switch (typeof child) { + case 'function': + // ignore bound functions directly on tree which do not have a prototype + // or aren't nodes + if (child.prototype && child.prototype.type) { + child.prototype.typeIndex = ticker++ + } + break + case 'object': + ticker = indexNodeTypes(child, ticker) + break + } + } + return ticker +} + +class Visitor { + constructor(implementation) { + this._implementation = implementation + this._visitInCache = {} + this._visitOutCache = {} + + if (!_hasIndexed) { + indexNodeTypes(tree, 1) + _hasIndexed = true + } + } + + visit(node) { + if (!node) { + return node + } + + const nodeTypeIndex = node.typeIndex + if (!nodeTypeIndex) { + // MixinCall args aren't a node type? + if (node.value && node.value.typeIndex) { + this.visit(node.value) + } + return node + } + + const impl = this._implementation + let func = this._visitInCache[nodeTypeIndex] + let funcOut = this._visitOutCache[nodeTypeIndex] + const visitArgs = _visitArgs + let fnName + + visitArgs.visitDeeper = true + + if (!func) { + fnName = `visit${node.type}` + func = impl[fnName] || _noop + funcOut = impl[`${fnName}Out`] || _noop + this._visitInCache[nodeTypeIndex] = func + this._visitOutCache[nodeTypeIndex] = funcOut + } + + if (func !== _noop) { + const newNode = func.call(impl, node, visitArgs) + if (node && impl.isReplacing) { + node = newNode + } + } + + if (visitArgs.visitDeeper && node) { + if (node.length) { + for (let i = 0, cnt = node.length; i < cnt; i++) { + if (node[i].accept) { + node[i].accept(this) + } + } + } else if (node.accept) { + node.accept(this) + } + } + + if (funcOut != _noop) { + funcOut.call(impl, node) + } + + return node + } + + visitArray(nodes, nonReplacing) { + if (!nodes) { + return nodes + } + + const cnt = nodes.length + let i + + // Non-replacing + if (nonReplacing || !this._implementation.isReplacing) { + for (i = 0; i < cnt; i++) { + this.visit(nodes[i]) + } + return nodes + } + + // Replacing + const out = [] + for (i = 0; i < cnt; i++) { + const evald = this.visit(nodes[i]) + if (evald === undefined) { + continue + } + if (!evald.splice) { + out.push(evald) + } else if (evald.length) { + this.flatten(evald, out) + } + } + return out + } + + flatten(arr, out) { + if (!out) { + out = [] + } + + let cnt, i, item, nestedCnt, j, nestedItem + + for (i = 0, cnt = arr.length; i < cnt; i++) { + item = arr[i] + if (item === undefined) { + continue + } + if (!item.splice) { + out.push(item) + continue + } + + for (j = 0, nestedCnt = item.length; j < nestedCnt; j++) { + nestedItem = item[j] + if (nestedItem === undefined) { + continue + } + if (!nestedItem.splice) { + out.push(nestedItem) + } else if (nestedItem.length) { + this.flatten(nestedItem, out) + } + } + } + + return out + } +} + +export default Visitor