diff --git a/addon-registry.js b/addon-registry.js index 666c0bcd04..b502977ee8 100644 --- a/addon-registry.js +++ b/addon-registry.js @@ -130,6 +130,11 @@ class AddonConfigurationRegistry { this.packages = {}; this.customizations = new Map(); + // Theme from a package.json key, from volto.config.js or from an ENV VAR + // Programatically via volto.config.js wins or the ENV VAR if present + this.theme = + packageJson.theme || this.voltoConfigJS.theme || process.env.THEME; + this.initDevelopmentPackages(); this.initPublishedPackages(); this.initAddonsFromEnvVar(); @@ -354,6 +359,35 @@ class AddonConfigurationRegistry { .filter((e) => e); } + getCustomThemeAddons() { + const customThemeAddonsInfo = { + variables: [], + main: [], + }; + + this.getAddonDependencies().forEach((addon) => { + const normalizedAddonName = addon.split(':')[0]; + // We have two possible insertion points, variables and main + + const customThemeVariables = `${this.packages[normalizedAddonName].modulePath}/theme/_variables.scss`; + const customThemeMain = `${this.packages[normalizedAddonName].modulePath}/theme/_main.scss`; + if ( + fs.existsSync(customThemeVariables) && + normalizedAddonName !== this.theme + ) { + customThemeAddonsInfo.variables.push(normalizedAddonName); + } + if ( + fs.existsSync(customThemeMain) && + normalizedAddonName !== this.theme + ) { + customThemeAddonsInfo.main.push(normalizedAddonName); + } + }); + + return customThemeAddonsInfo; + } + /** * Returns a mapping name:diskpath to be uses in webpack's resolve aliases */ diff --git a/create-theme-addons-loader.js b/create-theme-addons-loader.js new file mode 100644 index 0000000000..41da132b44 --- /dev/null +++ b/create-theme-addons-loader.js @@ -0,0 +1,79 @@ +const path = require('path'); +const fs = require('fs'); +const tmp = require('tmp'); +const cryptoRandomString = require('crypto-random-string'); + +const titleCase = (w) => w.slice(0, 1).toUpperCase() + w.slice(1, w.length); + +/* + * Transforms a package name to javascript variable name + */ +function nameFromPackage(name) { + name = + name.replace(/[@~./\\:\s]/gi, '') || + cryptoRandomString({ length: 10, characters: 'abcdefghijk' }); + return name + .split('-') + .map((w, i) => (i > 0 ? titleCase(w) : w)) + .join(''); +} + +/* + * Creates a static file with code necessary to load the addons configuration + * + */ +function getAddonsLoaderCode(name, customThemeAddons = []) { + let buf = `/* +This file is autogenerated. Don't change it directly. +Add a ./theme/_${name}.scss in your add-on to load your theme customizations in the current theme. +*/ + +`; + customThemeAddons.forEach((addon) => { + const customization = `${addon}/theme/${name}`; + const line = `@import '${customization}';\n`; + buf += line; + }); + + return buf; +} + +module.exports = ({ main, variables }) => { + // const addonsThemeLoaderVariablesPath = path.join( + // process.cwd(), + // 'src', + // '_variables.scss', + // ); + // const addonsThemeLoaderMainPath = path.join( + // process.cwd(), + // 'src', + // '_main.scss', + // ); + + // const addonsThemeLoaderVariablesPath = path.join( + // process.cwd(), + // 'src', + // '_variables.scss', + // ); + // const addonsThemeLoaderMainPath = path.join( + // process.cwd(), + // 'src', + // '_main.scss', + // ); + + const addonsThemeLoaderVariablesPath = tmp.tmpNameSync({ postfix: '.scss' }); + const addonsThemeLoaderMainPath = tmp.tmpNameSync({ postfix: '.scss' }); + fs.writeFileSync( + addonsThemeLoaderVariablesPath, + new Buffer.from(getAddonsLoaderCode('variables', variables)), + ); + fs.writeFileSync( + addonsThemeLoaderMainPath, + new Buffer.from(getAddonsLoaderCode('main', main)), + ); + + return [addonsThemeLoaderVariablesPath, addonsThemeLoaderMainPath]; +}; + +module.exports.getAddonsLoaderCode = getAddonsLoaderCode; +module.exports.nameFromPackage = nameFromPackage; diff --git a/docs/source/addons/index.md b/docs/source/addons/index.md index cf456c215b..b692529aa5 100644 --- a/docs/source/addons/index.md +++ b/docs/source/addons/index.md @@ -14,6 +14,7 @@ myst: i18n best-practices +theme ``` There are several advanced scenarios where we might want to have more control diff --git a/docs/source/addons/theme.md b/docs/source/addons/theme.md new file mode 100644 index 0000000000..c3ec9ae413 --- /dev/null +++ b/docs/source/addons/theme.md @@ -0,0 +1,230 @@ +--- +myst: + html_meta: + "description": "Create a theme add-on" + "property=og:description": "Create a theme add-on" + "property=og:title": "Create a theme add-on" + "keywords": "Volto, Plone, Semantic UI, CSS, Volto theme" +--- + +# Create a Volto theme add-on + +We can create a Volto Add-on that acts as a Volto theme Add-on, so we can detach it from the project files. +The advantage is that you convert the project Volto theme in a pluggable one, so you can deploy the same theme in different projects. +You can even have themes depending on conditions that you could inject on build time. +This is the purpose of `volto.config.js`, the ability of declaring `add-ons` and the active `theme` programatically. See {ref}`volto-config-js` for more information. +For convenience, it can also be set via a `THEME` environment variable. + +1. Add a `theme` key in your `volto.config.js` file in the root of your project: + +```js +module.exports = { + addons: [], + theme: 'volto-my-theme' +}; +``` + +or add a key in your `package.json` project: + +```json +"theme": "volto-my-theme" +``` + +or via a `THEME` variable: + +```shell +THEME='volto-my-theme' yarn start +``` + +2. Create a directory `src/theme` in your add-on, then add this file `theme.config`, replacing `` with your add-on name: + +```less +/******************************* + Theme Selection +*******************************/ + +/* To override a theme for an individual element specify theme name below */ + +/* Global */ +@site : 'pastanaga'; +@reset : 'pastanaga'; + +/* Elements */ +@button : 'pastanaga'; +@container : 'pastanaga'; +@divider : 'pastanaga'; +@flag : 'pastanaga'; +@header : 'pastanaga'; +@icon : 'pastanaga'; +@image : 'pastanaga'; +@input : 'pastanaga'; +@label : 'pastanaga'; +@list : 'pastanaga'; +@loader : 'pastanaga'; +@placeholder : 'pastanaga'; +@rail : 'pastanaga'; +@reveal : 'pastanaga'; +@segment : 'pastanaga'; +@step : 'pastanaga'; + +/* Collections */ +@breadcrumb : 'pastanaga'; +@form : 'pastanaga'; +@grid : 'pastanaga'; +@menu : 'pastanaga'; +@message : 'pastanaga'; +@table : 'pastanaga'; + +/* Modules */ +@accordion : 'pastanaga'; +@checkbox : 'pastanaga'; +@dimmer : 'pastanaga'; +@dropdown : 'pastanaga'; +@embed : 'pastanaga'; +@modal : 'pastanaga'; +@nag : 'pastanaga'; +@popup : 'pastanaga'; +@progress : 'pastanaga'; +@rating : 'pastanaga'; +@search : 'pastanaga'; +@shape : 'pastanaga'; +@sidebar : 'pastanaga'; +@sticky : 'pastanaga'; +@tab : 'pastanaga'; +@transition : 'pastanaga'; + +/* Views */ +@ad : 'pastanaga'; +@card : 'pastanaga'; +@comment : 'pastanaga'; +@feed : 'pastanaga'; +@item : 'pastanaga'; +@statistic : 'pastanaga'; + +/* Extras */ +@main : 'pastanaga'; +@custom : 'pastanaga'; + +/******************************* + Folders +*******************************/ + +/* Path to theme packages */ +@themesFolder : '~volto-themes'; + +/* Path to site override folder */ +@siteFolder : "/theme"; + +/******************************* + Import Theme +*******************************/ + +@import (multiple) "~semantic-ui-less/theme.less"; +@fontPath : "~volto-themes/@{theme}/assets/fonts"; + +.loadAddonOverrides() { + @import (optional) "@{siteFolder}/@{addon}/@{addontype}s/@{addonelement}.overrides"; +} + +/* End Config */ +``` + +3. Declare the theme as an add-on by adding its name to the value for the `addons` key in either `volto.config.js` or `package.json` of your project. +4. After starting Volto, the theme should be active. + Now you can add overrides to the default theme in `src/theme`, same as you would in a project. +5. Now you can safely delete your project's `theme` folder, since the one in the add-on will take precedence and a project can only have one active theme at a time. + +## Using your own theming escape hatch + +Volto theming uses SemanticUI theming capabilities to define and extend a theme for your site. +However, while maintaining and playing well with the Semantic UI Volto base, using a traditional CSS approach can be done using the LESS preprocessor-based `extras` escape hatch. + +At the same time, one can either discard or complement the extras escape hatch and add your own, by customizing the `theme.js` module in Volto. + +```js +import 'semantic-ui-less/semantic.less'; +import '@plone/volto/../theme/themes/pastanaga/extras/extras.less'; + +// You can add more entry points for theming +import '@kitconcept/volto-light-theme/theme/main.scss'; +``` + +Customizing it is a special use case in Volto: add a `./@root/theme.js` file structure in your `customizations` folder in your add-on or project. + +You may want to do this to create a complete new theming experience adapted to your way of doing things that do not match the current Volto theming experience. +For example, if you want to use another preprocessor in the theme, like SCSS. +Maybe because your client forces you to have another entirely base of pre-made components based on another library other than Semantic UI: +See {ref}`volto-custom-theming-strategy` for an example of a custom theme escape hatch. + +While building your own escape hatch for theming, you can use the preprocessor of your choice (in the example, SCSS) while maintaining the "base" Volto theme, but customizing it using the resultant CSS. + +You can see an example of such a theme in: https://github.com/kitconcept/volto-light-theme + +## Modify a custom theme from another add-on + +Sometimes you have a custom theme that you want to reuse through all your projects, but with some differences, maintaining the base. +Usually, the only option would be to use an add-on that adds more CSS to the base theme, using imports that will load after the theme. +However, there is a problem with this approach. +You cannot use existing theme variables, including breakpoints, on these new styles. +Similarly, it gets somewhat detached from the normal flow of the loaded theme. +The same applies for add-ons, as they are detached from the current theme. +One could use a SemanticUI approach for making this work, but it's SemanticUI bound. + +```{warning} +This is only possible when using your own escape hatch, and works only with SCSS-based themes, and not with SemanticUI themes, since it enables a couple of entry points that only support SCSS files. +For an example of how it could be used, see: https://github.com/kitconcept/volto-light-theme +``` + +If your custom escape hatch defines a custom theme using SCSS, you can take advantage of this feature. +Although not limited to this, it would be possible to extend this feature to add more entry points, using another preprocessor or theming approach. + +This feature enables two entry points: variables and main. +From your add-on code, you can extend an existing theme by creating a file corresponding to each entry point: + +* `./src/theme/_variables.scss` +* `./src/theme/_main.scss` + +### Variables (`addonsThemeCustomizationsVariables`) + +Use this entry point file to modify the original variables of the current loaded theme by adding the entry point before the theme variable definitions. +In the theme, it should be imported as shown below: + +```scss hl_lines="2" +@import 'addonsThemeCustomizationsVariables'; +@import 'variables'; +@import 'typography'; +@import 'utils'; +@import 'layout'; +``` + +```{warning} +Following SCSS best practices, your theme variables should be "overridable" using the `!default` flag. +This assigns a value to a variable _only_ if that variable isn't defined or its value is [`null`](https://sass-lang.com/documentation/values/null). +Otherwise, the existing value will be used. +For more information, see https://sass-lang.com/documentation/variables#default-values +``` + +Volto will not only load your add-on entry point files, but it will also detect all the add-ons that have these entry point files and import them grouped under a single file. +It will also automatically add an `addonsThemeCustomizationsVariables` alias that can be referenced from the theme as shown above. + +### Main (`addonsThemeCustomizationsMain`) + +This entry point is intended to add your own style definitions, complementing those in the theme. +You should add it after all the CSS of your theme: + +```scss hl_lines="6" +@import 'blocks/search'; +@import 'blocks/listing'; + +@import 'temp'; + +@import 'addonsThemeCustomizationsMain'; + +/* No CSS beyond this point */ +``` + +Volto will also detect all the add-ons that have these entry point files, and import them grouped under a single file, and will automatically add an `addonsThemeCustomizationsMain` alias that can be referenced from the theme as shown above. + +```{note} +It will only work in combination with the theme declaration in `volto.config.js` or in `package.json`. +``` diff --git a/docs/source/configuration/volto-config-js.md b/docs/source/configuration/volto-config-js.md index 56bbcf417d..9320c3c6d5 100644 --- a/docs/source/configuration/volto-config-js.md +++ b/docs/source/configuration/volto-config-js.md @@ -1,8 +1,25 @@ -# Dynamic Volto Addons Configuration +--- +myst: + html_meta: + "description": "Dynamic Volto Addons Configuration programmatically via volto.config.js" + "property=og:description": "Dynamic Volto Addons Configuration programmatically via volto.config.js" + "property=og:title": "Dynamic Volto Addons Configuration" + "keywords": "Volto, Plone, frontend, React, config" +--- -There are some cases where defining the Volto addons your project is going to use is not enough, and you need more control over it. For example, when you have several builds under the umbrella of the same project that share the core of the code, but each build have special requirements, like other CSS, customizations, {term}`shadowing` or the features of other addons available. +(volto-config-js)= -There is a scapehatch `volto.config.js`. This module exports an object and can have arbitrary code depending on your needs: +# Programatically define the active add-ons and theme + +Volto allows you to define the active `add-ons` and `theme` via a file in the root of your project called `volto.config.js`. + +## Dynamic Volto Addons Configuration + +There are some cases where defining the Volto addons your project is going to use via `package.json` `addons` key is not enough, and you need more control over it. +For example, when you have several builds under the umbrella of the same project that share the core of the code, but each build have special requirements, like other CSS, customizations, {term}`shadowing` or the features of other addons available. + +This is an example of a `volto.config.js` file. +This module exports an object and can have arbitrary code depending on your needs: ```js let addons = []; @@ -10,12 +27,38 @@ if (process.env.MY_SPECIAL_CUSTOM_BUILD) { addons = ['volto-custom-addon']; } +if (process.env.MY_SPECIAL_SECOND_CUSTOM_BUILD) { + addons = ['volto-custom-addon', 'volto-custom-addon-additional']; +} + module.exports = { - addons: addons, + addons, }; - ``` -In the case above, we delegate to the presence of an environment variable (MY_SPECIAL_CUSTOM_BUILD) the use of the list of addons specified. +In the case above, we delegate to the presence of an environment variable (`MY_SPECIAL_CUSTOM_BUILD`) the use of the list of addons specified. This list, sums up to the one defined in `package.json` (it does not override it), and the addons added are placed at the end of the addons list, so the config in there is applied after the ones in the `package.json`. + +## Dynamic Volto active theme configuration + +The same applies for the active theme: + +```js +let addons = []; +let theme; +if (process.env.MY_SPECIAL_CUSTOM_BUILD) { + addons = ['volto-custom-addon']; + theme = 'volto-my-theme'; +} + +if (process.env.MY_SPECIAL_SECOND_CUSTOM_BUILD) { + addons = ['volto-custom-addon-alternative', 'volto-custom-addon-additional']; + theme = 'volto-my-alternate-theme'; +} + +module.exports = { + addons, + theme +}; +``` diff --git a/docs/source/theming/using-third-party-themes.md b/docs/source/theming/using-third-party-themes.md index ea8a8f0279..bc545302ca 100644 --- a/docs/source/theming/using-third-party-themes.md +++ b/docs/source/theming/using-third-party-themes.md @@ -7,6 +7,8 @@ myst: "keywords": "Volto, Plone, frontend, React, Semantic UI, semantic-ui, third, party, libraries, themes" --- +(volto-custom-theming-strategy)= + # Using third party libraries and themes other than `semantic-ui` You can use Volto with third party libraries or themes written in SASS and avoid applying `semantic-ui` on public facing views. diff --git a/news/4625.feature b/news/4625.feature new file mode 100644 index 0000000000..b7f9654fa6 --- /dev/null +++ b/news/4625.feature @@ -0,0 +1,2 @@ +Support for declaring a theme in `volto.config.js` or in `package.json` +Add two entry points to allow extension of a theme from other add-ons. @sneridagh diff --git a/razzle.config.js b/razzle.config.js index 34f84a95ce..78ad60f619 100644 --- a/razzle.config.js +++ b/razzle.config.js @@ -8,6 +8,7 @@ const fs = require('fs'); const RootResolverPlugin = require('./webpack-plugins/webpack-root-resolver'); const RelativeResolverPlugin = require('./webpack-plugins/webpack-relative-resolver'); const createAddonsLoader = require('./create-addons-loader'); +const createThemeAddonsLoader = require('./create-theme-addons-loader'); const AddonConfigurationRegistry = require('./addon-registry'); const CircularDependencyPlugin = require('circular-dependency-plugin'); const TerserPlugin = require('terser-webpack-plugin'); @@ -245,6 +246,28 @@ const defaultModify = ({ 'lodash-es': path.dirname(require.resolve('lodash')), }; + const [ + addonsThemeLoaderVariablesPath, + addonsThemeLoaderMainPath, + ] = createThemeAddonsLoader(registry.getCustomThemeAddons()); + + // Automatic Theme Loading + if (registry.theme) { + // The themes should be located in `src/theme` + const themePath = registry.packages[registry.theme].modulePath; + const themeConfigPath = `${themePath}/theme/theme.config`; + config.resolve.alias['../../theme.config$'] = themeConfigPath; + config.resolve.alias['../../theme.config'] = themeConfigPath; + + // We create an alias for each custom theme insertion point (variables, main) + config.resolve.alias[ + 'addonsThemeCustomizationsVariables' + ] = addonsThemeLoaderVariablesPath; + config.resolve.alias[ + 'addonsThemeCustomizationsMain' + ] = addonsThemeLoaderMainPath; + } + config.performance = { maxAssetSize: 10000000, maxEntrypointSize: 10000000,