web/satellite: statically serve Vuetify theme styles

Vuetify's way of applying themes uses an inline stylesheet. This is
incompatible with our CSP policy, so this change implements a Vite
plugin that writes theme styles to CSS files that are statically
served.

Change-Id: I73e3a032435e46d41248c5181e913a8e04f65881
This commit is contained in:
Jeremy Wharton 2023-11-01 19:14:46 -05:00 committed by Storj Robot
parent 74757ffc1d
commit 51fefb2882
11 changed files with 172 additions and 54 deletions

View File

@ -61,6 +61,7 @@ module.exports = {
],
'newlines-between': 'always',
}],
'import/no-unresolved': ['error', { ignore: ['^virtual:'] }],
'no-duplicate-imports': 'error',
'import/default': 'off',
'vue/script-setup-uses-vars': 'error',

View File

@ -2,6 +2,7 @@
"name": "storj-satellite",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"preview": "vite preview",
"dev": "vite",

View File

@ -40,7 +40,8 @@
"src/**/*.ts",
"src/**/*.vue",
"src/types/*.d.ts",
"tests/**/*.ts"
"tests/**/*.ts",
"vitePlugins/**/*.d.ts"
],
"exclude": [
"node_modules"

View File

@ -7,6 +7,8 @@ import vue from '@vitejs/plugin-vue';
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify';
import { defineConfig } from 'vite';
import vuetifyThemeCSS from './vitePlugins/vuetifyThemeCSS';
// https://vitejs.dev/config/
export default defineConfig({
base: '/static/dist_vuetify_poc',
@ -21,6 +23,7 @@ export default defineConfig({
configFile: 'vuetify-poc/src/styles/settings.scss',
},
}),
vuetifyThemeCSS(),
],
define: {
'process.env': {},

View File

@ -19,7 +19,7 @@ const plugins = [
plugins: [{ name: 'removeViewBox', fn: () => {} }],
},
}),
vitePluginRequire(),
vitePluginRequire.default(),
];
if (process.env['STORJ_DEBUG_BUNDLE_SIZE']) {

View File

@ -0,0 +1,57 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
import { Plugin } from 'vite';
import { build } from 'esbuild';
import { createVuetify } from 'vuetify';
import { THEME_OPTIONS } from '../../vuetify-poc/src/plugins/theme';
export default function vuetifyThemeCSS(): Plugin {
const name = 'vuetify-theme-css';
const virtualModuleId = 'virtual:' + name;
const resolvedVirtualModuleId = '\0' + virtualModuleId;
const theme = createVuetify({ theme: THEME_OPTIONS }).theme;
const themeURLs: Record<string, string> = {};
return {
name,
async buildStart() {
for (const name of Object.keys(theme.themes.value)) {
theme.global.name.value = name;
const result = await build({
stdin: {
contents: theme.styles.value,
loader: 'css',
},
write: false,
minify: true,
});
const refId = this.emitFile({
type: 'asset',
name: `theme-${name}.css`,
source: result.outputFiles[0].text,
});
themeURLs[name] = `__VITE_ASSET__${refId}__`;
}
},
resolveId(id: string) {
if (id === virtualModuleId) return resolvedVirtualModuleId;
},
load(id: string) {
if (id === resolvedVirtualModuleId) {
return `export const themeURLs = {${
Object.entries(themeURLs)
.map(([name, url]) => `'${name}':'${url}'`)
.join(',')
}};`;
}
},
};
}

View File

@ -0,0 +1,6 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
declare module 'virtual:vuetify-theme-css' {
export const themeURLs: Record<string, string>;
}

View File

@ -8,8 +8,9 @@
*/
// Plugins
import { App } from 'vue';
import { App, watch } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { themeURLs } from 'virtual:vuetify-theme-css';
import { router, startTitleWatcher } from '../router';
@ -20,7 +21,46 @@ import NotificatorPlugin from '@/utils/plugins/notificator';
const pinia = createPinia();
setActivePinia(pinia);
// Vuetify's way of applying themes uses a dynamic inline stylesheet.
// This is incompatible with our CSP policy, so circumvent it.
function setupTheme() {
const oldAppend = document.head.appendChild.bind(document.head);
document.head.appendChild = function<T extends Node>(node: T): T {
if (node instanceof HTMLStyleElement && node.id === 'vuetify-theme-stylesheet') {
node.remove();
return node;
}
return oldAppend(node);
};
const themeLinks: Record<string, HTMLLinkElement> = {};
for (const [name, url] of Object.entries(themeURLs)) {
let link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.disabled = name !== vuetify.theme.global.name.value;
document.head.appendChild(link);
themeLinks[name] = link;
// If we don't preload the style, there will be a delay after
// toggling to it for the first time.
link = document.createElement('link');
link.rel = 'preload';
link.as = 'style';
link.href = url;
document.head.appendChild(link);
}
watch(() => vuetify.theme.global.name.value, newName => {
for (const [name, link] of Object.entries(themeLinks)) {
link.disabled = name !== newName;
}
});
}
export function registerPlugins(app: App<Element>) {
setupTheme();
app
.use(vuetify)
.use(router)

View File

@ -0,0 +1,57 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
import { createVuetify } from 'vuetify';
type ThemeOptions = NonNullable<NonNullable<Parameters<typeof createVuetify>[0]>['theme']>;
export const THEME_OPTIONS: ThemeOptions = {
themes: {
light: {
colors: {
primary: '#0149FF',
secondary: '#0218A7',
background: '#FFF',
surface: '#FFF',
info: '#0059D0',
help: '#FFA800',
success: '#00AC26',
warning: '#FF7F00',
error: '#FF0149',
purple: '#7B61FF',
blue6: '#091c45',
blue5: '#0218A7',
blue4: '#0059D0',
blue2: '#003ACD',
yellow: '#FFC600',
yellow2: '#FFB701',
orange: '#FFA800',
green: '#00B150',
purple2: '#502EFF',
},
},
dark: {
colors: {
primary: '#0149FF',
secondary: '#537CFF',
background: '#090920',
success: '#00AC26',
help: '#FFC600',
error: '#FF0149',
surface: '#090920',
purple: '#A18EFF',
blue6: '#091c45',
blue5: '#2196f3',
blue4: '#0059D0',
blue2: '#003ACD',
yellow: '#FFC600',
yellow2: '#FFB701',
orange: '#FFA800',
warning: '#FF8A00',
// green: '#00B150',
green: '#00e366',
purple2: '#A18EFF',
},
},
},
};

View File

@ -13,62 +13,14 @@ import '@fontsource-variable/inter';
import { createVuetify } from 'vuetify';
import { md3 } from 'vuetify/blueprints';
import '../styles/styles.scss';
import '@poc/styles/styles.scss';
import { THEME_OPTIONS } from '@poc/plugins/theme';
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
// Use blueprint for Material Design 3
blueprint: md3,
theme: {
themes: {
light: {
colors: {
primary: '#0149FF',
secondary: '#0218A7',
background: '#FFF',
surface: '#FFF',
info: '#0059D0',
help: '#FFA800',
success: '#00AC26',
warning: '#FF7F00',
error: '#FF0149',
purple: '#7B61FF',
blue6: '#091c45',
blue5: '#0218A7',
blue4: '#0059D0',
blue2: '#003ACD',
yellow: '#FFC600',
yellow2: '#FFB701',
orange: '#FFA800',
green: '#00B150',
purple2: '#502EFF',
},
},
dark: {
colors: {
primary: '#0149FF',
secondary: '#537CFF',
background: '#090920',
success: '#00AC26',
help: '#FFC600',
error: '#FF0149',
surface: '#090920',
purple: '#A18EFF',
blue6: '#091c45',
blue5: '#2196f3',
blue4: '#0059D0',
blue2: '#003ACD',
yellow: '#FFC600',
yellow2: '#FFB701',
orange: '#FFA800',
warning: '#FF8A00',
// green: '#00B150',
green: '#00e366',
purple2: '#A18EFF',
},
},
},
},
theme: THEME_OPTIONS,
defaults: {
global: {
// ripple: false,