diff --git a/.env.development b/.env.development index 21974f265..ab3fd28bf 100644 --- a/.env.development +++ b/.env.development @@ -27,4 +27,5 @@ FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico IGNORED_ERROR_REGEX= MFE_CONFIG_API_URL= APP_ID= -SUPPORT_URL=https://support.edx.org \ No newline at end of file +SUPPORT_URL=https://support.edx.org +PARAGON_THEME_URLS={} diff --git a/.env.test b/.env.test index f25231a3b..ab3fd28bf 100644 --- a/.env.test +++ b/.env.test @@ -28,3 +28,4 @@ IGNORED_ERROR_REGEX= MFE_CONFIG_API_URL= APP_ID= SUPPORT_URL=https://support.edx.org +PARAGON_THEME_URLS={} diff --git a/docs/how_tos/assets/paragon-theme-loader.png b/docs/how_tos/assets/paragon-theme-loader.png new file mode 100644 index 000000000..737a95052 Binary files /dev/null and b/docs/how_tos/assets/paragon-theme-loader.png differ diff --git a/docs/how_tos/theming.md b/docs/how_tos/theming.md new file mode 100644 index 000000000..5e52cc088 --- /dev/null +++ b/docs/how_tos/theming.md @@ -0,0 +1,261 @@ +# Theming support with `@openedx/paragon` and `@openedx/brand-openedx` + +> [!IMPORTANT] +> This document describes theming with design tokens. +> +> Information on theming MFEs that do not yet have design tokens support: +> * https://github.com/openedx/brand-openedx +> +> Information on the design tokens project: +> * https://github.com/openedx/paragon/blob/master/docs/decisions/0019-scaling-styles-with-design-tokens.rst +> * https://github.com/openedx/paragon/tree/alpha?tab=readme-ov-file#design-tokens + +## Overview + +This document is a guide for using `@edx/frontend-platform` to support MFE theming with Paragon loading the theme CSS externally (e.g., from a CDN). + +To do this, configured URLs pointing to relevant CSS files from `@openedx/paragon` and (optionally) `@openedx/brand-openedx` are loaded and injected to the HTML document at runtime. This differs from importing the styles from `@openedx/paragon` and `@openedx/brand-openedx` directly, which includes these styles in the application's production assets. + +By serving CSS loaded externally, consuming applications of Paragon no longer need to be responsible for compiling the theme SCSS to CSS themselves and instead use a pre-compiled CSS file. In doing so, this allows making changes to the Paragon theme without needing to necessarily re-build and re-deploy all consuming applications. + +### Dark mode and theme variant preferences + +`@edx/frontend-platform` supports both `light` (required) and `dark` (optional) theme variants. The choice of which theme variant should be applied on page load is based on the following preference cascade: + +1. **Get theme preference from localStorage.** Supports persisting and loading the user's preference for their selected theme variant, until cleared. +1. **Detect user system settings.** Rely on the `prefers-color-scheme` media query to detect if the user's system indicates a preference for dark mode. If so, use the default dark theme variant, if one is configured. +1. **Use the default theme variant as configured (see below).** Otherwise, load the default theme variant as configured by the `defaults` option described below. + +Whenever the current theme variant changes, an attribute `data-paragon-theme-variant="*"` is updated on the `` element. This attribute enables applications both JS and CSS to have knowledge of the currently applied theme variant. + +### Supporting custom theme variants beyond `light` and `dark` + +If your use case requires additional variants beyond the default `light` and `dark` theme variants, you may pass any number of custom theme variants. Custom theme variants will work the user's persisted localStorage setting (i.e., if a user switches to a custom theme variant, the MFE will continue to load the custom theme variant by default). By supporting custom theme variants, it also supports having multiple or alternative `light` and/or `dark` theme variants. You can see the [Configuration options](#configuration-options) example for better understanding. + +### Performance implications + +There is also a meaningful improvement in performance as loading the compiled theme CSS from an external CDN means micro-frontends (MFEs) can include cached styles instead of needing to load essentially duplicate theme styles included in each individual MFE as users navigate across the platform. + +However, as the styles from `@openedx/paragon` and `@openedx/brand-openedx` get loaded at runtime by `@edx/frontend-platform`, the associated CSS files do not get processed through the consuming application's Webpack build process (e.g., if the MFE used PurgeCSS or any custom PostCSS plugins specifically for Paragon). + +### Falling back to styles installed in consuming application + +If any of the configured external `PARAGON_THEME_URLS` fail to load for whatever reason (e.g., CDN is down, URL is incorrectly configured), `@openedx/paragon` will attempt to fallback to the relevant files installed in `node_modules` from the consuming application. + +## Technical architecture + +![overview of paragon theme loader](./assets/paragon-theme-loader.png "Paragon theme loader") + +## Development + +### Basic theme URL configuration + +Paragon theme loader supports 3 mechanisms for configuring the Paragon theme urls: +* Environment Variables stringified JSON useful to preload the theme during the build time. +* JavaScript-based configuration via `env.config.js` (only runtime config). +* MFE runtime configuration API via `edx-platform` (only runtime config). + +Using any configuration mechanism, a `PARAGON_THEME_URLS` configuration setting must be created to point to the externally hosted Paragon theme CSS files, e.g.: + +```json +{ + "core": { + "url": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css" + }, + "defaults": { + "light": "light", + }, + "variants": { + "light": { + "url": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css", + } + } +} +``` + +### Configuration options + +The `PARAGON_THEME_URLS` configuration object supports using only the default styles from `@openedx/paragon` or, optionally, extended/overridden styles via `@openedx/brand-openedx`. To utilize `@openedx/brand-openedx` overrides, see the `core.urls` and `variants.*.urls` options below. + +The `dark` theme variant options are optional. + +| Property | Data Type | Description | +| -------- | ----------- | ----------- | +| `core` | Object | Metadata about the core styles from `@openedx/paragon` and `@openedx/brand-openedx`. | +| `core.url` | String | URL for the `core.css` file from `@openedx/paragon`. | +| `core.urls` | Object | URL(s) for the `core.css` files from `@openedx/paragon` CSS and (optionally) `@openedx/brand-openedx`. | +| `core.urls.default` | String | URL for the `core.css` file from `@openedx/paragon`. | +| `core.urls.brandOverride` | Object | URL for the `core.css` file from `@openedx/brand-openedx`. | +| `defaults` | Object | Mapping of theme variants to Paragon's default supported light and dark theme variants. | +| `defaults.light` | String | Default `light` theme variant from the theme variants in the `variants` object. | +| `defaults.dark` | String | Default `dark` theme variant from the theme variants in the `variants` object. | +| `variants` | Object | Metadata about each supported theme variant. | +| `variants.light` | Object | Metadata about the light theme variant styles from `@openedx/paragon` and (optionally)`@openedx/brand-openedx`. | +| `variants.light.url` | String | URL for the `light.css` file from `@openedx/paragon`. | +| `variants.light.urls` | Object | URL(s) for the `light.css` files from `@openedx/paragon` CSS and (optionally) `@openedx/brand-openedx`. | +| `variants.light.urls.default` | String | URL for the `light.css` file from `@openedx/paragon`. | +| `variants.light.urls.brandOverride` | String | URL for the `light.css` file from `@openedx/brand-openedx`. | +| `variants.dark` | Object | Metadata about the dark theme variant styles from `@openedx/paragon` and (optionally)`@openedx/brand-openedx`. | +| `variants.dark.url` | String | URL for the `dark.css` file from `@openedx/paragon`. | +| `variants.dark.urls` | Object | URL(s) for the `dark.css` files from `@openedx/paragon` CSS and (optionally) `@openedx/brand-openedx`. | +| `variants.dark.urls.default` | String | URL for the `dark.css` file from `@openedx/paragon`. | +| `variants.dark.urls.brandOverride` | String | URL for the `dark.css` file from `@openedx/brand-openedx`. | + +Understanding the different configuration options, a complex use case: + +```js +const config = { + PARAGON_THEME_URLS: { + { + core: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@my-brand/brand-package@#brandVersion/dist/core.min.css', + }, + }, + defaults: { + light: 'light', + dark: 'dark', + }, + variants: { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@my-brand/brand-package@$brandVersion/dist/light.min.css', + }, + }, + // Configure optional dark mode + dark: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dark/dark.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@my-brand/brand-package@$brandVersion/dist/dark.min.css', + }, + }, + // Configure any extra theme using a custom @openedx/brand-openedx package + green: { + url: 'https://cdn.jsdelivr.net/npm/@my-brand/brand-package@$brandVersion/dist/green.min.css', + }, + red: { + url: 'https://cdn.jsdelivr.net/npm/@my-brand/brand-package@$brandVersion/dist/red.min.css', + }, + 'high-contrast-dark': { + url: 'https://cdn.jsdelivr.net/npm/@my-brand/brand-package@$brandVersion/dist/high-contrast-dark.min.css', + }, + }, + } + } +} +``` + +### JavaScript-based configuration + +One approach to configuring the `PARAGON_THEME_URLS` is to create a `env.config.js` file in the root of the repository. The configuration is defined as a JavaScript file, which affords consumers to use more complex data types, amongst other benefits. + +To use this JavaScript-based configuration approach, you may set a `PARAGON_THEME_URLS` configuration variable in a `env.config.js` file: + +```js +const config = { + PARAGON_THEME_URLS: { + core: { + url: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css', + }, + defaults: { + light: 'light', + }, + variants: { + light: { + url: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css', + }, + }, + }, +}; + +export default config; +``` + +### MFE runtime configuration API + +`@edx/frontend-platform` additionally supports loading application configuration from the MFE runtime configuration API via `edx-platform`. The configuration is served by the `http://localhost:18000/api/mfe_config/v1` API endpoint. For more information, refer to [this documentation](https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/mfe_config_api/docs/decisions/0001-mfe-config-api.rst) about the MFE runtime configuration API, please see these docs. + +The application configuration may be setup via Django settings as follows: + +```python +ENABLE_MFE_CONFIG_API = True +MFE_CONFIG = {} +MFE_CONFIG_OVERRIDES = { + # The below key represented the `APP_ID` defined in your MFE + 'profile': { + 'PARAGON_THEME_URLS': { + 'core': { + 'url': 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css', + }, + 'defaults': { + 'light': 'light', + }, + 'variants': { + 'light': { + 'url': 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css', + }, + }, + }, + }, +} +``` + +### Reference the locally installed `@openedx/paragon` version + +If you would like to use the same version of the Paragon CSS urls as the locally installed `@openedx/paragon`, the configuration for the Paragon CSS urls may contain a wildcard `$paragonVersion` which gets replaced with the locally installed version of `@openedx/paragon` in the consuming application, e.g.: + +```shell +https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css +https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css +``` + +In the event the other Paragon CSS urls are configured via one of the other documented mechanisms, but they fail to load (e.g., the CDN url throws a 404), `@edx/frontend-platform` will attempt to fallback to injecting the locally installed Paragon CSS from the consuming application into the HTML document. + +## Usage with `@openedx/brand-openedx` + +The core Paragon design tokens and styles may be optionally overridden by utilizing `@openedx/brand-openedx`, which allows theme authors to customize the default Paragon theme to match the look and feel of their custom brand. + +This override mechanism works by compiling the design tokens defined in `@openedx/brand-openedx` with the core Paragon tokens to generate overrides to Paragon's default CSS variables and then compiling the output CSS with any SCSS theme customizations not possible through a design token override. + +The CSS urls for `@openedx/brand-openedx` overrides will be applied after the core Paragon theme urls load, thus overriding any previously set CSS variables and/or styles. + +To enable `@openedx/brand-openedx` overrides, the `PARAGON_THEME_URLS` setting may be configured as following: + +```js +const config = { + PARAGON_THEME_URLS: { + core: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@openedx/brand-openedx@#brandVersion/dist/core.min.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@openedx/brand-openedx@$brandVersion/dist/light.min.css', + }, + }, + }, + }, +}; + +export default config; +``` + +### Reference the locally installed `@openedx/brand-openedx` version + +If you would like to use the same version of the brand overrides CSS urls as the locally installed `@openedx/brand-openedx`, the configuration for the brand override CSS urls may contain a wildcard `$brandVersion` which gets replaced with the locally installed version of `@openedx/brand-openedx` in the consuming application, e.g.: + +```shell +https://cdn.jsdelivr.net/npm/@openedx/brand-openedx@$brandVersion/dist/core.min.css +https://cdn.jsdelivr.net/npm/@openedx/brand-openedx@$brandVersion/dist/light.min.css +``` + +In the event the other brand override CSS urls are configured via one of the other documented mechanisms, but they fail to load (e.g., the CDN is down), `@edx/frontend-platform` will attempt to fallback to injecting the locally installed brand override CSS urls from the consuming application into the HTML document. diff --git a/example/index.scss b/example/index.scss index 333ca14e5..cf248ae09 100644 --- a/example/index.scss +++ b/example/index.scss @@ -1,4 +1 @@ -@import "@edx/brand/paragon/fonts"; -@import "@edx/brand/paragon/variables"; -@import "@openedx/paragon/scss/core/core"; -@import "@edx/brand/paragon/overrides"; +@use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" as paragonCustomMediaBreakpoints; diff --git a/package-lock.json b/package-lock.json index 937dccde0..4ad019572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,13 +37,15 @@ "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/browserslist-config": "1.5.0", "@openedx/frontend-build": "^14.3.0", - "@openedx/paragon": "22.15.3", + "@openedx/paragon": "^23.3.0", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", + "@testing-library/user-event": "14.4.3", "axios-mock-adapter": "^1.22.0", "husky": "8.0.3", "jest-environment-jsdom": "29.7.0", + "jest-localstorage-mock": "^2.4.26", "jsdoc": "^4.0.0", "nodemon": "3.1.9", "prop-types": "15.8.1", @@ -55,7 +57,7 @@ }, "peerDependencies": { "@openedx/frontend-build": ">= 14.0.0", - "@openedx/paragon": ">= 21.5.7 < 23.0.0", + "@openedx/paragon": ">= 21.5.7 < 24.0.0", "prop-types": ">=15.7.2 <16.0.0", "react": "^16.9.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0", @@ -2047,6 +2049,221 @@ "dev": true, "license": "MIT" }, + "node_modules/@bundled-es-modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==", + "dev": true, + "license": "ISC", + "dependencies": { + "deepmerge": "^4.3.1" + } + }, + "node_modules/@bundled-es-modules/glob": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/glob/-/glob-10.4.2.tgz", + "integrity": "sha512-740y5ofkzydsFao5EXJrGilcIL6EFEw/cmPf2uhTw9J6G1YOhiIFjNFCHdpgEiiH5VlU3G0SARSjlFlimRRSMA==", + "dev": true, + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "buffer": "^6.0.3", + "events": "^3.3.0", + "glob": "^10.4.2", + "patch-package": "^8.0.0", + "path": "^0.12.7", + "stream": "^0.0.3", + "string_decoder": "^1.3.0", + "url": "^0.11.3" + } + }, + "node_modules/@bundled-es-modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@bundled-es-modules/glob/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@bundled-es-modules/glob/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@bundled-es-modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@bundled-es-modules/memfs": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/memfs/-/memfs-4.9.4.tgz", + "integrity": "sha512-1XyYPUaIHwEOdF19wYVLBtHJRr42Do+3ctht17cZOHwHf67vkmRNPlYDGY2kJps4RgE5+c7nEZmEzxxvb1NZWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "assert": "^2.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "memfs": "^4.9.3", + "path": "^0.12.7", + "stream": "^0.0.3", + "util": "^0.12.5" + } + }, + "node_modules/@bundled-es-modules/memfs/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@bundled-es-modules/memfs/node_modules/memfs": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.0.tgz", + "integrity": "sha512-4eirfZ7thblFmqFjywlTmuWVSvccHAJbn1r8qQLzmTO11qcqpohOjmY2mFce6x7x7WtskzRqApPD0hv+Oa74jg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/@bundled-es-modules/postcss-calc-ast-parser": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.6.tgz", + "integrity": "sha512-y65TM5zF+uaxo9OeekJ3rxwTINlQvrkbZLogYvQYVoLtxm4xEiHfZ7e/MyiWbStYyWZVZkVqsaVU6F4SUK5XUA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-calc-ast-parser": "^0.1.4" + } + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@cospired/i18n-iso-languages": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-4.2.0.tgz", @@ -2687,43 +2904,6 @@ "node": ">=14.17" } }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", - "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", - "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.7.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/react-fontawesome": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz", - "integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "~1 || ~6", - "react": ">=16.x" - } - }, "node_modules/@fullhuman/postcss-purgecss": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-5.0.0.tgz", @@ -2775,6 +2955,109 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3296,6 +3579,63 @@ "node": ">=v12.0.0" } }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.1.tgz", + "integrity": "sha512-osjeBqMJ2lb/j/M8NCPjs1ylqWIcTRTycIhVB5pt6LgzgeRSb0YRZ7j9RfA8wIUrsr/medIuhVyonXRZWLyfdw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.5.0.tgz", + "integrity": "sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -3459,9 +3799,9 @@ } }, "node_modules/@openedx/paragon": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.15.3.tgz", - "integrity": "sha512-FrL5GSQnDeGfmfKF//K3e3ZaoveUDGM0Hv35sQA2G3zaKRWdlHB/drggaQNBYYrLhk30KaC+uLDH10GsE4A8aA==", + "version": "23.4.0", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.4.0.tgz", + "integrity": "sha512-fy7jmi4KaKIIVP8MDtqhOcKI8tvMkeYhYGI3iSjqWUWRUtdjKGbSMZuxhCt037zeSwJoecaxQ3z/nL3UrOkfLw==", "dev": true, "license": "Apache-2.0", "workspaces": [ @@ -3472,20 +3812,32 @@ "dependent-usage-analyzer" ], "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.1.1", - "@fortawesome/react-fontawesome": "^0.1.18", "@popperjs/core": "^2.11.4", + "@tokens-studio/sd-transforms": "^1.2.4", + "axios": "^0.27.2", "bootstrap": "^4.6.2", "chalk": "^4.1.2", "child_process": "^1.0.2", + "chroma-js": "^2.4.2", "classnames": "^2.3.1", + "cli-progress": "^3.12.0", + "commander": "^9.4.1", "email-prop-type": "^3.0.0", "file-selector": "^0.6.0", - "font-awesome": "^4.7.0", "glob": "^8.0.3", "inquirer": "^8.2.5", + "js-toml": "^1.0.0", "lodash.uniqby": "^4.7.0", + "log-update": "^4.0.0", "mailto-link": "^2.0.0", + "minimist": "^1.2.8", + "ora": "^5.4.1", + "postcss": "^8.4.21", + "postcss-combine-duplicated-selectors": "^10.0.3", + "postcss-custom-media": "^9.1.2", + "postcss-import": "^15.1.0", + "postcss-map": "^0.11.0", + "postcss-minify": "^1.1.0", "prop-types": "^15.8.1", "react-bootstrap": "^1.6.5", "react-colorful": "^5.6.1", @@ -3498,6 +3850,8 @@ "react-responsive": "^8.2.0", "react-table": "^7.7.0", "react-transition-group": "^4.4.2", + "sass": "^1.58.3", + "style-dictionary": "^4.3.2", "tabbable": "^5.3.3", "uncontrollable": "^7.2.1", "uuid": "^9.0.0" @@ -3511,6 +3865,17 @@ "react-intl": "^5.25.1 || ^6.4.0" } }, + "node_modules/@openedx/paragon/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/@openedx/paragon/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -3521,6 +3886,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/@openedx/paragon/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/@openedx/paragon/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -3555,6 +3930,46 @@ "node": ">=10" } }, + "node_modules/@openedx/paragon/node_modules/postcss-custom-media": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-9.1.5.tgz", + "integrity": "sha512-GStyWMz7Qbo/Gtw1xVspzVSX8eipgNg4lpsO3CAeY4/A1mzok+RV6MCv3fg62trWijh/lYEj6vps4o8JcBBpDA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^1.0.2", + "@csstools/css-parser-algorithms": "^2.2.0", + "@csstools/css-tokenizer": "^2.1.1", + "@csstools/media-query-list-parser": "^2.1.1" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", @@ -4050,6 +4465,48 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.4.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz", + "integrity": "sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tokens-studio/sd-transforms": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@tokens-studio/sd-transforms/-/sd-transforms-1.2.9.tgz", + "integrity": "sha512-doRL3tjhwmSck/9fH0X1mlBA6derw+8wpmi5hbG2vhAmvc8F89MxIN6JCKSIbVIJNvaprDVlQqSzXLG7Ug7F9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/deepmerge": "^4.3.1", + "@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6", + "@tokens-studio/types": "^0.5.1", + "colorjs.io": "^0.4.3", + "expr-eval-fork": "^2.0.2", + "is-mergeable-object": "^1.1.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "style-dictionary": "^4.1.4" + } + }, + "node_modules/@tokens-studio/types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.5.2.tgz", + "integrity": "sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -5111,6 +5568,25 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@zip.js/zip.js": { + "version": "2.7.57", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.57.tgz", + "integrity": "sha512-BtonQ1/jDnGiMed6OkV6rZYW78gLmLswkHOzyMrMb+CAR7CZO8phOHO6c2qw6qb1g1betN7kwEHhhZk30dv+NA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=16.5.0" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -5585,6 +6061,20 @@ "dev": true, "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/assert-ok": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-ok/-/assert-ok-1.0.0.tgz", @@ -5599,6 +6089,16 @@ "dev": true, "license": "ISC" }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -6546,6 +7046,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -6563,6 +7070,21 @@ "dev": true, "license": "MIT" }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, "node_modules/child_process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", @@ -6602,6 +7124,13 @@ "dev": true, "license": "ISC" }, + "node_modules/chroma-js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz", + "integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==", + "dev": true, + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -6694,6 +7223,19 @@ "node": ">=8" } }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -6849,6 +7391,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorjs.io": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.4.5.tgz", + "integrity": "sha512-yCtUNCmge7llyfd/Wou19PMAcf5yC3XXhgFoAh6zsO2pGswhUPBaaUh8jzgHnXtXuZyFKzXZNAnyF5i+apICow==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -8104,6 +8653,13 @@ "dev": true, "license": "MIT" }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9452,6 +10008,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expr-eval-fork": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-2.0.2.tgz", + "integrity": "sha512-NaAnObPVwHEYrODd7Jzp3zzT9pgTAlUUL4MZiZu9XAYPDpx89cPsfyEImFb2XY0vQNbrqg2CG7CLiI+Rs3seaQ==", + "dev": true, + "license": "MIT" + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -9841,6 +10404,16 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -9923,16 +10496,6 @@ } } }, - "node_modules/font-awesome": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", - "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==", - "dev": true, - "license": "(OFL-1.1 AND MIT)", - "engines": { - "node": ">=0.10.3" - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9949,6 +10512,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", @@ -10161,21 +10754,6 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -10988,6 +11566,16 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", @@ -11385,6 +11973,23 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -11717,6 +12322,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-mergeable-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-mergeable-object/-/is-mergeable-object-1.1.1.tgz", + "integrity": "sha512-CPduJfuGg8h8vW74WOxHtHmtQutyQBzR+3MjQ6iDHIYdbOnm1YC7jv43SqCoU8OPGTJD4nibmiryA4kmogbGrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12182,6 +12811,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -12725,6 +13370,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jest-localstorage-mock": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz", + "integrity": "sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.16.0" + } + }, "node_modules/jest-matcher-utils": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", @@ -13234,6 +13889,17 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-toml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-toml/-/js-toml-1.0.1.tgz", + "integrity": "sha512-rHd/IolpFm2V5BmHCEY8CckHs8NDsYZZ64H5RNgA6Opsr9vX4QyTiQPplgtqg7b3ztqYShZC38nl6CUg7QuhXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chevrotain": "^11.0.3", + "xregexp": "^5.1.1" + } + }, "node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", @@ -13493,6 +14159,16 @@ "graceful-fs": "^4.1.9" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -13656,6 +14332,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -13718,6 +14401,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -14120,6 +14822,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -14457,6 +15169,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -14806,6 +15535,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -14883,6 +15619,91 @@ "tslib": "^2.0.3" } }, + "node_modules/patch-package": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/patch-package/node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -14926,6 +15747,30 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -14943,6 +15788,30 @@ "node": ">=8" } }, + "node_modules/path-unified": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/path-unified/-/path-unified-0.2.0.tgz", + "integrity": "sha512-MNKqvrKbbbb5p7XHXV6ZAsf/1f/yJQa13S/fcX0uua8ew58Tgc6jXV+16JyAbnR/clgCH+euKDxrF2STxMHdrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/path/node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -15258,6 +16127,26 @@ "postcss": "^8.2.2" } }, + "node_modules/postcss-calc-ast-parser": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.4.tgz", + "integrity": "sha512-CebpbHc96zgFjGgdQ6BqBy6XIUgRx1xXWCAAk6oke02RZ5nxwo9KQejTg8y7uYEeI9kv8jKQPYjoe6REsY23vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^3.3.1" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/postcss-calc-ast-parser/node_modules/postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss-colormin": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", @@ -15271,10 +16160,26 @@ "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-combine-duplicated-selectors": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/postcss-combine-duplicated-selectors/-/postcss-combine-duplicated-selectors-10.0.3.tgz", + "integrity": "sha512-IP0BmwFloCskv7DV7xqvzDXqMHpwdczJa6ZvIW8abgHdcIHs9mCJX2ltFhu3EwA51ozp13DByng30+Ke+eIExA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >=14.0.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.1.0" } }, "node_modules/postcss-convert-values": { @@ -15375,6 +16280,24 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, "node_modules/postcss-loader": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", @@ -15411,6 +16334,56 @@ "node": ">=10" } }, + "node_modules/postcss-map": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/postcss-map/-/postcss-map-0.11.0.tgz", + "integrity": "sha512-cgHYZrH9aAMds90upYUPhYz8xnAcRD45SwuNns/nQHONIrPQDhpwk3JLsAQGOndQxnRVXfB6nB+3WqSMy8fqlA==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "js-yaml": "^3.12.0", + "postcss": "^7.0.2", + "reduce-function-call": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/postcss-map/node_modules/picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss-map/node_modules/postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/postcss-map/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/postcss-merge-longhand": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz", @@ -15447,6 +16420,20 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-minify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify/-/postcss-minify-1.1.0.tgz", + "integrity": "sha512-9D64ueIW0DL2FdLajQTlXrnTN8Ox9NjuXqigKMmB819RhdClNPYx5Zp3i5x0ghjjy3vGrLBBYEYvJjY/1eMNbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0", + "postcss-value-parser": "^4.1" + }, + "peerDependencies": { + "postcss": "^8.0" + } + }, "node_modules/postcss-minify-font-values": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", @@ -15935,6 +16922,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", + "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -15974,6 +16977,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -16961,6 +17974,26 @@ "react-dom": ">=16.6.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -17029,6 +18062,16 @@ "node": ">=8" } }, + "node_modules/reduce-function-call": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz", + "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -18212,6 +19255,24 @@ "node": ">=6" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -18416,6 +19477,29 @@ "node": ">= 0.8" } }, + "node_modules/stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.3.tgz", + "integrity": "sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^2.0.0" + } + }, + "node_modules/stream/node_modules/component-emitter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz", + "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/streamx": { "version": "2.22.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", @@ -18479,6 +19563,29 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -18586,6 +19693,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -18632,6 +19753,71 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-dictionary": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-4.3.3.tgz", + "integrity": "sha512-93ISASYmvGdKOvNHFaOZ+mVsCNQdoZzhSEq7JINE0BjMoE8zUzkwFyGDUBnfmXayHq/F4B4MCWmtjqjgHAYthw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@bundled-es-modules/deepmerge": "^4.3.1", + "@bundled-es-modules/glob": "^10.4.2", + "@bundled-es-modules/memfs": "^4.9.4", + "@zip.js/zip.js": "^2.7.44", + "chalk": "^5.3.0", + "change-case": "^5.3.0", + "commander": "^12.1.0", + "is-plain-obj": "^4.1.0", + "json5": "^2.2.2", + "patch-package": "^8.0.0", + "path-unified": "^0.2.0", + "prettier": "^3.3.3", + "tinycolor2": "^1.6.0" + }, + "bin": { + "style-dictionary": "bin/style-dictionary.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/style-dictionary/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/style-dictionary/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/style-dictionary/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", @@ -19028,6 +20214,19 @@ "dev": true, "license": "MIT" }, + "node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -19054,6 +20253,13 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -19156,6 +20362,23 @@ "node": ">=12" } }, + "node_modules/tree-dump": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", + "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -19654,6 +20877,20 @@ "punycode": "^2.1.0" } }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/url-loader": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", @@ -19712,6 +20949,13 @@ "requires-port": "^1.0.0" } }, + "node_modules/url/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -19767,6 +21011,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -20463,6 +21721,25 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -20529,6 +21806,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/xregexp": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-5.1.2.tgz", + "integrity": "sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.9" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index c8a88996d..97230fd78 100644 --- a/package.json +++ b/package.json @@ -36,13 +36,15 @@ "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/browserslist-config": "1.5.0", "@openedx/frontend-build": "^14.3.0", - "@openedx/paragon": "22.15.3", + "@openedx/paragon": "^23.3.0", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", + "@testing-library/user-event": "14.4.3", "axios-mock-adapter": "^1.22.0", "husky": "8.0.3", "jest-environment-jsdom": "29.7.0", + "jest-localstorage-mock": "^2.4.26", "jsdoc": "^4.0.0", "nodemon": "3.1.9", "prop-types": "15.8.1", @@ -75,7 +77,7 @@ }, "peerDependencies": { "@openedx/frontend-build": ">= 14.0.0", - "@openedx/paragon": ">= 21.5.7 < 23.0.0", + "@openedx/paragon": ">= 21.5.7 < 24.0.0", "prop-types": ">=15.7.2 <16.0.0", "react": "^16.9.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0", diff --git a/src/config.js b/src/config.js index 3adea36dd..ebbb3be99 100644 --- a/src/config.js +++ b/src/config.js @@ -137,6 +137,40 @@ function extractRegex(envVar) { return undefined; } +/** + * Safely parses a JSON string coming from the environment variables. + * If the JSON is invalid, the function returns an empty object and logs an error to the console. + * + * @param {string} paragonUrlsJson - The JSON string representing Paragon theme URLs. + * @returns {Object|undefined} - Returns a parsed object if the JSON is valid; otherwise, returns + * an empty object if invalid or undefined if no input is provided. + * + * @example + * const jsonString = '{ + * "core":{"urls":{"default":"core.min.css"}}, + * "defaults":{"light":"light"}, + * "variants":{"light":{"urls":{"default":"light.min.css"}}} + * }'; + * const parsedUrls = parseParagonThemeUrls(jsonString); + * console.log(parsedUrls); // Outputs the parsed JSON object + */ +function parseParagonThemeUrls(paragonUrlsJson) { + if (!paragonUrlsJson) { + return undefined; + } + try { + return JSON.parse(paragonUrlsJson); + } catch (err) { + if (err instanceof SyntaxError) { + // eslint-disable-next-line no-console + console.error('Unable to parse PARAGON_THEME_URLS JSON.\nPlease check https://github.com/openedx/frontend-platform/tree/master/docs/how_tos/theming.md for the expected formatting.\nAn empty object ({}) will be returned, which will cause the theming configuration to fall back to the installed packages.'); + return {}; + } + // In case of a different type of error, return the error object itself + return err; + } +} + const ENVIRONMENT = process.env.NODE_ENV; let config = { ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME, @@ -171,6 +205,7 @@ let config = { MFE_CONFIG_API_URL: process.env.MFE_CONFIG_API_URL, APP_ID: process.env.APP_ID, SUPPORT_URL: process.env.SUPPORT_URL, + PARAGON_THEME_URLS: parseParagonThemeUrls(process.env.PARAGON_THEME_URLS), }; /** @@ -324,4 +359,5 @@ export function ensureConfig(keys, requester = 'unspecified application code') { * @property {string} MFE_CONFIG_API_URL * @property {string} APP_ID * @property {string} SUPPORT_URL + * @property {string} PARAGON_THEME_URLS */ diff --git a/src/react/AppProvider.jsx b/src/react/AppProvider.jsx index 9aff41a27..2a2b6f37c 100644 --- a/src/react/AppProvider.jsx +++ b/src/react/AppProvider.jsx @@ -6,7 +6,12 @@ import OptionalReduxProvider from './OptionalReduxProvider'; import ErrorBoundary from './ErrorBoundary'; import AppContext from './AppContext'; -import { useAppEvent, useTrackColorSchemeChoice } from './hooks'; +import { + useAppEvent, + useParagonTheme, + useTrackColorSchemeChoice, +} from './hooks'; +import { paragonThemeActions } from './reducers'; import { getAuthenticatedUser, AUTHENTICATED_USER_CHANGED } from '../auth'; import { getConfig } from '../config'; import { CONFIG_CHANGED } from '../constants'; @@ -17,6 +22,7 @@ import { LOCALE_CHANGED, } from '../i18n'; import { basename } from '../initialize'; +import { SELECTED_THEME_VARIANT_KEY } from './constants'; /** * A wrapper component for React-based micro-frontends to initialize a number of common data/ @@ -39,6 +45,7 @@ import { basename } from '../initialize'; * - Optionally a redux `Provider`. Will only be included if a `store` property is passed to * `AppProvider`. * - A `Router` for react-router. + * - A theme manager for Paragon. * * @param {Object} props * @param {Object} [props.store] A redux store. @@ -49,8 +56,6 @@ export default function AppProvider({ store = null, children, wrapWithRouter = t const [authenticatedUser, setAuthenticatedUser] = useState(getAuthenticatedUser()); const [locale, setLocale] = useState(getLocale()); - useTrackColorSchemeChoice(); - useAppEvent(AUTHENTICATED_USER_CHANGED, () => { setAuthenticatedUser(getAuthenticatedUser()); }); @@ -63,7 +68,27 @@ export default function AppProvider({ store = null, children, wrapWithRouter = t setLocale(getLocale()); }); - const appContextValue = useMemo(() => ({ authenticatedUser, config, locale }), [authenticatedUser, config, locale]); + useTrackColorSchemeChoice(); + const [paragonThemeState, paragonThemeDispatch] = useParagonTheme(); + + const appContextValue = useMemo(() => ({ + authenticatedUser, + config, + locale, + paragonTheme: { + state: paragonThemeState, + setThemeVariant: (themeVariant) => { + paragonThemeDispatch(paragonThemeActions.setParagonThemeVariant(themeVariant)); + + // Persist selected theme variant to localStorage. + window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant); + }, + }, + }), [authenticatedUser, config, locale, paragonThemeState, paragonThemeDispatch]); + + if (!paragonThemeState?.isThemeLoaded) { + return null; + } return ( @@ -87,7 +112,7 @@ export default function AppProvider({ store = null, children, wrapWithRouter = t } AppProvider.propTypes = { - store: PropTypes.shape(), + store: PropTypes.shape({}), children: PropTypes.node.isRequired, wrapWithRouter: PropTypes.bool, }; diff --git a/src/react/AppProvider.test.jsx b/src/react/AppProvider.test.jsx index 9dd05c2c3..f8e3feae0 100644 --- a/src/react/AppProvider.test.jsx +++ b/src/react/AppProvider.test.jsx @@ -1,17 +1,46 @@ import React from 'react'; import { createStore } from 'redux'; import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + import AppProvider from './AppProvider'; import { initialize } from '../initialize'; +import { useAppEvent, useTrackColorSchemeChoice, useParagonTheme } from './hooks'; +import { AUTHENTICATED_USER_CHANGED, getAuthenticatedUser } from '../auth'; +import { CONFIG_CHANGED } from '../constants'; +import { getConfig } from '../config'; +import { getLocale, LOCALE_CHANGED } from '../i18n'; +import AppContext from './AppContext'; +import { SELECTED_THEME_VARIANT_KEY, SET_THEME_VARIANT } from './constants'; jest.mock('../auth', () => ({ - configure: () => {}, - getAuthenticatedUser: () => null, - fetchAuthenticatedUser: () => null, - getAuthenticatedHttpClient: () => ({}), + ...jest.requireActual('../auth'), + getAuthenticatedUser: jest.fn(), + fetchAuthenticatedUser: jest.fn(), + getAuthenticatedHttpClient: jest.fn().mockReturnValue({}), AUTHENTICATED_USER_CHANGED: 'user_changed', })); +jest.mock('../config', () => ({ + ...jest.requireActual('../config'), + getConfig: jest.fn().mockReturnValue({ + BASE_URL: 'localhost:8080', + LMS_BASE_URL: 'localhost:18000', + LOGIN_URL: 'localhost:18000/login', + LOGOUT_URL: 'localhost:18000/logout', + REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token', + ACCESS_TOKEN_COOKIE_NAME: 'access_token', + CSRF_TOKEN_API_PATH: 'localhost:18000/csrf', + PUBLIC_PATH: '/', + }), +})); + +jest.mock('../i18n', () => ({ + ...jest.requireActual('../i18n'), + getLocale: jest.fn().mockReturnValue('en'), +})); + jest.mock('../analytics', () => ({ configure: () => {}, identifyAnonymousUser: jest.fn(), @@ -20,11 +49,24 @@ jest.mock('../analytics', () => ({ jest.mock('./hooks', () => ({ ...jest.requireActual('./hooks'), + useAppEvent: jest.fn(), useTrackColorSchemeChoice: jest.fn(), + useParagonTheme: jest.fn().mockImplementation(() => [ + { isThemeLoaded: true, themeVariant: 'light' }, + jest.fn(), + ]), })); +Object.defineProperty(window, 'localStorage', { + value: { + setItem: jest.fn(), + }, +}); + describe('AppProvider', () => { beforeEach(async () => { + jest.clearAllMocks(); + await initialize({ loggingService: jest.fn(() => ({ logError: jest.fn(), @@ -107,4 +149,115 @@ describe('AppProvider', () => { const reduxProvider = wrapper.queryByTestId('redux-provider'); expect(reduxProvider).not.toBeInTheDocument(); }); + + describe('paragon theme and brand', () => { + let Component = ( + +
Child One
+
Child Two
+
+ ); + + it('calls trackColorSchemeChoice', () => { + render(Component); + expect(useTrackColorSchemeChoice).toHaveBeenCalled(); + }); + + it('calls useParagonTheme', () => { + render(Component); + expect(useParagonTheme).toHaveBeenCalled(); + }); + + it('blocks rendering until paragon theme is loaded', () => { + useParagonTheme.mockImplementationOnce(() => [ + { isThemeLoaded: false }, + jest.fn(), + ]); + const { container } = render(Component); + expect(container).toBeEmptyDOMElement(); + }); + + it('returns correct `paragonTheme` in context value', async () => { + const mockUseParagonThemeDispatch = jest.fn(); + useParagonTheme.mockImplementationOnce(() => [ + { isThemeLoaded: true, themeVariant: 'light' }, + mockUseParagonThemeDispatch, + ]); + Component = ( + + + {({ paragonTheme }) => ( +
+

Is theme loaded: {paragonTheme.state.isThemeLoaded ? 'yes' : 'no'}

+

Current theme variant: {paragonTheme.state.themeVariant}

+ +
+ )} +
+
+ ); + const wrapper = render(Component); + expect(wrapper.getByText('Is theme loaded: yes')).toBeInTheDocument(); + expect(wrapper.getByText('Current theme variant: light')).toBeInTheDocument(); + + const setThemeVariantBtn = wrapper.getByRole('button', { name: 'Set theme variant' }); + expect(setThemeVariantBtn).toBeInTheDocument(); + await userEvent.click(setThemeVariantBtn); + + expect(mockUseParagonThemeDispatch).toHaveBeenCalledTimes(1); + expect(mockUseParagonThemeDispatch).toHaveBeenCalledWith({ + payload: 'dark', + type: SET_THEME_VARIANT, + }); + expect(localStorage.setItem).toHaveBeenLastCalledWith(SELECTED_THEME_VARIANT_KEY, 'dark'); + }); + }); + + describe('useAppEvent', () => { + const Component = ( + +
Child
+
+ ); + it('subscribes to `AUTHENTICATED_USER_CHANGED`', async () => { + render(Component); + expect(useAppEvent).toHaveBeenCalledWith(AUTHENTICATED_USER_CHANGED, expect.any(Function)); + const useAppEventMockCalls = useAppEvent.mock.calls; + const authUserChangedFn = useAppEventMockCalls.find(([event]) => event === AUTHENTICATED_USER_CHANGED)[1]; + expect(authUserChangedFn).toBeDefined(); + const getAuthUserCallCount = getAuthenticatedUser.mock.calls.length; + authUserChangedFn(); + expect(getAuthUserCallCount + 1).toEqual(getAuthenticatedUser.mock.calls.length); + }); + + it('subscribes to `CONFIG_CHANGED`', async () => { + render(Component); + expect(useAppEvent).toHaveBeenCalledWith(CONFIG_CHANGED, expect.any(Function)); + const useAppEventMockCalls = useAppEvent.mock.calls; + const configChangedFn = useAppEventMockCalls.find(([event]) => event === CONFIG_CHANGED)[1]; + expect(configChangedFn).toBeDefined(); + const getConfigCallCount = getConfig.mock.calls.length; + configChangedFn(); + expect(getConfig.mock.calls.length).toEqual(getConfigCallCount + 1); + }); + + it('subscribes to `LOCALE_CHANGED`', async () => { + render(Component); + expect(useAppEvent).toHaveBeenCalledWith(LOCALE_CHANGED, expect.any(Function)); + const useAppEventMockCalls = useAppEvent.mock.calls; + const localeChangedFn = useAppEventMockCalls.find(([event]) => event === LOCALE_CHANGED)[1]; + expect(localeChangedFn).toBeDefined(); + const getLocaleCallCount = getLocale.mock.calls.length; + localeChangedFn(); + expect(getLocale.mock.calls.length).toEqual(getLocaleCallCount + 1); + }); + }); }); diff --git a/src/react/constants.js b/src/react/constants.js new file mode 100644 index 000000000..89f640bd4 --- /dev/null +++ b/src/react/constants.js @@ -0,0 +1,3 @@ +export const SET_THEME_VARIANT = 'SET_THEME_VARIANT'; +export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED'; +export const SELECTED_THEME_VARIANT_KEY = 'selected-paragon-theme-variant'; diff --git a/src/react/hooks.js b/src/react/hooks.js deleted file mode 100644 index b1d4e219e..000000000 --- a/src/react/hooks.js +++ /dev/null @@ -1,50 +0,0 @@ -/* eslint-disable import/prefer-default-export */ -import { useEffect } from 'react'; -import { subscribe, unsubscribe } from '../pubSub'; -import { sendTrackEvent } from '../analytics'; - -/** - * A React hook that allows functional components to subscribe to application events. This should - * be used sparingly - for the most part, Context should be used higher-up in the application to - * provide necessary data to a given component, rather than utilizing a non-React-like Pub/Sub - * mechanism. - * - * @memberof module:React - * @param {string} type - * @param {function} callback - */ -export const useAppEvent = (type, callback) => { - useEffect(() => { - const subscriptionToken = subscribe(type, callback); - - return function cleanup() { - unsubscribe(subscriptionToken); - }; - }, [callback, type]); -}; - -/** - * A React hook that tracks user's preferred color scheme (light or dark) and sends respective - * event to the tracking service. - * - * @memberof module:React - */ -export const useTrackColorSchemeChoice = () => { - useEffect(() => { - const trackColorSchemeChoice = ({ matches }) => { - const preferredColorScheme = matches ? 'dark' : 'light'; - sendTrackEvent('openedx.ui.frontend-platform.prefers-color-scheme.selected', { preferredColorScheme }); - }; - const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); - if (colorSchemeQuery) { - // send user's initial choice - trackColorSchemeChoice(colorSchemeQuery); - colorSchemeQuery.addEventListener('change', trackColorSchemeChoice); - } - return () => { - if (colorSchemeQuery) { - colorSchemeQuery.removeEventListener('change', trackColorSchemeChoice); - } - }; - }, []); -}; diff --git a/src/react/hooks/index.js b/src/react/hooks/index.js new file mode 100644 index 000000000..059a04a4c --- /dev/null +++ b/src/react/hooks/index.js @@ -0,0 +1,3 @@ +export { default as useAppEvent } from './useAppEvent'; + +export * from './paragon'; diff --git a/src/react/hooks/paragon/index.js b/src/react/hooks/paragon/index.js new file mode 100644 index 000000000..bda7abb66 --- /dev/null +++ b/src/react/hooks/paragon/index.js @@ -0,0 +1,2 @@ +export { default as useTrackColorSchemeChoice } from './useTrackColorSchemeChoice'; +export { default as useParagonTheme } from './useParagonTheme'; diff --git a/src/react/hooks/paragon/useParagonTheme.js b/src/react/hooks/paragon/useParagonTheme.js new file mode 100644 index 000000000..e487f601d --- /dev/null +++ b/src/react/hooks/paragon/useParagonTheme.js @@ -0,0 +1,189 @@ +import { + useCallback, useEffect, useReducer, useState, +} from 'react'; + +import { SELECTED_THEME_VARIANT_KEY } from '../../constants'; +import { logError } from '../../../logging'; +import { paragonThemeActions, paragonThemeReducer } from '../../reducers'; +import { isEmptyObject } from './utils'; + +import useParagonThemeCore from './useParagonThemeCore'; +import useParagonThemeUrls from './useParagonThemeUrls'; +import useParagonThemeVariants from './useParagonThemeVariants'; + +/** +* Finds the default theme variant from the given theme variants object. If no default theme exists, the light theme +* variant is returned as a fallback. +* +* It prioritizes: +* 1. A persisted theme variant from localStorage. +* 2. A system preference (`prefers-color-scheme`). +* 3. The configured default theme variant. +* +* @param {Object.|undefined} themeVariants - An object where the keys are theme variant +* names (e.g., "light", "dark") and the values are objects containing URLs for theme CSS files. +* @param {Object} [options.themeVariantDefaults={}] - An object containing default theme variant preferences. +* +* @returns {Object|undefined} The default theme variant, or `undefined` if no valid theme variant is found. +* +*/ +export const getDefaultThemeVariant = ({ themeVariants, themeVariantDefaults = {} }) => { + if (!themeVariants) { + return undefined; + } + + const themeVariantKeys = Object.keys(themeVariants); + + // If there is only one theme variant, return it since it's the only one that may be used. + if (themeVariantKeys.length === 1) { + const themeVariantKey = themeVariantKeys[0]; + return { + name: themeVariantKey, + metadata: themeVariants[themeVariantKey], + }; + } + + // Prioritize persisted localStorage theme variant preference. + const persistedSelectedParagonThemeVariant = localStorage.getItem(SELECTED_THEME_VARIANT_KEY); + if (persistedSelectedParagonThemeVariant && themeVariants[persistedSelectedParagonThemeVariant]) { + return { + name: persistedSelectedParagonThemeVariant, + metadata: themeVariants[persistedSelectedParagonThemeVariant], + }; + } + + // Then, detect system preference via `prefers-color-scheme` media query and use + // the default dark theme variant, if one exists. + const hasDarkSystemPreference = !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches; + const defaultDarkThemeVariant = themeVariantDefaults.dark; + const darkThemeVariantMetadata = themeVariants[defaultDarkThemeVariant]; + + if (hasDarkSystemPreference && defaultDarkThemeVariant && darkThemeVariantMetadata) { + return { + name: defaultDarkThemeVariant, + metadata: darkThemeVariantMetadata, + }; + } + + const defaultLightThemeVariant = themeVariantDefaults.light; + const lightThemeVariantMetadata = themeVariants[defaultLightThemeVariant]; + + // Handle edge case where the default light theme variant is not configured or provided. + if (!defaultLightThemeVariant || !lightThemeVariantMetadata) { + return undefined; + } + + // Otherwise, fallback to using the default light theme variant as configured. + return { + name: defaultLightThemeVariant, + metadata: lightThemeVariantMetadata, + }; +}; + +/** + * A custom React hook that manages the application's theme state and injects the appropriate CSS for the theme core + * and theme variants (e.g., light and dark modes) into the HTML document. It handles dynamically loading the theme + * CSS based on the current theme variant, and ensures that the theme variant's CSS is preloaded for runtime theme + * switching.This is done using "alternate" stylesheets. That is, the browser will download the CSS for the + * non-current theme variants with a lower priority than the current one. + * + * The hook also responds to system theme preference changes (e.g., via the `prefers-color-scheme` media query), + * and can automatically switch the theme based on the system's dark mode or light mode preference. + * + * @memberof module:React + * + * @returns {Array} - An array containing: + * 1. An object representing the current theme state. + * 2. A dispatch function to mutate the app theme state (e.g., change the theme variant). + * + * * @example + * const [themeState, dispatch] = useParagonTheme(); + * console.log(themeState.isThemeLoaded); // true when the theme has been successfully loaded. + * + * // Dispatch an action to change the theme variant + * dispatch(paragonThemeActions.setParagonThemeVariant('dark')); + */ +const useParagonTheme = () => { + const paragonThemeUrls = useParagonThemeUrls(); + const { + core: themeCore, + defaults: themeVariantDefaults, + variants: themeVariants, + } = paragonThemeUrls || {}; + const initialParagonThemeState = { + isThemeLoaded: false, + themeVariant: getDefaultThemeVariant({ themeVariants, themeVariantDefaults })?.name, + }; + const [themeState, dispatch] = useReducer(paragonThemeReducer, initialParagonThemeState); + + const [isCoreThemeLoaded, setIsCoreThemeLoaded] = useState(false); + const onLoadThemeCore = useCallback(() => { + setIsCoreThemeLoaded(true); + }, []); + + const [hasLoadedThemeVariants, setHasLoadedThemeVariants] = useState(false); + const onLoadThemeVariants = useCallback(() => { + setHasLoadedThemeVariants(true); + }, []); + + // load the core theme CSS + useParagonThemeCore({ + themeCore, + onComplete: onLoadThemeCore, + }); + + // respond to system preference changes with regard to `prefers-color-scheme: dark`. + const handleDarkModeSystemPreferenceChange = useCallback((prefersDarkMode) => { + // Ignore system preference change if the theme variant is already set in localStorage. + if (localStorage.getItem(SELECTED_THEME_VARIANT_KEY)) { + return; + } + + if (prefersDarkMode && themeVariantDefaults?.dark) { + dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.dark)); + } else if (!prefersDarkMode && themeVariantDefaults?.light) { + dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.light)); + } else { + logError(`Could not set theme variant based on system preference (prefers dark mode: ${prefersDarkMode})`, themeVariantDefaults, themeVariants); + } + }, [themeVariantDefaults, themeVariants]); + + // load the theme variant(s) CSS + useParagonThemeVariants({ + themeVariants, + onComplete: onLoadThemeVariants, + currentThemeVariant: themeState.themeVariant, + onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange, + }); + + useEffect(() => { + // theme is already loaded, do nothing + if (themeState.isThemeLoaded) { + return; + } + + const hasThemeConfig = (themeCore?.urls && !isEmptyObject(themeVariants)); + if (!hasThemeConfig) { + // no theme URLs to load, set loading to false. + dispatch(paragonThemeActions.setParagonThemeLoaded(true)); + } + + // Return early if neither the core theme CSS nor any theme variant CSS is loaded. + if (!isCoreThemeLoaded || !hasLoadedThemeVariants) { + return; + } + + // All application theme URLs are loaded + dispatch(paragonThemeActions.setParagonThemeLoaded(true)); + }, [ + themeState.isThemeLoaded, + isCoreThemeLoaded, + hasLoadedThemeVariants, + themeCore?.urls, + themeVariants, + ]); + + return [themeState, dispatch]; +}; + +export default useParagonTheme; diff --git a/src/react/hooks/paragon/useParagonTheme.test.js b/src/react/hooks/paragon/useParagonTheme.test.js new file mode 100644 index 000000000..327263183 --- /dev/null +++ b/src/react/hooks/paragon/useParagonTheme.test.js @@ -0,0 +1,177 @@ +import { act, renderHook } from '@testing-library/react'; + +import useParagonTheme from './useParagonTheme'; +import { getConfig } from '../../../config'; +import { logError } from '../../../logging'; + +jest.mock('../../../logging'); + +const PARAGON_THEME_URLS = { + core: { + urls: { + default: 'core.css', + }, + }, + defaults: { + light: 'light', + dark: 'dark', + }, + variants: { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@21.0.0/dist/light.min.css', + }, + }, + dark: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@21.0.0/dist/dark.min.css', + }, + }, + }, +}; + +jest.mock('../../../config', () => ({ + ...jest.requireActual('.../../../config'), + getConfig: jest.fn().mockReturnValue({ + PUBLIC_PATH: '/', + PARAGON_THEME_URLS, + }), +})); + +let mockMediaQueryListEvent; +const mockAddEventListener = jest.fn((dispatch, fn) => fn(mockMediaQueryListEvent)); +const mockRemoveEventListener = jest.fn(); + +Object.defineProperty(window, 'matchMedia', { + value: jest.fn(() => ({ + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + matches: mockMediaQueryListEvent.matches, + })), +}); + +Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn(), + }, +}); + +describe('useParagonTheme', () => { + beforeEach(() => { + document.head.innerHTML = ''; + mockMediaQueryListEvent = { matches: true }; + mockAddEventListener.mockClear(); + mockRemoveEventListener.mockClear(); + window.localStorage.getItem.mockClear(); + }); + + it.each([ + ['dark', 'stylesheet', 'alternate stylesheet', true], // preference is dark + ['light', 'alternate stylesheet', 'stylesheet', false], // preference is light + ])( + 'should configure theme variant for system preference %s and handle theme change events', + (initialPreference, expectedDarkRel, expectedLightRel, isDarkMediaMatch) => { + // Mock the matchMedia behavior to simulate system preference + mockMediaQueryListEvent = { matches: isDarkMediaMatch }; + // Set up the hook and initial theme configuration + const { result, unmount } = renderHook(() => useParagonTheme()); + const themeLinks = document.head.querySelectorAll('link'); + + const checkThemeLinks = () => { + const darkLink = document.head.querySelector('link[data-paragon-theme-variant="dark"]'); + const lightLink = document.head.querySelector('link[data-paragon-theme-variant="light"]'); + expect(darkLink.rel).toBe(expectedDarkRel); + expect(lightLink.rel).toBe(expectedLightRel); + }; + // Simulate initial theme configuration based on system preference + act(() => { themeLinks.forEach((link) => link.onload()); }); + + // Ensure matchMedia was called with the correct system preference + expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)'); + expect(mockAddEventListener).toHaveBeenCalled(); + + // Check initial theme setup + checkThemeLinks(); + expect(result.current[0]).toEqual({ + isThemeLoaded: true, + themeVariant: initialPreference, + }); + + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalled(); + }, + ); + it('should configure theme variants according with user preference if is defined (localStorage)', () => { + window.localStorage.getItem.mockReturnValue('light'); + const { result, unmount } = renderHook(() => useParagonTheme()); + const themeLinks = document.head.querySelectorAll('link'); + const darkLink = document.head.querySelector('link[data-paragon-theme-variant="dark"]'); + const lightLink = document.head.querySelector('link[data-paragon-theme-variant="light"]'); + + act(() => { themeLinks.forEach((link) => link.onload()); }); + + expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)'); + expect(mockAddEventListener).toHaveBeenCalled(); + + expect(darkLink.rel).toBe('alternate stylesheet'); + expect(lightLink.rel).toBe('stylesheet'); + expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'light' }); + + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalled(); + }); + it('should define the theme variant as default if only 1 is configured', () => { + getConfig.mockReturnValueOnce({ PUBLIC_PATH: '/', PARAGON_THEME_URLS: { ...PARAGON_THEME_URLS, variants: { light: PARAGON_THEME_URLS.variants.light } } }); + window.localStorage.getItem.mockReturnValue('light'); + const { result, unmount } = renderHook(() => useParagonTheme()); + const themeLinks = document.head.querySelectorAll('link'); + const themeVariantLinks = document.head.querySelectorAll('link[data-paragon-theme-variant]'); + + act(() => { themeLinks.forEach((link) => link.onload()); }); + + expect(themeVariantLinks.length).toBe(1); + expect(themeVariantLinks[0].rel).toBe('stylesheet'); + expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'light' }); + + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalled(); + }); + it('should not configure any theme if PARAGON_THEME_URLS is undefined', () => { + getConfig.mockReturnValueOnce({ PUBLIC_PATH: '/', PARAGON_THEME_URLS: undefined }); + const { result, unmount } = renderHook(() => useParagonTheme()); + const themeLinks = document.head.querySelectorAll('link'); + + expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: undefined }); + expect(themeLinks.length).toBe(0); + unmount(); + }); + it('should return themeVariant undefined if can not configure the default theme or fallback in the light theme', () => { + getConfig.mockReturnValueOnce({ PUBLIC_PATH: '/', PARAGON_THEME_URLS: { ...PARAGON_THEME_URLS, defaults: { red: 'red' }, variants: { light: PARAGON_THEME_URLS.variants.light, green: { urls: { default: 'green-url' } } } } }); + window.localStorage.getItem.mockReturnValue(); + + const { result, unmount } = renderHook(() => useParagonTheme()); + const themeLinks = document.head.querySelectorAll('link'); + const themeVariantLinks = document.head.querySelectorAll('link[data-paragon-theme-variant]'); + act(() => { themeLinks.forEach((link) => link.onload()); }); + + expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: undefined }); + expect(themeLinks.length).toBe(3); + themeVariantLinks.forEach(link => expect(link.rel).toBe('alternate stylesheet')); + unmount(); + }); + it('should log a error if can not configure the theme variant base on preference system', () => { + getConfig.mockReturnValueOnce({ PUBLIC_PATH: '/', PARAGON_THEME_URLS: { ...PARAGON_THEME_URLS, defaults: { dark: 'dark' }, variants: { light: PARAGON_THEME_URLS.variants.light, green: { urls: { default: 'green-url' } } } } }); + window.localStorage.getItem.mockReturnValue(); + + const { result, unmount } = renderHook(() => useParagonTheme()); + const themeLinks = document.head.querySelectorAll('link'); + const themeVariantLinks = document.head.querySelectorAll('link[data-paragon-theme-variant]'); + act(() => { themeLinks.forEach((link) => link.onload()); }); + + expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'dark' }); + expect(logError.mock.calls[0][0]).toBe('Could not set theme variant based on system preference (prefers dark mode: true)'); + expect(themeVariantLinks.length).toBe(2); + themeVariantLinks.forEach(link => expect(link.rel).toBe('alternate stylesheet')); + unmount(); + }); +}); diff --git a/src/react/hooks/paragon/useParagonThemeCore.js b/src/react/hooks/paragon/useParagonThemeCore.js new file mode 100644 index 000000000..4b75d747a --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeCore.js @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; + +import { logError, logInfo } from '../../../logging'; +import { fallbackThemeUrl, removeExistingLinks } from './utils'; + +/** + * Custom React hook that manages the loading and updating of the core Paragon theme CSS and the brand override + * theme CSS. It ensures that the core theme CSS (both default and brand override) is added to the document + * `` as `` elements. + * + * The function logs and handles fallback logic in case the core theme fails to load. + * + * @memberof module:React + * + * @param {Object} args - The arguments object containing theme and callback information. + * @param {Object} args.themeCore - The core theme configuration. + * @param {string} args.themeCore.urls.default - The URL to the default core theme CSS. + * @param {string} [args.themeCore.urls.brandOverride] - The URL to the brand override theme CSS (optional). + * @param {Function} args.onComplete - A callback function that is called once both the core Paragon (default) + * theme and brand override theme (if provided) are complete. + */ +const useParagonThemeCore = ({ + themeCore, + onComplete, +}) => { + const [isParagonThemeCoreComplete, setIsParagonThemeCoreComplete] = useState(false); + const [isBrandThemeCoreComplete, setIsBrandThemeCoreComplete] = useState(false); + + useEffect(() => { + // Call `onComplete` once both the paragon and brand theme core are complete. + if (isParagonThemeCoreComplete && isBrandThemeCoreComplete) { + onComplete(); + } + }, [isParagonThemeCoreComplete, isBrandThemeCoreComplete, onComplete]); + + useEffect(() => { + // If there is no config for the core theme url, do nothing. + if (!themeCore?.urls) { + setIsParagonThemeCoreComplete(true); + setIsBrandThemeCoreComplete(true); + return; + } + + const existingCoreThemeLink = document.head.querySelector(`link[href='${themeCore.urls.default}']`); + const brandCoreLink = document.head.querySelector(`link[href='${themeCore.urls.brandOverride}']`); + + if (existingCoreThemeLink) { + existingCoreThemeLink.rel = 'stylesheet'; + existingCoreThemeLink.removeAttribute('as'); + existingCoreThemeLink.dataset.paragonThemeCore = true; + if (brandCoreLink) { + brandCoreLink.rel = 'stylesheet'; + brandCoreLink.removeAttribute('as'); + brandCoreLink.dataset.brandThemeCore = true; + } + setIsParagonThemeCoreComplete(true); + setIsBrandThemeCoreComplete(true); + return; + } + + const getParagonThemeCoreLink = () => document.head.querySelector('link[data-paragon-theme-core="true"]'); + const getExistingCoreThemeLinks = (isBrandOverride) => { + const coreThemeLinkSelector = `link[data-${isBrandOverride ? 'brand' : 'paragon'}-theme-core="true"]`; + return document.head.querySelectorAll(coreThemeLinkSelector); + }; + + const createCoreThemeLink = ( + url, + { + isFallbackThemeUrl = false, + isBrandOverride = false, + } = {}, + ) => { + let coreThemeLink = document.createElement('link'); + coreThemeLink.href = url; + coreThemeLink.rel = 'stylesheet'; + if (isBrandOverride) { + coreThemeLink.dataset.brandThemeCore = true; + } else { + coreThemeLink.dataset.paragonThemeCore = true; + } + coreThemeLink.onload = () => { + if (isBrandOverride) { + setIsBrandThemeCoreComplete(true); + } else { + setIsParagonThemeCoreComplete(true); + } + }; + coreThemeLink.onerror = () => { + if (isFallbackThemeUrl) { + logError('Could not load core theme fallback URL. Aborting.'); + if (isBrandOverride) { + setIsBrandThemeCoreComplete(true); + } else { + setIsParagonThemeCoreComplete(true); + } + const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + return; + } + const paragonThemeAccessor = isBrandOverride ? 'brand' : 'paragon'; + const core = PARAGON_THEME?.[paragonThemeAccessor]?.themeUrls?.core ?? null; + if (core) { + const coreThemeFallbackUrl = fallbackThemeUrl(core.fileName); + logInfo(`Could not load core theme CSS from ${url}. Falling back to locally installed core theme CSS: ${coreThemeFallbackUrl}`); + coreThemeLink = createCoreThemeLink(coreThemeFallbackUrl, { isFallbackThemeUrl: true, isBrandOverride }); + const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + const foundParagonThemeCoreLink = getParagonThemeCoreLink(); + if (foundParagonThemeCoreLink) { + foundParagonThemeCoreLink.insertAdjacentElement( + 'afterend', + coreThemeLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + coreThemeLink, + ); + } + } else { + logError(`Failed to load core theme CSS from ${url} or fallback URL. Aborting.`); + } + }; + return coreThemeLink; + }; + + const paragonCoreThemeLink = createCoreThemeLink(themeCore.urls.default); + document.head.insertAdjacentElement( + 'afterbegin', + paragonCoreThemeLink, + ); + + if (themeCore.urls.brandOverride) { + const brandCoreThemeLink = createCoreThemeLink(themeCore.urls.brandOverride, { isBrandOverride: true }); + const foundParagonThemeCoreLink = getParagonThemeCoreLink(); + if (foundParagonThemeCoreLink) { + foundParagonThemeCoreLink.insertAdjacentElement( + 'afterend', + brandCoreThemeLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + brandCoreThemeLink, + ); + } + } else { + setIsBrandThemeCoreComplete(true); + } + }, [themeCore?.urls, onComplete]); +}; + +export default useParagonThemeCore; diff --git a/src/react/hooks/paragon/useParagonThemeCore.test.js b/src/react/hooks/paragon/useParagonThemeCore.test.js new file mode 100644 index 000000000..8d6ca5ad5 --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeCore.test.js @@ -0,0 +1,136 @@ +import { renderHook, act } from '@testing-library/react'; + +import { getConfig } from '../../../config'; +import { logError, logInfo } from '../../../logging'; + +import useParagonThemeCore from './useParagonThemeCore'; + +jest.mock('../../../logging'); + +describe('useParagonThemeCore', () => { + const themeOnComplete = jest.fn(); + let coreConfig; + + beforeEach(() => { + document.head.innerHTML = ''; + coreConfig = { + themeCore: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@21.0.0/dist/core.min.css', + }, + }, + onComplete: themeOnComplete, + }; + jest.clearAllMocks(); + }); + + it('should load the core url and change the loading state to true', () => { + renderHook(() => useParagonThemeCore(coreConfig)); + const createdLinkTag = document.head.querySelector('link'); + act(() => createdLinkTag.onload()); + expect(createdLinkTag.href).toBe(coreConfig.themeCore.urls.default); + expect(themeOnComplete).toHaveBeenCalledTimes(1); + }); + + it('should load the core default and brand url and change the loading state to true', () => { + coreConfig.themeCore.urls.brandOverride = 'https://cdn.jsdelivr.net/npm/@edx/brand@2.0.0/dist/core.min.css'; + + renderHook(() => useParagonThemeCore(coreConfig)); + const createdLinkTag = document.head.querySelector('link[data-paragon-theme-core="true"]'); + const createdBrandLinkTag = document.head.querySelector('link[data-brand-theme-core="true"]'); + + act(() => { createdLinkTag.onload(); createdBrandLinkTag.onload(); }); + expect(createdLinkTag.href).toBe(coreConfig.themeCore.urls.default); + expect(createdBrandLinkTag.href).toBe(coreConfig.themeCore.urls.brandOverride); + expect(themeOnComplete).toHaveBeenCalledTimes(1); + }); + + it('should dispatch a log error and fallback to PARAGON_THEME if can not load the core theme link (either default or brandOverride)', () => { + coreConfig.themeCore.urls.brandOverride = 'https://cdn.jsdelivr.net/npm/@edx/brand@2.0.0/dist/core.min.css'; + renderHook(() => useParagonThemeCore(coreConfig)); + + const createdLinkTag = document.head.querySelector('link[data-paragon-theme-core="true"]'); + const createdBrandLinkTag = document.head.querySelector('link[data-brand-theme-core="true"]'); + const defaultFallbackUrl = `${getConfig().BASE_URL}/${PARAGON_THEME.paragon.themeUrls.core.fileName}`; + const brandFallbackUrl = `${getConfig().BASE_URL}/${PARAGON_THEME.brand.themeUrls.core.fileName}`; + + act(() => { createdLinkTag.onerror(); createdBrandLinkTag.onerror(); }); + + const fallbackLinks = document.querySelectorAll('link'); + + expect(logInfo).toHaveBeenCalledTimes(2); + expect(logInfo).toHaveBeenCalledWith(`Could not load core theme CSS from ${coreConfig.themeCore.urls.default}. Falling back to locally installed core theme CSS: ${defaultFallbackUrl}`); + expect(logInfo).toHaveBeenCalledWith(`Could not load core theme CSS from ${coreConfig.themeCore.urls.brandOverride}. Falling back to locally installed core theme CSS: ${brandFallbackUrl}`); + expect(fallbackLinks[0].href).toBe(defaultFallbackUrl); + expect(fallbackLinks[1].href).toBe(brandFallbackUrl); + }); + it('should dispatch a log error if the fallback url is not loaded (either default or brandOverride)', () => { + coreConfig.themeCore.urls.brandOverride = 'https://cdn.jsdelivr.net/npm/@edx/brand@2.0.0/dist/core.min.css'; + + renderHook(() => useParagonThemeCore(coreConfig)); + const createdLinkTag = document.head.querySelector('link[data-paragon-theme-core="true"]'); + const createdBrandLinkTag = document.head.querySelector('link[data-brand-theme-core="true"]'); + const defaultFallbackUrl = `${getConfig().BASE_URL}/${PARAGON_THEME.paragon.themeUrls.core.fileName}`; + const brandFallbackUrl = `${getConfig().BASE_URL}/${PARAGON_THEME.brand.themeUrls.core.fileName}`; + + act(() => { createdLinkTag.onerror(); createdBrandLinkTag.onerror(); }); + const fallbackLinks = document.querySelectorAll('link'); + + expect(fallbackLinks[0].href).toBe(defaultFallbackUrl); + expect(fallbackLinks[1].href).toBe(brandFallbackUrl); + act(() => { fallbackLinks[0].onerror(); fallbackLinks[1].onerror(); }); + + expect(logInfo).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenCalledWith('Could not load core theme fallback URL. Aborting.'); + }); + it('should dispatch a log error if can not load the core theme and the fallback url is not configured', () => { + const originalParagonTheme = global.PARAGON_THEME; + Object.defineProperty(global, 'PARAGON_THEME', { + value: 'mocked-theme', + writable: true, + }); + renderHook(() => useParagonThemeCore(coreConfig)); + const createdLinkTag = document.head.querySelector('link[data-paragon-theme-core="true"]'); + act(() => { createdLinkTag.onerror(); }); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith(`Failed to load core theme CSS from ${coreConfig.themeCore.urls.default} or fallback URL. Aborting.`); + + // Restores the original PARAGON_THEME + Object.defineProperty(global, 'PARAGON_THEME', { + value: originalParagonTheme, + writable: false, + }); + }); + + it('should not create a new link if the core theme is already loaded (either default or brandOverride)', () => { + coreConfig.themeCore.urls.brandOverride = 'https://cdn.jsdelivr.net/npm/@edx/brand@2.0.0/dist/core.min.css'; + + document.head.innerHTML = ` + `; + + renderHook(() => useParagonThemeCore(coreConfig)); + + const createdLinkTags = document.head.querySelectorAll('link'); + + expect(createdLinkTags.length).toBe(2); + expect(createdLinkTags[0].rel).toContain('stylesheet'); + expect(createdLinkTags[0]).not.toHaveAttribute('as', 'style'); + expect(createdLinkTags[1].rel).toContain('stylesheet'); + expect(createdLinkTags[1].href).toBe(coreConfig.themeCore.urls.brandOverride); + expect(createdLinkTags[1]).not.toHaveAttribute('as', 'style'); + }); + + it('should not create any core link if can not find themeCore urls definition', () => { + coreConfig = { + themeCore: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@21.0.0/dist/core.min.css', + }, + onComplete: themeOnComplete, + }; + + renderHook(() => useParagonThemeCore(coreConfig)); + expect(document.head.querySelectorAll('link').length).toBe(0); + expect(themeOnComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/react/hooks/paragon/useParagonThemeUrls.js b/src/react/hooks/paragon/useParagonThemeUrls.js new file mode 100644 index 000000000..89f0feabe --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeUrls.js @@ -0,0 +1,137 @@ +import { useMemo } from 'react'; + +import { fallbackThemeUrl, isEmptyObject } from './utils'; +import { getConfig } from '../../../config'; + +/** + * Replaces a wildcard in the URL string with a provided local version string. + * This is typically used to substitute a version placeholder (e.g., `$paragonVersion`) + * in URLs with actual version values. + * + * @param {Object} args - The arguments object for version substitution. + * @param {string} args.url - The URL string that may contain a wildcard keyword (e.g., `$paragonVersion`). + * @param {string} args.wildcardKeyword - The keyword (e.g., `$paragonVersion`) in the URL to be replaced + * with the local version. + * @param {string} args.localVersion - The local version string to replace the wildcard with. + * + * @returns {string} The URL with the wildcard keyword replaced by the provided version string. + * If the conditions are not met (e.g., missing URL or version), the original URL is returned. + * + * @example + * const url = 'https://cdn.example.com/$paragonVersion/theme.css'; + * const version = '1.0.0'; + * const updatedUrl = handleVersionSubstitution({ url, wildcardKeyword: '$paragonVersion', localVersion: version }); + * console.log(updatedUrl); // Outputs: 'https://cdn.example.com/1.0.0/theme.css' + */ +export const handleVersionSubstitution = ({ url, wildcardKeyword, localVersion }) => { + if (!url || !url.includes(wildcardKeyword) || !localVersion) { + return url; + } + return url.replaceAll(wildcardKeyword, localVersion); +}; + +/** + * Custom React hook that retrieves the Paragon theme URLs, including the core theme CSS and any theme variants. + * It supports version substitution for the Paragon and brand versions and returns a structured object containing + * the URLs. The hook also handles fallback scenarios when the URLs are unavailable in the configuration or when + * version substitution is required. + * + * @returns {Object|undefined} An object containing: + * - `core`: The core theme URLs (including default and brand override). + * - `defaults`: Any default theme variants. + * - `variants`: The URLs for any additional theme variants (default and brand override). + * + * If the required URLs are not available or cannot be determined, `undefined` is returned. + * + * @example + * const themeUrls = useParagonThemeUrls(); + * if (themeUrls) { + * console.log(themeUrls.core.urls.default); // Outputs the URL of the core theme CSS + * console.log(themeUrls.variants['dark'].urls.default); // Outputs the URL of the dark theme variant CSS + * } + * + */ +const useParagonThemeUrls = () => useMemo(() => { + const { PARAGON_THEME_URLS: paragonThemeUrls } = getConfig(); + if (!paragonThemeUrls) { + return undefined; + } + + const paragonCoreCssUrl = typeof paragonThemeUrls?.core?.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls?.core?.url; + const brandCoreCssUrl = typeof paragonThemeUrls?.core?.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined; + const defaultThemeVariants = paragonThemeUrls.defaults; + + // Local versions of @openedx/paragon and @edx/brand + // these are only used when passed into handleVersionSubstitution + // which does not attempt substitution using falsy value + const localParagonVersion = PARAGON_THEME?.paragon?.version; + const localBrandVersion = PARAGON_THEME?.brand?.version; + + const coreCss = { + default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: localParagonVersion }), + brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: localBrandVersion }), + }; + const themeVariantsCss = {}; + const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {}); + themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => { + const themeVariantMetadata = { urls: null }; + if (url) { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url, + wildcardKeyword: '$paragonVersion', + localVersion: localParagonVersion, + }), + }; + } else { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url: urls.default, + wildcardKeyword: '$paragonVersion', + localVersion: localParagonVersion, + }), + brandOverride: handleVersionSubstitution({ + url: urls.brandOverride, + wildcardKeyword: '$brandVersion', + localVersion: localBrandVersion, + }), + }; + } + themeVariantsCss[themeVariant] = themeVariantMetadata; + }); + + // If we don't have the core default or any theme variants, use the PARAGON_THEME + if (!coreCss.default || isEmptyObject(themeVariantsCss) || isEmptyObject(defaultThemeVariants)) { + const localCoreUrl = PARAGON_THEME.paragon?.themeUrls?.core; + const localThemeVariants = PARAGON_THEME.paragon?.themeUrls?.variants; + const localDefaultThemeVariants = PARAGON_THEME.paragon?.themeUrls?.defaults; + + if (isEmptyObject(localCoreUrl) || isEmptyObject(localThemeVariants)) { + return undefined; + } + if (!coreCss.default) { + coreCss.default = fallbackThemeUrl(localCoreUrl?.fileName); + } + + if (isEmptyObject(themeVariantsCss)) { + Object.entries(localThemeVariants).forEach(([themeVariant, { fileName, ...rest }]) => { + themeVariantsCss[themeVariant] = { + urls: { default: fallbackThemeUrl(fileName), ...rest.urls }, + }; + }); + } + return { + core: { urls: coreCss }, + defaults: defaultThemeVariants || localDefaultThemeVariants, + variants: themeVariantsCss, + }; + } + + return { + core: { urls: coreCss }, + defaults: defaultThemeVariants, + variants: themeVariantsCss, + }; +}, []); + +export default useParagonThemeUrls; diff --git a/src/react/hooks/paragon/useParagonThemeUrls.test.js b/src/react/hooks/paragon/useParagonThemeUrls.test.js new file mode 100644 index 000000000..44c942655 --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeUrls.test.js @@ -0,0 +1,217 @@ +import { renderHook } from '@testing-library/react'; + +import useParagonThemeUrls from './useParagonThemeUrls'; +import { mergeConfig } from '../../../config'; + +describe('useParagonThemeUrls', () => { + beforeEach(() => { jest.resetAllMocks(); }); + it.each([ + [undefined, undefined], + [{}, { core: { urls: { default: 'localhost:8080/core.min.css', brandOverride: undefined } }, defaults: { light: 'light' }, variants: { light: { urls: { default: 'localhost:8080/light.min.css' } } } }], + ])('handles when `config.PARAGON_THEME_URLS` is not present (%s)', (paragonThemeUrls, expectedURLConfig) => { + mergeConfig({ PARAGON_THEME_URLS: paragonThemeUrls }); + const { result } = renderHook(() => useParagonThemeUrls()); + expect(result.current).toEqual(expectedURLConfig); + }); + + describe('when `config.PARAGON_THEME_URLS` is present', () => { + it('returns expected object when configuration is valid (only Paragon)', () => { + const config = { + PARAGON_THEME_URLS: { + core: { + url: 'core.css', + }, + defaults: { + light: 'light', + }, + variants: { + light: { + url: 'light.css', + }, + }, + }, + }; + mergeConfig(config); + const { result } = renderHook(() => useParagonThemeUrls(config)); + expect(result.current).toEqual( + expect.objectContaining({ + core: { + urls: { + default: 'core.css', + brandOverride: undefined, + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'light.css', + brandOverride: undefined, + }, + }, + }, + }), + ); + }); + + it('returns expected object when configuration is valid (both Paragon + brand)', () => { + const config = { + PARAGON_THEME_URLS: { + core: { + urls: { + default: 'core.css', + brandOverride: 'brand-core.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'light.css', + brandOverride: 'brand-light.css', + }, + }, + }, + }, + }; + mergeConfig(config); + const { result } = renderHook(() => useParagonThemeUrls(config)); + expect(result.current).toEqual( + expect.objectContaining({ + core: { + urls: { + default: 'core.css', + brandOverride: 'brand-core.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'light.css', + brandOverride: 'brand-light.css', + }, + }, + }, + }), + ); + }); + it('returns expected object when core default and variants are not present, fallback to PARAGON_THEME', () => { + const config = { + PARAGON_THEME_URLS: { + core: { + urls: { + brandOverride: 'brand-core.css', + }, + }, + defaults: { + light: 'light', + }, + variants: {}, + }, + }; + mergeConfig(config); + const { result } = renderHook(() => useParagonThemeUrls()); + expect(result.current).toEqual( + expect.objectContaining({ + core: { + urls: { + default: 'localhost:8080/core.min.css', + brandOverride: 'brand-core.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'localhost:8080/light.min.css', + }, + }, + }, + }), + ); + }); + it('returns expected undefined when core default and variants are not present and can not fallback to PARAGON_THEME', () => { + const config = { + PARAGON_THEME_URLS: { + core: { + urls: { + brandOverride: 'brand-core.css', + }, + }, + defaults: { + light: 'light', + }, + variants: {}, + }, + }; + const originalParagonTheme = global.PARAGON_THEME; + Object.defineProperty(global, 'PARAGON_THEME', { + value: 'mocked-theme', + writable: true, + }); + mergeConfig(config); + const { result } = renderHook(() => useParagonThemeUrls()); + expect(result.current).toBe(undefined); + // Restores the original PARAGON_THEME + Object.defineProperty(global, 'PARAGON_THEME', { + value: originalParagonTheme, + writable: false, + }); + }); + it('manage substitucion of keywords in the url with the local installed version', () => { + const config = { + PARAGON_THEME_URLS: { + core: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$brandVersion/dist/core.min.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$brandVersion/dist/light.min.css', + }, + }, + }, + }, + }; + mergeConfig(config); + const { result } = renderHook(() => useParagonThemeUrls(config)); + expect(result.current).toEqual( + expect.objectContaining({ + core: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@1.0.0/dist/core.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@1.0.0/dist/core.min.css', + }, + }, + defaults: { + light: 'light', + }, + variants: { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@1.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@1.0.0/dist/light.min.css', + }, + }, + }, + }), + ); + }); + }); +}); diff --git a/src/react/hooks/paragon/useParagonThemeVariants.js b/src/react/hooks/paragon/useParagonThemeVariants.js new file mode 100644 index 000000000..18e15c306 --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeVariants.js @@ -0,0 +1,221 @@ +import { useEffect, useState } from 'react'; + +import { logError, logInfo } from '../../../logging'; + +import { fallbackThemeUrl, removeExistingLinks } from './utils'; + +/** + * A custom React hook that manages the loading of theme variant CSS files dynamically. + * Adds/updates a `` element in the HTML document to load each theme variant's CSS, setting the + * non-current theme variants as "alternate" stylesheets. That is, the browser will download + * the CSS for the non-current theme variants, but at a lower priority than the current one. + * This ensures that if the theme variant is changed at runtime, the new theme's CSS will already be loaded. + * + * The hook also listens for changes in the system's preference and triggers the provided callback accordingly. + * + * @memberof module:React + * @param {object} args Configuration object for theme variants and related settings. + * @param {object} [args.themeVariants] An object containing the URLs for each supported theme variant, + * e.g.: `{ light: { url: 'https://path/to/light.css' } }`. + * @param {string} [args.currentThemeVariant] The currently applied theme variant, e.g.: `light`. + * @param {function} args.onComplete A callback function called when the theme variant(s) CSS is (are) complete. + * @param {function} [args.onDarkModeSystemPreferenceChange] A callback function that is triggered + * when the system's preference changes. + */ +const useParagonThemeVariants = ({ + themeVariants, + currentThemeVariant, + onComplete, + onDarkModeSystemPreferenceChange, +}) => { + const [isParagonThemeVariantComplete, setIsParagonThemeVariantComplete] = useState(false); + const [isBrandThemeVariantComplete, setIsBrandThemeVariantComplete] = useState(false); + + // Effect hook that listens for changes in the system's dark mode preference. + useEffect(() => { + const changeColorScheme = (colorSchemeQuery) => { + onDarkModeSystemPreferenceChange(colorSchemeQuery.matches); + }; + const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); + if (colorSchemeQuery) { + colorSchemeQuery.addEventListener('change', changeColorScheme); + } + return () => { + if (colorSchemeQuery) { + colorSchemeQuery.removeEventListener('change', changeColorScheme); + } + }; + }, [onDarkModeSystemPreferenceChange]); + + // Effect hook to set the theme current variant on the HTML element. + useEffect(() => { + if (currentThemeVariant && themeVariants?.[currentThemeVariant]) { + const htmlDataThemeVariantAttr = 'data-paragon-theme-variant'; + document.querySelector('html').setAttribute(htmlDataThemeVariantAttr, currentThemeVariant); + return () => { + document.querySelector('html').removeAttribute(htmlDataThemeVariantAttr); + }; + } + return () => {}; // Cleanup: no action needed when theme variant is not set + }, [themeVariants, currentThemeVariant]); + + // Effect hook that calls `onComplete` when both paragon and brand theme variants are completed the processing. + useEffect(() => { + if (isParagonThemeVariantComplete && isBrandThemeVariantComplete) { + onComplete(); + } + }, [isParagonThemeVariantComplete, isBrandThemeVariantComplete, onComplete]); + + useEffect(() => { + if (!themeVariants) { + return; + } + + /** + * Determines the value for the `rel` attribute for a given theme variant based + * on if its the currently applied variant. + * + * @param {string} themeVariant The key representing a theme variant (e.g., `light`, `dark`). + * @returns {string} The value for the `rel` attribute, either 'stylesheet' or 'alternate stylesheet'. + */ + const generateStylesheetRelAttr = (themeVariant) => (currentThemeVariant === themeVariant ? 'stylesheet' : 'alternate stylesheet'); + + // Iterate over each theme variant URL and inject it into the HTML document, if it doesn't already exist. + Object.entries(themeVariants).forEach(([themeVariant, value]) => { + // If there is no config for the theme variant URL, set the theme variant to complete and continue. + if (!value.urls) { + setIsParagonThemeVariantComplete(true); + setIsBrandThemeVariantComplete(true); + return; + } + const getParagonThemeVariantLink = () => document.head.querySelector(`link[data-paragon-theme-variant='${themeVariant}']`); + const existingThemeVariantLink = document.head.querySelector(`link[href='${value.urls.default}']`); + const existingThemeVariantBrandLink = document.head.querySelector(`link[href='${value.urls.brandOverride}']`); + + const getExistingThemeVariantLinks = (isBrandOverride) => { + const themeVariantLinkSelector = `link[data-${isBrandOverride ? 'brand' : 'paragon'}-theme-variant='${themeVariant}']`; + return document.head.querySelectorAll(themeVariantLinkSelector); + }; + + const createThemeVariantLink = ( + url, + { + isFallbackThemeUrl = false, + isBrandOverride = false, + } = {}, + ) => { + let themeVariantLink = document.createElement('link'); + themeVariantLink.href = url; + themeVariantLink.rel = generateStylesheetRelAttr(themeVariant); + if (isBrandOverride) { + themeVariantLink.dataset.brandThemeVariant = themeVariant; + } else { + themeVariantLink.dataset.paragonThemeVariant = themeVariant; + } + + themeVariantLink.onload = () => { + if (themeVariant === currentThemeVariant) { + if (isBrandOverride) { + setIsBrandThemeVariantComplete(true); + } else { + setIsParagonThemeVariantComplete(true); + } + } + }; + + themeVariantLink.onerror = () => { + const paragonThemeAccessor = isBrandOverride ? 'brand' : 'paragon'; + if (isFallbackThemeUrl) { + logError(`Could not load theme variant (${paragonThemeAccessor} - ${themeVariant}) CSS from fallback URL. Aborting.`); + if (isBrandOverride) { + setIsBrandThemeVariantComplete(true); + } else { + setIsParagonThemeVariantComplete(true); + } + const otherExistingLinks = getExistingThemeVariantLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + return; + } + const variants = PARAGON_THEME?.[paragonThemeAccessor]?.themeUrls?.variants ?? {}; + if (variants[themeVariant]) { + const themeVariantFallbackUrl = fallbackThemeUrl(variants[themeVariant].fileName); + logInfo(`Failed to load theme variant (${themeVariant}) CSS from ${isBrandOverride ? value.urls.brandOverride : value.urls.default}. Falling back to locally installed theme variant: ${themeVariantFallbackUrl}`); + themeVariantLink = createThemeVariantLink(themeVariantFallbackUrl, { + isFallbackThemeUrl: true, + isBrandOverride, + }); + const otherExistingLinks = getExistingThemeVariantLinks(isBrandOverride); + removeExistingLinks(otherExistingLinks); + const foundParagonThemeVariantLink = getParagonThemeVariantLink(); + if (foundParagonThemeVariantLink) { + foundParagonThemeVariantLink.insertAdjacentElement( + 'afterend', + themeVariantLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + themeVariantLink, + ); + } + } else { + logError(`Failed to load theme variant (${themeVariant}) CSS from ${url} and locally installed fallback URL is not available. Aborting.`); + if (isBrandOverride) { + setIsBrandThemeVariantComplete(true); + } else { + setIsParagonThemeVariantComplete(true); + } + } + }; + return themeVariantLink; + }; + + const insertBrandThemeVariantLink = () => { + const updatedStylesheetRel = generateStylesheetRelAttr(themeVariant); + + if (existingThemeVariantBrandLink) { + existingThemeVariantBrandLink.rel = updatedStylesheetRel; + existingThemeVariantBrandLink.removeAttribute('as'); + existingThemeVariantBrandLink.dataset.brandThemeVariant = themeVariant; + return; + } + + if (value.urls.brandOverride) { + const brandThemeVariantLink = createThemeVariantLink(value.urls.brandOverride, { isBrandOverride: true }); + const foundParagonThemeVariantLink = getParagonThemeVariantLink(); + if (foundParagonThemeVariantLink) { + foundParagonThemeVariantLink.insertAdjacentElement( + 'afterend', + brandThemeVariantLink, + ); + } else { + document.head.insertAdjacentElement( + 'afterbegin', + brandThemeVariantLink, + ); + } + } + setIsBrandThemeVariantComplete(true); + }; + + if (!existingThemeVariantLink) { + const paragonThemeVariantLink = createThemeVariantLink(value.urls.default); + document.head.insertAdjacentElement( + 'afterbegin', + paragonThemeVariantLink, + ); + insertBrandThemeVariantLink(existingThemeVariantBrandLink); + } else { + const updatedStylesheetRel = generateStylesheetRelAttr(themeVariant); + existingThemeVariantLink.rel = updatedStylesheetRel; + existingThemeVariantLink.removeAttribute('as'); + existingThemeVariantLink.dataset.paragonThemeVariant = themeVariant; + insertBrandThemeVariantLink(existingThemeVariantBrandLink); + } + setIsParagonThemeVariantComplete(true); + setIsBrandThemeVariantComplete(true); + }); + }, [themeVariants, currentThemeVariant, onComplete]); +}; + +export default useParagonThemeVariants; diff --git a/src/react/hooks/paragon/useParagonThemeVariants.test.js b/src/react/hooks/paragon/useParagonThemeVariants.test.js new file mode 100644 index 000000000..940d0a849 --- /dev/null +++ b/src/react/hooks/paragon/useParagonThemeVariants.test.js @@ -0,0 +1,185 @@ +import { act, renderHook } from '@testing-library/react'; + +import { getConfig } from '../../../config'; +import { logError, logInfo } from '../../../logging'; + +import useParagonThemeVariants from './useParagonThemeVariants'; + +jest.mock('../../../logging'); + +describe('useParagonThemeVariants', () => { + const themeOnComplete = jest.fn(); + + afterEach(() => { + document.head.innerHTML = ''; + jest.clearAllMocks(); + }); + + it('should create the links tags for each theme variant and change the state to true when all variants are loaded', () => { + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/light.min.css', + }, + }, + dark: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/dark.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/dark.min.css', + }, + }, + }; + const currentThemeVariant = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentThemeVariant, onComplete: themeOnComplete })); + const themeLinks = document.head.querySelectorAll('link'); + act(() => { themeLinks.forEach((link) => link.onload()); }); + + expect(themeLinks.length).toBe(4); + }); + + it('should dispatch a log error and fallback to PARAGON_THEME if can not load the variant theme link', () => { + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/light.min.css', + }, + }, + }; + const currentThemeVariant = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentThemeVariant, onComplete: themeOnComplete })); + + const themeLinks = document.head.querySelectorAll('link'); + const paragonFallbackURL = `${getConfig().BASE_URL}/${PARAGON_THEME.paragon.themeUrls.variants.light.fileName}`; + const brandFallbackURL = `${getConfig().BASE_URL}/${PARAGON_THEME.brand.themeUrls.variants.light.fileName}`; + + act(() => { themeLinks.forEach((link) => link.onerror()); }); + + expect(logInfo).toHaveBeenCalledTimes(2); + expect(logInfo).toHaveBeenCalledWith(`Failed to load theme variant (${currentThemeVariant}) CSS from ${themeVariants.light.urls.default}. Falling back to locally installed theme variant: ${paragonFallbackURL}`); + expect(logInfo).toHaveBeenCalledWith(`Failed to load theme variant (${currentThemeVariant}) CSS from ${themeVariants.light.urls.brandOverride}. Falling back to locally installed theme variant: ${brandFallbackURL}`); + + const fallbackLinkTag = document.querySelectorAll('link'); + + expect(fallbackLinkTag.length).toBe(2); + expect(fallbackLinkTag[0].href).toBe(paragonFallbackURL); + expect(fallbackLinkTag[1].href).toBe(brandFallbackURL); + }); + + it('should dispatch a log error if the fallback url is not loaded (either default or brandOverride)', () => { + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/light.min.css', + }, + }, + }; + + const currentThemeVariant = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentThemeVariant, onComplete: themeOnComplete })); + const themeLinks = document.head.querySelectorAll('link'); + const paragonFallbackURL = `${getConfig().BASE_URL}/${PARAGON_THEME.paragon.themeUrls.variants.light.fileName}`; + const brandFallbackURL = `${getConfig().BASE_URL}/${PARAGON_THEME.brand.themeUrls.variants.light.fileName}`; + + act(() => { themeLinks.forEach((link) => link.onerror()); }); + + const fallbackLinks = document.querySelectorAll('link'); + expect(fallbackLinks[0].href).toBe(paragonFallbackURL); + expect(fallbackLinks[1].href).toBe(brandFallbackURL); + act(() => { fallbackLinks.forEach((link) => link.onerror()); }); + + expect(logInfo).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenCalledWith('Could not load theme variant (paragon - light) CSS from fallback URL. Aborting.'); + expect(logError).toHaveBeenCalledWith('Could not load theme variant (brand - light) CSS from fallback URL. Aborting.'); + }); + it('should dispatch a log error if can not load the theme variant and the fallback url is not configured', () => { + const originalParagonTheme = global.PARAGON_THEME; + Object.defineProperty(global, 'PARAGON_THEME', { + value: 'mocked-theme', + writable: true, + }); + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/light.min.css', + }, + }, + }; + + const currentThemeVariant = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentThemeVariant, onComplete: themeOnComplete })); + const themeLinks = document.head.querySelectorAll('link'); + act(() => { themeLinks.forEach((link) => link.onerror()); }); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenCalledWith(`Failed to load theme variant (${currentThemeVariant}) CSS from ${themeVariants.light.urls.default} and locally installed fallback URL is not available. Aborting.`); + + // Restores the original PARAGON_THEME + Object.defineProperty(global, 'PARAGON_THEME', { + value: originalParagonTheme, + writable: false, + }); + }); + + it('should do nothing if themeVariants is not configured', () => { + const themeVariants = null; + const currentTheme = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentTheme, onComplete: themeOnComplete })); + expect(document.head.querySelectorAll('link').length).toBe(0); + }); + + it('should not create any variant link if can not find themeVariant urls definition', () => { + const themeVariants = { + light: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + }, + dark: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/dark.min.css', + }, + }; + + const currentTheme = 'light'; + + renderHook(() => useParagonThemeVariants({ themeVariants, currentTheme, onComplete: themeOnComplete })); + + expect(document.head.querySelectorAll('link').length).toBe(0); + }); + it('shoud not create a new link if it already exists', () => { + document.head.innerHTML = ` + `; + + const themeVariants = { + light: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/light.min.css', + brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand@$2.0.0/dist/light.min.css', + }, + }, + dark: { + urls: { + default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$21.0.0/dist/dark.min.css', + }, + }, + }; + + const currentTheme = 'light'; + renderHook(() => useParagonThemeVariants({ themeVariants, currentTheme, onComplete: themeOnComplete })); + const themeLinks = document.head.querySelectorAll('link'); + const lightThemeLink = document.head.querySelector('link[data-paragon-theme-variant="light"]'); + const lightThemeBrandLink = document.head.querySelector('link[data-brand-theme-variant="light"]'); + + expect(themeLinks.length).toBe(3); + expect(lightThemeLink.rel).toContain('stylesheet'); + expect(lightThemeLink).not.toHaveAttribute('as', 'style'); + expect(lightThemeBrandLink.rel).toContain('stylesheet'); + expect(lightThemeBrandLink).not.toHaveAttribute('as', 'style'); + }); +}); diff --git a/src/react/hooks/paragon/useTrackColorSchemeChoice.js b/src/react/hooks/paragon/useTrackColorSchemeChoice.js new file mode 100644 index 000000000..1b3d63f12 --- /dev/null +++ b/src/react/hooks/paragon/useTrackColorSchemeChoice.js @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; + +import { sendTrackEvent } from '../../../analytics'; + +/** + * A custom React hook that listens for changes in the system's color scheme preference (via `matchMedia`) + * and sends an event with the chosen color scheme (either `light` or `dark`) to the provided tracking service. + * It sends an event both when the hook is first initialized (to capture the user's initial preference) + * and when the system's color scheme preference changes. + * + * @memberof module:React + */ +const useTrackColorSchemeChoice = () => { + useEffect(() => { + const trackColorSchemeChoice = ({ matches }) => { + const preferredColorScheme = matches ? 'dark' : 'light'; + sendTrackEvent('openedx.ui.frontend-platform.prefers-color-scheme.selected', { preferredColorScheme }); + }; + const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); + if (colorSchemeQuery) { + // send user's initial choice + trackColorSchemeChoice(colorSchemeQuery); + colorSchemeQuery.addEventListener('change', trackColorSchemeChoice); + } + return () => { + if (colorSchemeQuery) { + colorSchemeQuery.removeEventListener('change', trackColorSchemeChoice); + } + }; + }, []); +}; + +export default useTrackColorSchemeChoice; diff --git a/src/react/hooks.test.jsx b/src/react/hooks/paragon/useTrackColorSchemeChoice.test.js similarity index 84% rename from src/react/hooks.test.jsx rename to src/react/hooks/paragon/useTrackColorSchemeChoice.test.js index 8fa48fb34..6e11f7807 100644 --- a/src/react/hooks.test.jsx +++ b/src/react/hooks/paragon/useTrackColorSchemeChoice.test.js @@ -1,8 +1,11 @@ import { renderHook } from '@testing-library/react'; -import { useTrackColorSchemeChoice } from './hooks'; -import { sendTrackEvent } from '../analytics'; +import { sendTrackEvent } from '../../../analytics'; +import useTrackColorSchemeChoice from './useTrackColorSchemeChoice'; -jest.mock('../analytics'); +jest.mock('../../../analytics', () => ({ + ...jest.requireActual('../../../analytics'), + sendTrackEvent: jest.fn(), +})); const mockAddEventListener = jest.fn(); const mockRemoveEventListener = jest.fn(); @@ -16,7 +19,7 @@ Object.defineProperty(window, 'matchMedia', { })), }); -describe('useTrackColorSchemeChoice hook', () => { +describe('useTrackColorSchemeChoice', () => { afterEach(() => { mockAddEventListener.mockClear(); mockRemoveEventListener.mockClear(); diff --git a/src/react/hooks/paragon/utils.js b/src/react/hooks/paragon/utils.js new file mode 100644 index 000000000..eb41474b6 --- /dev/null +++ b/src/react/hooks/paragon/utils.js @@ -0,0 +1,24 @@ +import { getConfig } from '../../../config'; +import { basename } from '../../../initialize'; + +/** + * Iterates through each given `` element and removes it from the DOM. + * @param {HTMLLinkElement[]} existingLinks + */ +export const removeExistingLinks = (existingLinks) => { + existingLinks.forEach((link) => { + link.remove(); + }); +}; + +/** +* Creates the fallback URL for the given theme file. +* @param {string} url The theme file path. +* @returns {string} The default theme url. +*/ +export const fallbackThemeUrl = (url) => { + const baseUrl = getConfig().BASE_URL || window.location?.origin; + return `${baseUrl}${basename}${url}`; +}; + +export const isEmptyObject = (obj) => !obj || Object.keys(obj).length === 0; diff --git a/src/react/hooks/useAppEvent.js b/src/react/hooks/useAppEvent.js new file mode 100644 index 000000000..6bb08c54b --- /dev/null +++ b/src/react/hooks/useAppEvent.js @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +import { subscribe, unsubscribe } from '../../pubSub'; + +/** + * A React hook that allows functional components to subscribe to application events. This should + * be used sparingly - for the most part, Context should be used higher-up in the application to + * provide necessary data to a given component, rather than utilizing a non-React-like Pub/Sub + * mechanism. + * + * @memberof module:React + * + * @param {string} type + * @param {function} callback + */ +const useAppEvent = (type, callback) => { + useEffect(() => { + const subscriptionToken = subscribe(type, callback); + + return () => unsubscribe(subscriptionToken); + }, [callback, type]); +}; + +export default useAppEvent; diff --git a/src/react/reducers.js b/src/react/reducers.js new file mode 100644 index 000000000..d10a98501 --- /dev/null +++ b/src/react/reducers.js @@ -0,0 +1,40 @@ +import { + SET_THEME_VARIANT, + SET_IS_THEME_LOADED, +} from './constants'; + +export function paragonThemeReducer(state, action) { + switch (action.type) { + case SET_THEME_VARIANT: { + const requestedThemeVariant = action.payload; + return { + ...state, + themeVariant: requestedThemeVariant, + }; + } + case SET_IS_THEME_LOADED: { + const requestedIsThemeLoaded = action.payload; + return { + ...state, + isThemeLoaded: requestedIsThemeLoaded, + }; + } + default: + return state; + } +} + +const setParagonThemeVariant = (payload) => ({ + type: SET_THEME_VARIANT, + payload, +}); + +const setParagonThemeLoaded = (payload) => ({ + type: SET_IS_THEME_LOADED, + payload, +}); + +export const paragonThemeActions = { + setParagonThemeVariant, + setParagonThemeLoaded, +};