diff --git a/package.json b/package.json index 82588f10..9361098c 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,9 @@ "postcss-flexbugs-fixes": "^5.0.2", "postcss-value-parser": "^4.2.0", "regenerator-runtime": "^0.13.7", + "svgo": "^2.8.0", "text-encoding": "^0.7.0", - "typescript": "^4.3.5", + "typescript": "^4.7.0", "vite": "^2.9.8", "xxhashjs": "^0.2.2" }, diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 438203b7..c8c73220 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ const path = require('path').posix; +const {optimize} = require('svgo'); async function readCSSSource(location) { const fs = require("fs").promises; - const path = require("path"); const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`); const data = await fs.readFile(resolvedLocation); return data; @@ -43,6 +43,45 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) { } } +/** + * Returns an object where keys are the svg file names and the values + * are the svg code (optimized) + * @param {*} icons Object where keys are css variable names and values are locations of the svg + * @param {*} manifestLocation Location of manifest used for resolving path + */ +async function generateIconSourceMap(icons, manifestLocation) { + const sources = {}; + const fileNames = []; + const promises = []; + const fs = require("fs").promises; + for (const icon of Object.values(icons)) { + const [location] = icon.split("?"); + // resolve location against manifestLocation + const resolvedLocation = path.resolve(manifestLocation, location); + const iconData = fs.readFile(resolvedLocation); + promises.push(iconData); + const fileName = path.basename(resolvedLocation); + fileNames.push(fileName); + } + const results = await Promise.all(promises); + for (let i = 0; i < results.length; ++i) { + const svgString = results[i].toString(); + const result = optimize(svgString, { + plugins: [ + { + name: "preset-default", + params: { + overrides: { convertColors: false, }, + }, + }, + ], + }); + const optimizedSvgString = result.data; + sources[fileNames[i]] = optimizedSvgString; + } + return sources; +} + /** * Returns a mapping from location (of manifest file) to an array containing all the chunks (of css files) generated from that location. * To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle. @@ -278,7 +317,7 @@ module.exports = function buildThemes(options) { ]; }, - generateBundle(_, bundle) { + async generateBundle(_, bundle) { const assetMap = getMappingFromFileNameToAssetInfo(bundle); const chunkMap = getMappingFromLocationToChunkArray(bundle); const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle); @@ -299,13 +338,29 @@ module.exports = function buildThemes(options) { const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); builtAssets[`${name}-${variant}`] = locationRelativeToManifest; } + // Emit the base svg icons as asset + const nameToAssetHashedLocation = []; + const nameToSource = await generateIconSourceMap(icon, location); + for (const [name, source] of Object.entries(nameToSource)) { + const ref = this.emitFile({ type: "asset", name, source }); + const assetHashedName = this.getFileName(ref); + nameToAssetHashedLocation[name] = assetHashedName; + } + // Update icon section in output manifest with paths to the icon in build output + for (const [variable, location] of Object.entries(icon)) { + const [locationWithoutQueryParameters, queryParameters] = location.split("?"); + const name = path.basename(locationWithoutQueryParameters); + const locationRelativeToBuildRoot = nameToAssetHashedLocation[name]; + const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); + icon[variable] = `${locationRelativeToManifest}?${queryParameters}`; + } const runtimeThemeChunk = runtimeThemeChunkMap.get(location); const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName); manifest.source = { "built-assets": builtAssets, "runtime-asset": runtimeAssetLocation, "derived-variables": derivedVariables, - "icon": icon + "icon": icon, }; const name = `theme-${themeKey}.json`; manifestLocations.push(`${manifestLocation}/${name}`); diff --git a/scripts/postcss/css-url-to-variables.js b/scripts/postcss/css-url-to-variables.js index f9588434..9988a10e 100644 --- a/scripts/postcss/css-url-to-variables.js +++ b/scripts/postcss/css-url-to-variables.js @@ -81,7 +81,8 @@ module.exports = (opts = {}) => { const urlVariables = new Map(); const counter = createCounter(); root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter)); - if (urlVariables.size) { + const cssFileLocation = root.source.input.from; + if (urlVariables.size && !cssFileLocation.includes("type=runtime")) { addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables); } if (opts.compiledVariables){ diff --git a/scripts/postcss/svg-colorizer.js b/scripts/postcss/svg-builder.mjs similarity index 61% rename from scripts/postcss/svg-colorizer.js rename to scripts/postcss/svg-builder.mjs index 06b7b14b..3efff4ae 100644 --- a/scripts/postcss/svg-colorizer.js +++ b/scripts/postcss/svg-builder.mjs @@ -14,12 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -const fs = require("fs"); -const path = require("path"); -const xxhash = require('xxhashjs'); +import {readFileSync, mkdirSync, writeFileSync} from "fs"; +import {resolve} from "path"; +import {h32} from "xxhashjs"; +import {getColoredSvgString} from "../../src/platform/web/theming/shared/svg-colorizer.mjs"; function createHash(content) { - const hasher = new xxhash.h32(0); + const hasher = new h32(0); hasher.update(content); return hasher.digest(); } @@ -30,18 +31,14 @@ function createHash(content) { * @param {string} primaryColor Primary color for the new svg * @param {string} secondaryColor Secondary color for the new svg */ -module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) { - const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"}); - let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor); - coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor); - if (svgCode === coloredSVGCode) { - throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive)."); - } +export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) { + const svgCode = readFileSync(svgLocation, { encoding: "utf8"}); + const coloredSVGCode = getColoredSvgString(svgCode, primaryColor, secondaryColor); const fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1]; const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`; - const outputPath = path.resolve(__dirname, "../../.tmp"); + const outputPath = resolve(__dirname, "../../.tmp"); try { - fs.mkdirSync(outputPath); + mkdirSync(outputPath); } catch (e) { if (e.code !== "EEXIST") { @@ -49,6 +46,6 @@ module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondar } } const outputFile = `${outputPath}/${outputName}`; - fs.writeFileSync(outputFile, coloredSVGCode); + writeFileSync(outputFile, coloredSVGCode); return outputFile; } diff --git a/scripts/test-theme.sh b/scripts/test-theme.sh new file mode 100755 index 00000000..9f94d3c3 --- /dev/null +++ b/scripts/test-theme.sh @@ -0,0 +1,5 @@ +#!/bin/zsh +cp theme.json target/assets/theme-customer.json +cat target/config.json | jq '.themeManifests += ["assets/theme-customer.json"]' | cat > target/config.temp.json +rm target/config.json +mv target/config.temp.json target/config.json diff --git a/src/platform/types/theme.ts b/src/platform/types/theme.ts index 9a984277..9dd969de 100644 --- a/src/platform/types/theme.ts +++ b/src/platform/types/theme.ts @@ -22,6 +22,13 @@ export type ThemeManifest = Partial<{ version: number; // A user-facing string that is the name for this theme-collection. name: string; + // An identifier for this theme + id: string; + /** + * Id of the theme that this theme derives from. + * Only present for derived/runtime themes. + */ + extends: string; /** * This is added to the manifest during the build process and includes data * that is needed to load themes at runtime. @@ -42,6 +49,12 @@ export type ThemeManifest = Partial<{ "runtime-asset": string; // Array of derived-variables "derived-variables": Array; + /** + * Mapping from icon variable to location of icon in build output with query parameters + * indicating how it should be colored for this particular theme. + * eg: "icon-url-1": "element-logo.86bc8565.svg?primary=accent-color" + */ + icon: Record; }; values: { /** @@ -60,6 +73,8 @@ type Variant = Partial<{ default: boolean; // A user-facing string that is the name for this variant. name: string; + // A boolean indicating whether this is a dark theme or not + dark: boolean; /** * Mapping from css variable to its value. * eg: {"background-color-primary": "#21262b", ...} diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index c2eef17e..15923a86 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -38,7 +38,7 @@ import {downloadInIframe} from "./dom/download.js"; import {Disposables} from "../../utils/Disposables"; import {parseHTML} from "./parsehtml.js"; import {handleAvatarError} from "./ui/avatar"; -import {ThemeLoader} from "./ThemeLoader"; +import {ThemeLoader} from "./theming/ThemeLoader"; function addScript(src) { return new Promise(function (resolve, reject) { diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts deleted file mode 100644 index 89430663..00000000 --- a/src/platform/web/ThemeLoader.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* -Copyright 2020 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. -*/ - -import type {ILogItem} from "../../logging/types.js"; -import type {Platform} from "./Platform.js"; - -type NormalVariant = { - id: string; - cssLocation: string; -}; - -type DefaultVariant = { - dark: { - id: string; - cssLocation: string; - variantName: string; - }; - light: { - id: string; - cssLocation: string; - variantName: string; - }; - default: { - id: string; - cssLocation: string; - variantName: string; - }; -} - -type ThemeInformation = NormalVariant | DefaultVariant; - -export enum ColorSchemePreference { - Dark, - Light -}; - -export class ThemeLoader { - private _platform: Platform; - private _themeMapping: Record; - - constructor(platform: Platform) { - this._platform = platform; - } - - async init(manifestLocations: string[], log?: ILogItem): Promise { - await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { - this._themeMapping = {}; - const results = await Promise.all( - manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) - ); - results.forEach(({ body }, i) => this._populateThemeMap(body, manifestLocations[i], log)); - }); - } - - private _populateThemeMap(manifest, manifestLocation: string, log: ILogItem) { - log.wrap("populateThemeMap", (l) => { - /* - After build has finished, the source section of each theme manifest - contains `built-assets` which is a mapping from the theme-id to - cssLocation of theme - */ - const builtAssets: Record = manifest.source?.["built-assets"]; - const themeName = manifest.name; - let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; - for (let [themeId, cssLocation] of Object.entries(builtAssets)) { - try { - /** - * This cssLocation is relative to the location of the manifest file. - * So we first need to resolve it relative to the root of this hydrogen instance. - */ - cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; - } - catch { - continue; - } - const variant = themeId.match(/.+-(.+)/)?.[1]; - const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!]; - const themeDisplayName = `${themeName} ${variantName}`; - if (isDefault) { - /** - * This is a default variant! - * We'll add these to the themeMapping (separately) keyed with just the - * theme-name (i.e "Element" instead of "Element Dark"). - * We need to be able to distinguish them from other variants! - * - * This allows us to render radio-buttons with "dark" and - * "light" options. - */ - const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; - defaultVariant.variantName = variantName; - defaultVariant.id = themeId - defaultVariant.cssLocation = cssLocation; - continue; - } - // Non-default variants are keyed in themeMapping with "theme_name variant_name" - // eg: "Element Dark" - this._themeMapping[themeDisplayName] = { - cssLocation, - id: themeId - }; - } - if (defaultDarkVariant.id && defaultLightVariant.id) { - /** - * As mentioned above, if there's both a default dark and a default light variant, - * add them to themeMapping separately. - */ - const defaultVariant = this.preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; - } - else { - /** - * If only one default variant is found (i.e only dark default or light default but not both), - * treat it like any other variant. - */ - const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; - } - //Add the default-theme as an additional option to the mapping - const defaultThemeId = this.getDefaultTheme(); - if (defaultThemeId) { - const themeDetails = this._findThemeDetailsFromId(defaultThemeId); - if (themeDetails) { - this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation }; - } - } - l.log({ l: "Default Theme", theme: defaultThemeId}); - l.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); - l.log({ l: "Result", themeMapping: this._themeMapping }); - }); - } - - setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { - this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { - let cssLocation: string; - let themeDetails = this._themeMapping[themeName]; - if ("id" in themeDetails) { - cssLocation = themeDetails.cssLocation; - } - else { - if (!themeVariant) { - throw new Error("themeVariant is undefined!"); - } - cssLocation = themeDetails[themeVariant].cssLocation; - } - this._platform.replaceStylesheet(cssLocation); - this._platform.settingsStorage.setString("theme-name", themeName); - if (themeVariant) { - this._platform.settingsStorage.setString("theme-variant", themeVariant); - } - else { - this._platform.settingsStorage.remove("theme-variant"); - } - }); - } - - /** Maps theme display name to theme information */ - get themeMapping(): Record { - return this._themeMapping; - } - - async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> { - let themeName = await this._platform.settingsStorage.getString("theme-name"); - let themeVariant = await this._platform.settingsStorage.getString("theme-variant"); - if (!themeName || !this._themeMapping[themeName]) { - themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0]; - if (!this._themeMapping[themeName][themeVariant]) { - themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined; - } - } - return { themeName, themeVariant }; - } - - getDefaultTheme(): string | undefined { - switch (this.preferredColorScheme) { - case ColorSchemePreference.Dark: - return this._platform.config["defaultTheme"]?.dark; - case ColorSchemePreference.Light: - return this._platform.config["defaultTheme"]?.light; - } - } - - private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined { - for (const [themeName, themeData] of Object.entries(this._themeMapping)) { - if ("id" in themeData && themeData.id === themeId) { - return { themeName, cssLocation: themeData.cssLocation }; - } - else if ("light" in themeData && themeData.light?.id === themeId) { - return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" }; - } - else if ("dark" in themeData && themeData.dark?.id === themeId) { - return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" }; - } - } - } - - get preferredColorScheme(): ColorSchemePreference | undefined { - if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - return ColorSchemePreference.Dark; - } - else if (window.matchMedia("(prefers-color-scheme: light)").matches) { - return ColorSchemePreference.Light; - } - } -} diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 497ad553..eb4caab6 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -115,6 +115,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { } else if (format === "buffer") { body = await response.arrayBuffer(); } + else if (format === "text") { + body = await response.text(); + } } catch (err) { // some error pages return html instead of json, ignore error if (!(err.name === "SyntaxError" && status >= 400)) { diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts new file mode 100644 index 00000000..5a626bfa --- /dev/null +++ b/src/platform/web/theming/DerivedVariables.ts @@ -0,0 +1,130 @@ +/* +Copyright 2020 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. +*/ +import {derive} from "./shared/color.mjs"; + +export class DerivedVariables { + private _baseVariables: Record; + private _variablesToDerive: string[] + private _isDark: boolean + private _aliases: Record = {}; + private _derivedAliases: string[] = []; + + constructor(baseVariables: Record, variablesToDerive: string[], isDark: boolean) { + this._baseVariables = baseVariables; + this._variablesToDerive = variablesToDerive; + this._isDark = isDark; + } + + toVariables(): Record { + const resolvedVariables: any = {}; + this._detectAliases(); + for (const variable of this._variablesToDerive) { + const resolvedValue = this._derive(variable); + if (resolvedValue) { + resolvedVariables[variable] = resolvedValue; + } + } + for (const [alias, variable] of Object.entries(this._aliases) as any) { + resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable]; + } + for (const variable of this._derivedAliases) { + const resolvedValue = this._deriveAlias(variable, resolvedVariables); + if (resolvedValue) { + resolvedVariables[variable] = resolvedValue; + } + } + return resolvedVariables; + } + + private _detectAliases(): void { + const newVariablesToDerive: string[] = []; + for (const variable of this._variablesToDerive) { + const [alias, value] = variable.split("="); + if (value) { + this._aliases[alias] = value; + } + else { + newVariablesToDerive.push(variable); + } + } + this._variablesToDerive = newVariablesToDerive; + } + + private _derive(variable: string): string | undefined { + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = this._baseVariables[baseVariable]; + if (!value ) { + if (this._aliases[baseVariable]) { + this._derivedAliases.push(variable); + return; + } + else { + throw new Error(`Cannot find value for base variable "${baseVariable}"!`); + } + } + const resolvedValue = derive(value, operation, argument, this._isDark); + return resolvedValue; + } + } + + private _deriveAlias(variable: string, resolvedVariables: Record): string | undefined { + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = resolvedVariables[baseVariable]; + if (!value ) { + throw new Error(`Cannot find value for alias "${baseVariable}" when trying to derive ${variable}!`); + } + const resolvedValue = derive(value, operation, argument, this._isDark); + return resolvedValue; + } + } +} + +import pkg from "off-color"; +const {offColor} = pkg; + +export function tests() { + return { + "Simple variable derivation": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], false); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").darken(5/100).hex(); + assert.deepEqual(result, {"background-color--darker-5": resultColor}); + }, + + "For dark themes, lighten and darken are inverted": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], true); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").lighten(5/100).hex(); + assert.deepEqual(result, {"background-color--darker-5": resultColor}); + }, + + "Aliases can be derived": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["my-awesome-alias=background-color","my-awesome-alias--darker-5"], false); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").darken(5/100).hex(); + assert.deepEqual(result, { + "my-awesome-alias": "#ff00ff", + "my-awesome-alias--darker-5": resultColor, + }); + }, + } +} diff --git a/src/platform/web/theming/IconColorizer.ts b/src/platform/web/theming/IconColorizer.ts new file mode 100644 index 00000000..e02c0971 --- /dev/null +++ b/src/platform/web/theming/IconColorizer.ts @@ -0,0 +1,79 @@ +/* +Copyright 2020 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. +*/ +import type {Platform} from "../Platform.js"; +import {getColoredSvgString} from "./shared/svg-colorizer.mjs"; + +type ParsedStructure = { + [variableName: string]: { + svg: Promise<{ status: number; body: string }>; + primary: string | null; + secondary: string | null; + }; +}; + +export class IconColorizer { + private _iconVariables: Record; + private _resolvedVariables: Record; + private _manifestLocation: string; + private _platform: Platform; + + constructor(platform: Platform, iconVariables: Record, resolvedVariables: Record, manifestLocation: string) { + this._platform = platform; + this._iconVariables = iconVariables; + this._resolvedVariables = resolvedVariables; + this._manifestLocation = manifestLocation; + } + + async toVariables(): Promise> { + const { parsedStructure, promises } = await this._fetchAndParseIcons(); + await Promise.all(promises); + return this._produceColoredIconVariables(parsedStructure); + } + + private async _fetchAndParseIcons(): Promise<{ parsedStructure: ParsedStructure, promises: any[] }> { + const promises: any[] = []; + const parsedStructure: ParsedStructure = {}; + for (const [variable, url] of Object.entries(this._iconVariables)) { + const urlObject = new URL(`https://${url}`); + const pathWithoutQueryParams = urlObject.hostname; + const relativePath = new URL(pathWithoutQueryParams, new URL(this._manifestLocation, window.location.origin)); + const responsePromise = this._platform.request(relativePath, { method: "GET", format: "text", cache: true, }).response() + promises.push(responsePromise); + const searchParams = urlObject.searchParams; + parsedStructure[variable] = { + svg: responsePromise, + primary: searchParams.get("primary"), + secondary: searchParams.get("secondary") + }; + } + return { parsedStructure, promises }; + } + + private async _produceColoredIconVariables(parsedStructure: ParsedStructure): Promise> { + let coloredVariables: Record = {}; + for (const [variable, { svg, primary, secondary }] of Object.entries(parsedStructure)) { + const { body: svgCode } = await svg; + if (!primary) { + throw new Error(`Primary color variable ${primary} not in list of variables!`); + } + const primaryColor = this._resolvedVariables[primary], secondaryColor = this._resolvedVariables[secondary!]; + const coloredSvgCode = getColoredSvgString(svgCode, primaryColor, secondaryColor); + const dataURI = `url('data:image/svg+xml;utf8,${encodeURIComponent(coloredSvgCode)}')`; + coloredVariables[variable] = dataURI; + } + return coloredVariables; + } +} diff --git a/src/platform/web/theming/ThemeLoader.ts b/src/platform/web/theming/ThemeLoader.ts new file mode 100644 index 00000000..be1bafc0 --- /dev/null +++ b/src/platform/web/theming/ThemeLoader.ts @@ -0,0 +1,188 @@ +/* +Copyright 2020 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. +*/ + +import type {ILogItem} from "../../../logging/types"; +import type {Platform} from "../Platform.js"; +import {RuntimeThemeParser} from "./parsers/RuntimeThemeParser"; +import type {Variant, ThemeInformation} from "./parsers/types"; +import {ColorSchemePreference} from "./parsers/types"; +import {BuiltThemeParser} from "./parsers/BuiltThemeParser"; + +export class ThemeLoader { + private _platform: Platform; + private _themeMapping: Record; + private _injectedVariables?: Record; + + constructor(platform: Platform) { + this._platform = platform; + } + + async init(manifestLocations: string[], log?: ILogItem): Promise { + await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { + const results = await Promise.all( + manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) + ); + const runtimeThemeParser = new RuntimeThemeParser(this._platform, this.preferredColorScheme); + const builtThemeParser = new BuiltThemeParser(this.preferredColorScheme); + const runtimeThemePromises: Promise[] = []; + for (let i = 0; i < results.length; ++i) { + const { body } = results[i]; + try { + if (body.extends) { + const indexOfBaseManifest = results.findIndex(manifest => manifest.body.id === body.extends); + if (indexOfBaseManifest === -1) { + throw new Error(`Base manifest for derived theme at ${manifestLocations[i]} not found!`); + } + const {body: baseManifest} = results[indexOfBaseManifest]; + const baseManifestLocation = manifestLocations[indexOfBaseManifest]; + const promise = runtimeThemeParser.parse(body, baseManifest, baseManifestLocation, log); + runtimeThemePromises.push(promise); + } + else { + builtThemeParser.parse(body, manifestLocations[i], log); + } + } + catch(e) { + console.error(e); + } + } + await Promise.all(runtimeThemePromises); + this._themeMapping = { ...builtThemeParser.themeMapping, ...runtimeThemeParser.themeMapping }; + Object.assign(this._themeMapping, builtThemeParser.themeMapping, runtimeThemeParser.themeMapping); + this._addDefaultThemeToMapping(log); + log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); + log.log({ l: "Result", themeMapping: this._themeMapping }); + }); + } + + setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { + this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { + let cssLocation: string, variables: Record; + let themeDetails = this._themeMapping[themeName]; + if ("id" in themeDetails) { + cssLocation = themeDetails.cssLocation; + variables = themeDetails.variables; + } + else { + if (!themeVariant) { + throw new Error("themeVariant is undefined!"); + } + cssLocation = themeDetails[themeVariant].cssLocation; + variables = themeDetails[themeVariant].variables; + } + this._platform.replaceStylesheet(cssLocation); + if (variables) { + log?.log({l: "Derived Theme", variables}); + this._injectCSSVariables(variables); + } + else { + this._removePreviousCSSVariables(); + } + this._platform.settingsStorage.setString("theme-name", themeName); + if (themeVariant) { + this._platform.settingsStorage.setString("theme-variant", themeVariant); + } + else { + this._platform.settingsStorage.remove("theme-variant"); + } + }); + } + + private _injectCSSVariables(variables: Record): void { + const root = document.documentElement; + for (const [variable, value] of Object.entries(variables)) { + root.style.setProperty(`--${variable}`, value); + } + this._injectedVariables = variables; + } + + private _removePreviousCSSVariables(): void { + if (!this._injectedVariables) { + return; + } + const root = document.documentElement; + for (const variable of Object.keys(this._injectedVariables)) { + root.style.removeProperty(`--${variable}`); + } + this._injectedVariables = undefined; + } + + /** Maps theme display name to theme information */ + get themeMapping(): Record { + return this._themeMapping; + } + + async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> { + let themeName = await this._platform.settingsStorage.getString("theme-name"); + let themeVariant = await this._platform.settingsStorage.getString("theme-variant"); + if (!themeName || !this._themeMapping[themeName]) { + themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0]; + if (!this._themeMapping[themeName][themeVariant]) { + themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined; + } + } + return { themeName, themeVariant }; + } + + getDefaultTheme(): string | undefined { + switch (this.preferredColorScheme) { + case ColorSchemePreference.Dark: + return this._platform.config["defaultTheme"]?.dark; + case ColorSchemePreference.Light: + return this._platform.config["defaultTheme"]?.light; + } + } + + private _findThemeDetailsFromId(themeId: string): {themeName: string, themeData: Partial} | undefined { + for (const [themeName, themeData] of Object.entries(this._themeMapping)) { + if ("id" in themeData && themeData.id === themeId) { + return { themeName, themeData }; + } + else if ("light" in themeData && themeData.light?.id === themeId) { + return { themeName, themeData: themeData.light }; + } + else if ("dark" in themeData && themeData.dark?.id === themeId) { + return { themeName, themeData: themeData.dark }; + } + } + } + + private _addDefaultThemeToMapping(log: ILogItem) { + log.wrap("addDefaultThemeToMapping", l => { + const defaultThemeId = this.getDefaultTheme(); + if (defaultThemeId) { + const themeDetails = this._findThemeDetailsFromId(defaultThemeId); + if (themeDetails) { + this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! }; + const variables = themeDetails.themeData.variables; + if (variables) { + this._themeMapping["Default"].variables = variables; + } + } + } + l.log({ l: "Default Theme", theme: defaultThemeId}); + }); + } + + get preferredColorScheme(): ColorSchemePreference | undefined { + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + return ColorSchemePreference.Dark; + } + else if (window.matchMedia("(prefers-color-scheme: light)").matches) { + return ColorSchemePreference.Light; + } + } +} diff --git a/src/platform/web/theming/parsers/BuiltThemeParser.ts b/src/platform/web/theming/parsers/BuiltThemeParser.ts new file mode 100644 index 00000000..fbafadb8 --- /dev/null +++ b/src/platform/web/theming/parsers/BuiltThemeParser.ts @@ -0,0 +1,106 @@ +/* +Copyright 2020 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. +*/ + +import type {ThemeInformation} from "./types"; +import type {ThemeManifest} from "../../../types/theme"; +import type {ILogItem} from "../../../../logging/types"; +import {ColorSchemePreference} from "./types"; + +export class BuiltThemeParser { + private _themeMapping: Record = {}; + private _preferredColorScheme?: ColorSchemePreference; + + constructor(preferredColorScheme?: ColorSchemePreference) { + this._preferredColorScheme = preferredColorScheme; + } + + parse(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) { + log.wrap("BuiltThemeParser.parse", () => { + /* + After build has finished, the source section of each theme manifest + contains `built-assets` which is a mapping from the theme-id to + cssLocation of theme + */ + const builtAssets: Record = manifest.source?.["built-assets"]; + const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest at ${manifestLocation}`); + } + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (let [themeId, cssLocation] of Object.entries(builtAssets)) { + try { + /** + * This cssLocation is relative to the location of the manifest file. + * So we first need to resolve it relative to the root of this hydrogen instance. + */ + cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; + } + catch { + continue; + } + const variant = themeId.match(/.+-(.+)/)?.[1]; + const variantDetails = manifest.values?.variants[variant!]; + if (!variantDetails) { + throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`); + } + const { name: variantName, default: isDefault, dark } = variantDetails; + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + /** + * This is a default variant! + * We'll add these to the themeMapping (separately) keyed with just the + * theme-name (i.e "Element" instead of "Element Dark"). + * We need to be able to distinguish them from other variants! + * + * This allows us to render radio-buttons with "dark" and + * "light" options. + */ + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + defaultVariant.variantName = variantName; + defaultVariant.id = themeId + defaultVariant.cssLocation = cssLocation; + continue; + } + // Non-default variants are keyed in themeMapping with "theme_name variant_name" + // eg: "Element Dark" + this._themeMapping[themeDisplayName] = { + cssLocation, + id: themeId + }; + } + if (defaultDarkVariant.id && defaultLightVariant.id) { + /** + * As mentioned above, if there's both a default dark and a default light variant, + * add them to themeMapping separately. + */ + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + /** + * If only one default variant is found (i.e only dark default or light default but not both), + * treat it like any other variant. + */ + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + }); + } + + get themeMapping(): Record { + return this._themeMapping; + } +} diff --git a/src/platform/web/theming/parsers/RuntimeThemeParser.ts b/src/platform/web/theming/parsers/RuntimeThemeParser.ts new file mode 100644 index 00000000..9471740a --- /dev/null +++ b/src/platform/web/theming/parsers/RuntimeThemeParser.ts @@ -0,0 +1,98 @@ +/* +Copyright 2020 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. +*/ +import type {ThemeInformation} from "./types"; +import type {Platform} from "../../Platform.js"; +import type {ThemeManifest} from "../../../types/theme"; +import {ColorSchemePreference} from "./types"; +import {IconColorizer} from "../IconColorizer"; +import {DerivedVariables} from "../DerivedVariables"; +import {ILogItem} from "../../../../logging/types"; + +export class RuntimeThemeParser { + private _themeMapping: Record = {}; + private _preferredColorScheme?: ColorSchemePreference; + private _platform: Platform; + + constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) { + this._preferredColorScheme = preferredColorScheme; + this._platform = platform; + } + + async parse(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise { + await log.wrap("RuntimeThemeParser.parse", async () => { + const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log); + const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest!`); + } + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (const [variant, variantDetails] of Object.entries(manifest.values?.variants!) as [string, any][]) { + try { + const themeId = `${manifest.id}-${variant}`; + const { name: variantName, default: isDefault, dark, variables } = variantDetails; + const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables(); + Object.assign(variables, resolvedVariables); + const iconVariables = await new IconColorizer(this._platform, icons, variables, baseManifestLocation).toVariables(); + Object.assign(variables, resolvedVariables, iconVariables); + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables }); + continue; + } + this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, }; + } + catch (e) { + console.error(e); + continue; + } + } + if (defaultDarkVariant.id && defaultLightVariant.id) { + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + }); + } + + private _getSourceData(manifest: ThemeManifest, location: string, log: ILogItem) + : { cssLocation: string, derivedVariables: string[], icons: Record} { + return log.wrap("getSourceData", () => { + const runtimeCSSLocation = manifest.source?.["runtime-asset"]; + if (!runtimeCSSLocation) { + throw new Error(`Run-time asset not found in source section for theme at ${location}`); + } + const cssLocation = new URL(runtimeCSSLocation, new URL(location, window.location.origin)).href; + const derivedVariables = manifest.source?.["derived-variables"]; + if (!derivedVariables) { + throw new Error(`Derived variables not found in source section for theme at ${location}`); + } + const icons = manifest.source?.["icon"]; + if (!icons) { + throw new Error(`Icon mapping not found in source section for theme at ${location}`); + } + return { cssLocation, derivedVariables, icons }; + }); + } + + get themeMapping(): Record { + return this._themeMapping; + } + +} diff --git a/src/platform/web/theming/parsers/types.ts b/src/platform/web/theming/parsers/types.ts new file mode 100644 index 00000000..b357cf2c --- /dev/null +++ b/src/platform/web/theming/parsers/types.ts @@ -0,0 +1,38 @@ +/* +Copyright 2020 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. +*/ + +export type NormalVariant = { + id: string; + cssLocation: string; + variables?: any; +}; + +export type Variant = NormalVariant & { + variantName: string; +}; + +export type DefaultVariant = { + dark: Variant; + light: Variant; + default: Variant; +} + +export type ThemeInformation = NormalVariant | DefaultVariant; + +export enum ColorSchemePreference { + Dark, + Light +}; diff --git a/scripts/postcss/color.js b/src/platform/web/theming/shared/color.mjs similarity index 91% rename from scripts/postcss/color.js rename to src/platform/web/theming/shared/color.mjs index b1ef7073..e777bfac 100644 --- a/scripts/postcss/color.js +++ b/src/platform/web/theming/shared/color.mjs @@ -13,10 +13,10 @@ 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. */ +import pkg from 'off-color'; +const offColor = pkg.offColor; -const offColor = require("off-color").offColor; - -module.exports.derive = function (value, operation, argument, isDark) { +export function derive(value, operation, argument, isDark) { const argumentAsNumber = parseInt(argument); if (isDark) { // For dark themes, invert the operation diff --git a/src/platform/web/theming/shared/svg-colorizer.mjs b/src/platform/web/theming/shared/svg-colorizer.mjs new file mode 100644 index 00000000..cb291726 --- /dev/null +++ b/src/platform/web/theming/shared/svg-colorizer.mjs @@ -0,0 +1,24 @@ +/* +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. +*/ + +export function getColoredSvgString(svgString, primaryColor, secondaryColor) { + let coloredSVGCode = svgString.replaceAll("#ff00ff", primaryColor); + coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor); + if (svgString === coloredSVGCode) { + throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive)."); + } + return coloredSVGCode; +} diff --git a/theme.json b/theme.json new file mode 100644 index 00000000..ea7492d4 --- /dev/null +++ b/theme.json @@ -0,0 +1,51 @@ +{ + "name": "Customer", + "extends": "element", + "id": "customer", + "values": { + "variants": { + "dark": { + "dark": true, + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "light": { + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "red": { + "name": "Gruvbox", + "variables": { + "background-color-primary": "#282828", + "background-color-secondary": "#3c3836", + "text-color": "#fbf1c7", + "accent-color": "#8ec07c", + "error-color": "#fb4934", + "fixed-white": "#fff", + "room-badge": "#cc241d", + "link-color": "#fe8019" + } + } + } + } +} diff --git a/vite.common-config.js b/vite.common-config.js index 5d65f8e2..2fa09d46 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -8,8 +8,8 @@ const path = require("path"); const manifest = require("./package.json"); const version = manifest.version; const compiledVariables = new Map(); -const derive = require("./scripts/postcss/color").derive; -const replacer = require("./scripts/postcss/svg-colorizer").buildColorizedSVG; +import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-builder.mjs"; +import {derive} from "./src/platform/web/theming/shared/color.mjs"; const commonOptions = { logLevel: "warn", diff --git a/yarn.lock b/yarn.lock index 28efc3bc..c48d2719 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,6 +77,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@trysound/sax@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" + integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== + "@types/json-schema@^7.0.7": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -347,6 +352,11 @@ commander@^6.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== +commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -382,11 +392,26 @@ css-select@^4.1.3: domutils "^2.6.0" nth-check "^2.0.0" +css-tree@^1.1.2, css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + css-what@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad" integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg== +csso@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== + dependencies: + css-tree "^1.1.2" + cuint@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" @@ -1197,6 +1222,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + mdn-polyfills@^5.20.0: version "5.20.0" resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.20.0.tgz#ca8247edf20a4f60dec6804372229812b348260b" @@ -1500,7 +1530,7 @@ source-map-js@^1.0.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map@~0.6.1: +source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -1510,6 +1540,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + string-width@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" @@ -1550,6 +1585,19 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +svgo@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" + integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^4.1.3" + css-tree "^1.1.3" + csso "^4.2.0" + picocolors "^1.0.0" + stable "^0.1.8" + table@^6.0.9: version "6.7.1" resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" @@ -1617,10 +1665,10 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -typescript@^4.3.5: - version "4.3.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" - integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== +typescript@^4.7.0: + version "4.7.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" + integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39"