Repository: jellyfin/jellyfin-web
Pull request #6662: Move themes to sass
Author: login: thornbill
Labels: name: enhancement
description: Improve existing functionality or small fixes
Created: 2025-03-25T16:24:26Z | Merged: 2025-03-26T22:55:03Z

**Changes**
* Move themes to sass files
* Update webpack config to build sass themes

**Issues**
N/A



Diff:
diff --git a/package-lock.json b/package-lock.json
index d44b8fe1586c..b7d157358d54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -102,6 +102,7 @@
         "eslint-plugin-react-hooks": "5.2.0",
         "eslint-plugin-sonarjs": "3.0.2",
         "expose-loader": "5.0.1",
+        "fast-glob": "3.3.3",
         "fork-ts-checker-webpack-plugin": "9.0.2",
         "globals": "16.0.0",
         "html-loader": "5.1.0",
@@ -11384,7 +11385,6 @@
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
       "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "@nodelib/fs.stat": "^2.0.2",
         "@nodelib/fs.walk": "^1.2.3",
diff --git a/package.json b/package.json
index 46cc20e4572b..5d4704690c9f 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
     "eslint-plugin-react-hooks": "5.2.0",
     "eslint-plugin-sonarjs": "3.0.2",
     "expose-loader": "5.0.1",
+    "fast-glob": "3.3.3",
     "fork-ts-checker-webpack-plugin": "9.0.2",
     "globals": "16.0.0",
     "html-loader": "5.1.0",
diff --git a/src/themes/appletv/theme.css b/src/themes/appletv/theme.scss
similarity index 100%
rename from src/themes/appletv/theme.css
rename to src/themes/appletv/theme.scss
diff --git a/src/themes/blueradiance/theme.css b/src/themes/blueradiance/theme.scss
similarity index 100%
rename from src/themes/blueradiance/theme.css
rename to src/themes/blueradiance/theme.scss
diff --git a/src/themes/dark/theme.css b/src/themes/dark/theme.scss
similarity index 100%
rename from src/themes/dark/theme.css
rename to src/themes/dark/theme.scss
diff --git a/src/themes/light/theme.css b/src/themes/light/theme.scss
similarity index 100%
rename from src/themes/light/theme.css
rename to src/themes/light/theme.scss
diff --git a/src/themes/purplehaze/theme.css b/src/themes/purplehaze/theme.scss
similarity index 100%
rename from src/themes/purplehaze/theme.css
rename to src/themes/purplehaze/theme.scss
diff --git a/src/themes/wmc/theme.css b/src/themes/wmc/theme.scss
similarity index 100%
rename from src/themes/wmc/theme.css
rename to src/themes/wmc/theme.scss
diff --git a/webpack.common.js b/webpack.common.js
index 3534e683e122..b66f0b7611e3 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -1,3 +1,4 @@
+const fg = require('fast-glob');
 const path = require('path');
 const { CleanWebpackPlugin } = require('clean-webpack-plugin');
 const CopyPlugin = require('copy-webpack-plugin');
@@ -33,9 +34,19 @@ try {
 
 const NODE_MODULES_REGEX = /[\\/]node_modules[\\/]/;
 
+const THEMES = fg.globSync('themes/**/*.scss', { cwd: path.resolve(__dirname, 'src') });
+const THEMES_BY_ID = THEMES.reduce((acc, theme) => {
+    acc[theme.substring(0, theme.lastIndexOf('/'))] = `./${theme}`;
+    return acc;
+}, {});
+
 const config = {
     context: path.resolve(__dirname, 'src'),
     target: 'browserslist',
+    entry: {
+        'main.jellyfin': './index.jsx',
+        ...THEMES_BY_ID
+    },
     resolve: {
         extensions: ['.tsx', '.ts', '.js'],
         modules: [
@@ -60,13 +71,14 @@ const config = {
             filename: 'index.html',
             template: 'index.html',
             // Append file hashes to bundle urls for cache busting
-            hash: true
+            hash: true,
+            chunks: [
+                'main.jellyfin',
+                'serviceworker'
+            ]
         }),
         new CopyPlugin({
             patterns: [
-                {
-                    from: 'themes/**/*.{css,jpg}'
-                },
                 {
                     from: 'assets/**',
                     globOptions: {
@@ -107,6 +119,15 @@ const config = {
             typescript: {
                 configFile: path.resolve(__dirname, 'tsconfig.json')
             }
+        }),
+        new MiniCssExtractPlugin({
+            filename: pathData => {
+                if (pathData.chunk?.name?.startsWith('themes/')) {
+                    return '[name]/theme.css';
+                }
+                return '[name].[contenthash].css';
+            },
+            chunkFilename: '[name].[contenthash].css'
         })
     ],
     output: {
@@ -114,6 +135,12 @@ const config = {
             pathData.chunk.name === 'serviceworker' ? '[name].js' : '[name].bundle.js'
         ),
         chunkFilename: '[name].[contenthash].chunk.js',
+        assetModuleFilename: pathData => {
+            if (pathData.filename.startsWith('assets/') || pathData.filename.startsWith('themes/')) {
+                return '[path][base][query]';
+            }
+            return '[hash][ext][query]';
+        },
         path: path.resolve(__dirname, 'dist'),
         publicPath: ''
     },
@@ -288,33 +315,46 @@ const config = {
                 }]
             },
             {
-                test: /\.s[ac]ss$/i,
-                use: [
-                    DEV_MODE ? 'style-loader' : MiniCssExtractPlugin.loader,
-                    'css-loader',
+                test: /\.(sa|sc|c)ss$/i,
+                oneOf: [
                     {
-                        loader: 'postcss-loader',
-                        options: {
-                            postcssOptions: {
-                                config: path.resolve(__dirname, 'postcss.config.js')
-                            }
-                        }
+                        // Themes always need to use the MiniCssExtractPlugin since they are loaded directly
+                        include: [
+                            path.resolve(__dirname, 'src/themes/')
+                        ],
+                        use: [
+                            {
+                                loader: MiniCssExtractPlugin.loader,
+                                options: {
+                                    publicPath: '/'
+                                }
+                            },
+                            'css-loader',
+                            {
+                                loader: 'postcss-loader',
+                                options: {
+                                    postcssOptions: {
+                                        config: path.resolve(__dirname, 'postcss.config.js')
+                                    }
+                                }
+                            },
+                            'sass-loader'
+                        ]
                     },
-                    'sass-loader'
-                ]
-            },
-            {
-                test: /\.css$/i,
-                use: [
-                    DEV_MODE ? 'style-loader' : MiniCssExtractPlugin.loader,
-                    'css-loader',
                     {
-                        loader: 'postcss-loader',
-                        options: {
-                            postcssOptions: {
-                                config: path.resolve(__dirname, 'postcss.config.js')
-                            }
-                        }
+                        use: [
+                            DEV_MODE ? 'style-loader' : MiniCssExtractPlugin.loader,
+                            'css-loader',
+                            {
+                                loader: 'postcss-loader',
+                                options: {
+                                    postcssOptions: {
+                                        config: path.resolve(__dirname, 'postcss.config.js')
+                                    }
+                                }
+                            },
+                            'sass-loader'
+                        ]
                     }
                 ]
             },
@@ -341,10 +381,4 @@ const config = {
     }
 };
 
-if (!DEV_MODE) {
-    config.plugins.push(new MiniCssExtractPlugin({
-        filename: '[name].[contenthash].css'
-    }));
-}
-
 module.exports = config;
diff --git a/webpack.dev.js b/webpack.dev.js
index a6288a80cee8..58c5fbbd6812 100644
--- a/webpack.dev.js
+++ b/webpack.dev.js
@@ -1,11 +1,11 @@
-const common = require('./webpack.common');
 const { merge } = require('webpack-merge');
 
+const common = require('./webpack.common');
+
 module.exports = merge(common, {
     // In order for live reload to work we must use "web" as the target not "browserslist"
     target: process.env.WEBPACK_SERVE ? 'web' : 'browserslist',
     mode: 'development',
-    entry: { 'main.jellyfin': './index.jsx' },
     devtool: 'eval-cheap-module-source-map',
     module: {
         rules: [
diff --git a/webpack.prod.js b/webpack.prod.js
index 8b2c9aad5eae..e45f83f19d98 100644
--- a/webpack.prod.js
+++ b/webpack.prod.js
@@ -1,10 +1,11 @@
-const common = require('./webpack.common');
 const { merge } = require('webpack-merge');
 
+const common = require('./webpack.common');
+
 module.exports = merge(common, {
     mode: 'production',
     entry: {
-        'main.jellyfin': './index.jsx',
+        ...common.entry,
         'serviceworker': './serviceworker.js'
     }
 });


Reviews:
user: login: viown
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T17:10:31Z
user: login: thornbill
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:56:11Z
user: login: viown
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T20:16:09Z
user: login: viown
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T10:12:42Z

Review comments:
diff_hunk: @@ -1,11 +1,26 @@
 const common = require('./webpack.common');
 const { merge } = require('webpack-merge');
 
+const THEMES = [
+    'appletv',
+    'blueradiance',
+    'dark',
+    'light',
+    'purplehaze',
+    'wmc'
+];
+
 module.exports = merge(common, {
     // In order for live reload to work we must use "web" as the target not "browserslist"
     target: process.env.WEBPACK_SERVE ? 'web' : 'browserslist',
     mode: 'development',
-    entry: { 'main.jellyfin': './index.jsx' },
+    entry: {
+        'main.jellyfin': './index.jsx',
+        ...THEMES.reduce((acc, theme) => {
+            acc[`themes/${theme}`] = `./themes/${theme}/theme.scss`;
+            return acc;
+        }, {})
path: webpack.dev.js
user: login: viown
body: Would it be better to generate the themes from path? I figured it could save us a few steps if we decide to add a new theme.
created_at: 2025-03-25T17:10:30Z
author_association: MEMBER
diff_hunk: @@ -1,11 +1,26 @@
 const common = require('./webpack.common');
 const { merge } = require('webpack-merge');
 
+const THEMES = [
+    'appletv',
+    'blueradiance',
+    'dark',
+    'light',
+    'purplehaze',
+    'wmc'
+];
+
 module.exports = merge(common, {
     // In order for live reload to work we must use "web" as the target not "browserslist"
     target: process.env.WEBPACK_SERVE ? 'web' : 'browserslist',
     mode: 'development',
-    entry: { 'main.jellyfin': './index.jsx' },
+    entry: {
+        'main.jellyfin': './index.jsx',
+        ...THEMES.reduce((acc, theme) => {
+            acc[`themes/${theme}`] = `./themes/${theme}/theme.scss`;
+            return acc;
+        }, {})
path: webpack.dev.js
user: login: thornbill
body: :+1: good call
created_at: 2025-03-25T19:56:11Z
author_association: MEMBER
diff_hunk: @@ -60,13 +71,14 @@ const config = {
             filename: 'index.html',
             template: 'index.html',
             // Append file hashes to bundle urls for cache busting
-            hash: true
+            hash: true,
+            chunks: [
+                'main.jellyfin',
+                'serviceworker'
+            ]
         }),
         new CopyPlugin({
             patterns: [
-                {
-                    from: 'themes/**/*.{css,jpg}'
path: webpack.common.js
user: login: viown
body: Looks like the themes can't load the background images from themes nor the jellyfin logo in the top left:

```
GET
http://localhost:8080/themes/blueradiance/47410544679fce8d5411.jpg
[HTTP/1.1 404 Not Found
```

The theme images aren't being copied, but the Jellyfin logo is being copied correctly however the reference to it is incorrect.
created_at: 2025-03-25T20:16:08Z
author_association: MEMBER

PR comments:
user: login: jellyfin-bot
created_at: 2025-03-25T16:24:37Z
body: ## Cloudflare Pages deployment

| **Latest commit**       	| <code>1fdce16</code>     |
|-------------------------	|:----------------------------:	|
| **Status**              	| ✅ Deployed!                |
| **Preview URL**         	| https://ecb2437b.jellyfin-web.pages.dev                  |
| **Type**         	        | 🔀 Preview              |

**[View build logs](https://github.com/jellyfin/jellyfin-web/actions/runs/14070665791)**
<!-- thollander/actions-comment-pull-request "CFPages-deployment" -->
author_association: COLLABORATOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-25T21:42:56Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-web&pullRequest=6662) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=jellyfin_jellyfin-web&pullRequest=6662&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=jellyfin_jellyfin-web&pullRequest=6662&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=jellyfin_jellyfin-web&pullRequest=6662&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=jellyfin_jellyfin-web&pullRequest=6662&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=jellyfin_jellyfin-web&pullRequest=6662&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-web&pullRequest=6662)


author_association: NONE