diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index 074741f4a..0f2406338 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -4,13 +4,11 @@ module ThemeHelper def theme_style_tags(theme) if theme == 'system' ''.html_safe.tap do |tags| - tags << vite_stylesheet_tag('styles/mastodon-light.scss', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') - tags << vite_stylesheet_tag('styles/application.scss', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') + tags << vite_stylesheet_tag('themes/mastodon-light', type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous') + tags << vite_stylesheet_tag('themes/default', type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous') end - elsif theme == 'default' - vite_stylesheet_tag 'styles/application.scss', media: 'all', crossorigin: 'anonymous' else - vite_stylesheet_tag "styles/#{theme}.scss", media: 'all', crossorigin: 'anonymous' + vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' end end diff --git a/config/vite/plugin-mastodon-themes.ts b/config/vite/plugin-mastodon-themes.ts index 07d79584a..64bfa5e76 100644 --- a/config/vite/plugin-mastodon-themes.ts +++ b/config/vite/plugin-mastodon-themes.ts @@ -29,7 +29,7 @@ export function MastodonThemes(): Plugin { throw new Error('Invalid themes.yml file'); } - for (const themePath of Object.values(themes)) { + for (const [themeName, themePath] of Object.entries(themes)) { if ( typeof themePath !== 'string' || themePath.split('.').length !== 2 || // Ensure it has exactly one period @@ -40,7 +40,7 @@ export function MastodonThemes(): Plugin { ); continue; } - entrypoints[path.basename(themePath)] = path.resolve( + entrypoints[`themes/${themeName}`] = path.resolve( userConfig.root, themePath, ); diff --git a/config/vite/plugin-name-lookup.ts b/config/vite/plugin-name-lookup.ts new file mode 100644 index 000000000..82dbf712e --- /dev/null +++ b/config/vite/plugin-name-lookup.ts @@ -0,0 +1,68 @@ +import { relative, extname } from 'node:path'; + +import type { Plugin } from 'vite'; + +export function MastodonNameLookup(): Plugin { + const nameMap: Record = {}; + + let root = ''; + + return { + name: 'mastodon-name-lookup', + applyToEnvironment(environment) { + return !!environment.config.build.manifest; + }, + configResolved(userConfig) { + root = userConfig.root; + }, + generateBundle(options, bundle) { + if (!root) { + throw new Error( + 'MastodonNameLookup plugin requires the root to be set in the config.', + ); + } + + // Iterate over all chunks in the bundle and create a lookup map + for (const file in bundle) { + const chunk = bundle[file]; + if ( + chunk?.type !== 'chunk' || + !chunk.isEntry || + !chunk.facadeModuleId + ) { + continue; + } + + const relativePath = relative( + root, + sanitizeFileName(chunk.facadeModuleId), + ); + const ext = extname(relativePath); + const name = chunk.name.replace(ext, ''); + + if (nameMap[name]) { + throw new Error( + `Entrypoint ${relativePath} conflicts with ${nameMap[name]}`, + ); + } + + nameMap[name] = relativePath; + } + + this.emitFile({ + type: 'asset', + fileName: '.vite/manifest-lookup.json', + source: JSON.stringify(nameMap, null, 2), + }); + }, + }; +} + +// Taken from https://github.com/rollup/rollup/blob/4f69d33af3b2ec9320c43c9e6c65ea23a02bdde3/src/utils/sanitizeFileName.ts +// https://datatracker.ietf.org/doc/html/rfc2396 +// eslint-disable-next-line no-control-regex +const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g; + +function sanitizeFileName(name: string): string { + return name.replace(INVALID_CHAR_REGEX, ''); +} diff --git a/lib/vite_ruby/sri_extensions.rb b/lib/vite_ruby/sri_extensions.rb index a8b9fd2a7..5552e9cd0 100644 --- a/lib/vite_ruby/sri_extensions.rb +++ b/lib/vite_ruby/sri_extensions.rb @@ -7,6 +7,24 @@ module ViteRuby::ManifestIntegrityExtension { path: entry.fetch('file'), integrity: entry.fetch('integrity', nil) } end + def load_manifest + # Invalidate the name lookup cache when reloading manifest + @name_lookup_cache = load_name_lookup_cache + + super + end + + def load_name_lookup_cache + Oj.load(config.build_output_dir.join('.vite/manifest-lookup.json').read) + end + + # Upstream's `virtual` type is a hack, re-implement it with efficient exact name lookup + def resolve_virtual_entry(name) + @name_lookup_cache ||= load_name_lookup_cache + + @name_lookup_cache.fetch(name) + end + # Find a manifest entry by the *final* file name def integrity_hash_for_file(file_name) @integrity_cache ||= {} @@ -94,10 +112,10 @@ module ViteRails::TagHelpers::IntegrityExtension end end - def vite_stylesheet_tag(*names, **options) + def vite_stylesheet_tag(*names, type: :stylesheet, **options) ''.html_safe.tap do |tags| names.each do |name| - entry = vite_manifest.path_and_integrity_for(name, type: :stylesheet) + entry = vite_manifest.path_and_integrity_for(name, type:) options[:extname] = false if Rails::VERSION::MAJOR >= 7 diff --git a/spec/helpers/theme_helper_spec.rb b/spec/helpers/theme_helper_spec.rb index 60275adf5..bae68b24e 100644 --- a/spec/helpers/theme_helper_spec.rb +++ b/spec/helpers/theme_helper_spec.rb @@ -17,7 +17,7 @@ RSpec.describe ThemeHelper do ) expect(html_links.last.attributes.symbolize_keys) .to include( - href: have_attributes(value: match(/application/)), + href: have_attributes(value: match(/default/)), media: have_attributes(value: '(prefers-color-scheme: dark)') ) end @@ -26,10 +26,10 @@ RSpec.describe ThemeHelper do context 'when using "default" theme' do let(:theme) { 'default' } - it 'returns the application stylesheet' do + it 'returns the default stylesheet' do expect(html_links.last.attributes.symbolize_keys) .to include( - href: have_attributes(value: match(/application/)) + href: have_attributes(value: match(/default/)) ) end end diff --git a/vite.config.mts b/vite.config.mts index 37b5f0f12..484211eaa 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -16,6 +16,7 @@ import postcssPresetEnv from 'postcss-preset-env'; import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales'; import { MastodonEmojiCompressed } from './config/vite/plugin-emoji-compressed'; import { MastodonThemes } from './config/vite/plugin-mastodon-themes'; +import { MastodonNameLookup } from './config/vite/plugin-name-lookup'; const jsRoot = path.resolve(__dirname, 'app/javascript'); @@ -125,6 +126,7 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => { // Old library types need to be converted optimizeLodashImports() as PluginOption, !!process.env.ANALYZE_BUNDLE_SIZE && (visualizer() as PluginOption), + MastodonNameLookup(), ], } satisfies UserConfig; };