diff --git a/package.json b/package.json index 8fa27f47..12c73994 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts", "lint-ci": "eslint src/", "test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/", + "test:postcss": "impunity --entry-point scripts/postcss/test.js ", "start": "vite --port 3000", "build": "vite build", "build:sdk": "./scripts/sdk/build.sh" @@ -30,6 +31,7 @@ "acorn": "^8.6.0", "acorn-walk": "^8.2.0", "aes-js": "^3.1.2", + "bs58": "^4.0.1", "core-js": "^3.6.5", "es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush", "escodegen": "^2.0.0", @@ -45,13 +47,14 @@ "text-encoding": "^0.7.0", "typescript": "^4.3.5", "vite": "^2.6.14", - "xxhashjs": "^0.2.2", - "bs58": "^4.0.1" + "xxhashjs": "^0.2.2" }, "dependencies": { "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "another-json": "^0.2.0", "base64-arraybuffer": "^0.2.0", - "dompurify": "^2.3.0" + "dompurify": "^2.3.0", + "off-color": "^2.0.0", + "postcss-value-parser": "^4.2.0" } } diff --git a/scripts/.eslintrc.js b/scripts/.eslintrc.js new file mode 100644 index 00000000..1cdfca84 --- /dev/null +++ b/scripts/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + "env": { + "node": true, + "es6": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "rules": { + "no-console": "off", + "no-empty": "off", + "no-prototype-builtins": "off", + "no-unused-vars": "warn" + }, +}; + diff --git a/scripts/postcss/color.js b/scripts/postcss/color.js new file mode 100644 index 00000000..f61dac1e --- /dev/null +++ b/scripts/postcss/color.js @@ -0,0 +1,31 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const offColor = require("off-color").offColor; + +module.exports.derive = function (value, operation, argument) { + const argumentAsNumber = parseInt(argument); + switch (operation) { + case "darker": { + const newColorString = offColor(value).darken(argumentAsNumber / 100).hex(); + return newColorString; + } + case "lighter": { + const newColorString = offColor(value).lighten(argumentAsNumber / 100).hex(); + return newColorString; + } + } +} diff --git a/scripts/postcss/css-compile-variables.js b/scripts/postcss/css-compile-variables.js new file mode 100644 index 00000000..3ed34513 --- /dev/null +++ b/scripts/postcss/css-compile-variables.js @@ -0,0 +1,127 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const valueParser = require("postcss-value-parser"); + +/** + * This plugin derives new css variables from a given set of base variables. + * A derived css variable has the form --base--operation-argument; meaning that the derived + * variable has a value that is generated from the base variable "base" by applying "operation" + * with given "argument". + * + * eg: given the base variable --foo-color: #40E0D0, --foo-color--darker-20 is a css variable + * derived from foo-color by making it 20% more darker. + * + * All derived variables are added to the :root section. + * + * The actual derivation is done outside the plugin in a callback. + */ + +let aliasMap; +let resolvedMap; +let baseVariables; + +function getValueFromAlias(alias) { + const derivedVariable = aliasMap.get(alias); + return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable); +} + +function parseDeclarationValue(value) { + const parsed = valueParser(value); + const variables = []; + parsed.walk(node => { + if (node.type !== "function" && node.value !== "var") { + return; + } + const variable = node.nodes[0]; + variables.push(variable.value); + }); + return variables; +} + +function resolveDerivedVariable(decl, derive) { + const RE_VARIABLE_VALUE = /--((.+)--(.+)-(.+))/; + const variableCollection = parseDeclarationValue(decl.value); + for (const variable of variableCollection) { + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, wholeVariable, baseVariable, operation, argument] = matches; + const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable); + if (!value) { + throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`); + } + const derivedValue = derive(value, operation, argument); + resolvedMap.set(wholeVariable, derivedValue); + } + } +} + +function extract(decl) { + if (decl.variable) { + // see if right side is of form "var(--foo)" + const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1]; + // remove -- from the prop + const prop = decl.prop.substring(2); + if (wholeVariable) { + aliasMap.set(prop, wholeVariable); + // Since this is an alias, we shouldn't store it in baseVariables + return; + } + baseVariables.set(prop, decl.value); + } +} + +function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) { + const newRule = new Rule({ selector: ":root", source: root.source }); + // Add derived css variables to :root + resolvedMap.forEach((value, key) => { + const declaration = new Declaration({prop: `--${key}`, value}); + newRule.append(declaration); + }); + root.append(newRule); +} + +/** + * @callback derive + * @param {string} value - The base value on which an operation is applied + * @param {string} operation - The operation to be applied (eg: darker, lighter...) + * @param {string} argument - The argument for this operation + */ +/** + * + * @param {Object} opts - Options for the plugin + * @param {derive} opts.derive - The callback which contains the logic for resolving derived variables + */ +module.exports = (opts = {}) => { + aliasMap = new Map(); + resolvedMap = new Map(); + baseVariables = new Map(); + return { + postcssPlugin: "postcss-compile-variables", + + Once(root, {Rule, Declaration}) { + /* + Go through the CSS file once to extract all aliases and base variables. + We use these when resolving derived variables later. + */ + root.walkDecls(decl => extract(decl)); + root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive)); + addResolvedVariablesToRootSelector(root, {Rule, Declaration}); + }, + }; +}; + +module.exports.postcss = true; diff --git a/scripts/postcss/test.js b/scripts/postcss/test.js new file mode 100644 index 00000000..36ff9282 --- /dev/null +++ b/scripts/postcss/test.js @@ -0,0 +1,121 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const offColor = require("off-color").offColor; +const postcss = require("postcss"); +const plugin = require("./css-compile-variables"); +const derive = require("./color").derive; + +async function run(input, output, opts = {}, assert) { + let result = await postcss([plugin({ ...opts, derive })]).process(input, { from: undefined, }); + assert.strictEqual( + result.css.replaceAll(/\s/g, ""), + output.replaceAll(/\s/g, "") + ); + assert.strictEqual(result.warnings().length, 0); +} + +module.exports.tests = function tests() { + return { + "derived variables are resolved": async (assert) => { + const inputCSS = ` + :root { + --foo-color: #ff0; + } + div { + background-color: var(--foo-color--lighter-50); + }`; + const transformedColor = offColor("#ff0").lighten(0.5); + const outputCSS = + inputCSS + + ` + :root { + --foo-color--lighter-50: ${transformedColor.hex()}; + } + `; + await run( inputCSS, outputCSS, {}, assert); + }, + + "derived variables work with alias": async (assert) => { + const inputCSS = ` + :root { + --icon-color: #fff; + } + div { + background: var(--icon-color--darker-20); + --my-alias: var(--icon-color--darker-20); + color: var(--my-alias--lighter-15); + }`; + const colorDarker = offColor("#fff").darken(0.2).hex(); + const aliasLighter = offColor(colorDarker).lighten(0.15).hex(); + const outputCSS = inputCSS + `:root { + --icon-color--darker-20: ${colorDarker}; + --my-alias--lighter-15: ${aliasLighter}; + } + `; + await run(inputCSS, outputCSS, { }, assert); + }, + + "derived variable throws if base not present in config": async (assert) => { + const css = `:root { + color: var(--icon-color--darker-20); + }`; + assert.rejects(async () => await postcss([plugin({ variables: {} })]).process(css, { from: undefined, })); + }, + + "multiple derived variable in single declaration is parsed correctly": async (assert) => { + const inputCSS = ` + :root { + --foo-color: #ff0; + } + div { + background-color: linear-gradient(var(--foo-color--lighter-50), var(--foo-color--darker-20)); + }`; + const transformedColor1 = offColor("#ff0").lighten(0.5); + const transformedColor2 = offColor("#ff0").darken(0.2); + const outputCSS = + inputCSS + + ` + :root { + --foo-color--lighter-50: ${transformedColor1.hex()}; + --foo-color--darker-20: ${transformedColor2.hex()}; + } + `; + await run( inputCSS, outputCSS, { }, assert); + }, + "multiple aliased-derived variable in single declaration is parsed correctly": async (assert) => { + const inputCSS = ` + :root { + --foo-color: #ff0; + } + div { + --my-alias: var(--foo-color); + background-color: linear-gradient(var(--my-alias--lighter-50), var(--my-alias--darker-20)); + }`; + const transformedColor1 = offColor("#ff0").lighten(0.5); + const transformedColor2 = offColor("#ff0").darken(0.2); + const outputCSS = + inputCSS + + ` + :root { + --my-alias--lighter-50: ${transformedColor1.hex()}; + --my-alias--darker-20: ${transformedColor2.hex()}; + } + `; + await run( inputCSS, outputCSS, { }, assert); + } + }; +}; diff --git a/yarn.lock b/yarn.lock index 87b8ef96..7bcefdd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1133,6 +1133,13 @@ nth-check@^2.0.0: dependencies: boolbase "^1.0.0" +off-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/off-color/-/off-color-2.0.0.tgz#ecf3bda52e9a78dde535db86361e048741a56631" + integrity sha512-JJ9ObbY2CzgT7F8PpdpHGNjQa7QbU8f4DkY3cCxYUq9NezYUMmL/oSofCc5MMaiUnNNBEFCc4w1unMA+R8syvw== + dependencies: + core-js "^3.6.5" + once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -1215,6 +1222,11 @@ postcss-flexbugs-fixes@^5.0.2: resolved "https://registry.yarnpkg.com/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz#2028e145313074fc9abe276cb7ca14e5401eb49d" integrity sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ== +postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + postcss@^8.3.8: version "8.3.9" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31"