/* This plugins handles glitch-soc's specific theming system */ import fs from 'node:fs/promises'; import path from 'node:path'; import glob from 'fast-glob'; import yaml from 'js-yaml'; import type { Plugin } from 'vite'; interface Flavour { pack_directory: string; } export function GlitchThemes(): Plugin { let jsRoot = ''; const entrypoints: Record = {}; return { name: 'glitch-themes', async config(userConfig) { const existingInputs = userConfig.build?.rollupOptions?.input; if (typeof existingInputs === 'string') { entrypoints[path.basename(existingInputs)] = existingInputs; } else if (Array.isArray(existingInputs)) { for (const input of existingInputs) { if (typeof input === 'string') { entrypoints[path.basename(input)] = input; } } } else if (typeof existingInputs === 'object') { Object.assign(entrypoints, existingInputs); } if (!userConfig.root || !userConfig.envDir) { throw new Error('Unknown project directory'); } jsRoot = userConfig.root; const glitchFlavourFiles = glob.sync( path.resolve(userConfig.root, 'flavours/*/theme.yml'), ); for (const flavourFile of glitchFlavourFiles) { const flavourName = path.basename(path.dirname(flavourFile)); const flavourString = await fs.readFile(flavourFile, 'utf8'); const flavourDef = yaml.load(flavourString, { filename: 'theme.yml', schema: yaml.FAILSAFE_SCHEMA, }) as Flavour; const flavourEntrypoints = glob.sync( `${flavourDef.pack_directory}/*.{ts,tsx,js,jsx}`, ); for (const entrypoint of flavourEntrypoints) { const name = `${flavourName}/${path.basename(entrypoint)}`; entrypoints[name] = path.resolve(userConfig.envDir, entrypoint); } // Skins const skinFiles = glob.sync( `app/javascript/skins/${flavourName}/*.{css,scss}`, ); for (const entrypoint of skinFiles) { const name = `skins/${flavourName}/${path.basename(entrypoint)}`; entrypoints[name] = path.resolve(userConfig.envDir, entrypoint); } const alternateSkinFiles = glob.sync( `app/javascript/skins/${flavourName}/*/{index,common,application}.{css,scss}`, ); for (const entrypoint of alternateSkinFiles) { const name = `skins/${flavourName}/${path.basename(path.dirname(entrypoint))}`; entrypoints[name] = path.resolve(userConfig.envDir, entrypoint); } } return { build: { rollupOptions: { input: entrypoints, }, }, }; }, configureServer(server) { server.middlewares.use((req, res, next) => { if (!req.url?.startsWith('/packs-dev/skins/')) { next(); return; } // Rewrite the URL to the entrypoint if it matches a theme. const filename = req.url.slice(11).split(/[.?]/)[0] ?? ''; if (filename in entrypoints) { req.url = `/packs-dev/${entrypoints[filename]}`; } next(); }); }, handleHotUpdate({ modules, server }) { if (modules.length === 0) { return; } // Unlike upstream, we don't need to look up, we can deduce the theme // solely from the path name const baseRoot = path.join(jsRoot, 'skins'); const themeNames = new Set(); const addIfMatches = (file: string | null) => { if (!file) { return false; } const segments = path.relative(baseRoot, file).split(path.sep); if ( segments.length >= 2 && segments.length < 4 && segments[0] !== '..' && segments[1] ) { const themeName = `skins/${segments[0]}/${path.basename(segments[1], path.extname(segments[1]))}`; themeNames.add(themeName); return true; } return false; }; for (const module of modules) { if (!addIfMatches(module.file)) { for (const importer of module.importers) { addIfMatches(importer.file); } } } if (themeNames.size > 0) { server.ws.send({ type: 'update', updates: Array.from(themeNames).map((themeName) => ({ type: 'css-update', path: themeName, acceptedPath: themeName, timestamp: Date.now(), })), }); } }, }; }