Webpack to Vite: Utilize Ruby Vite (#34469)

Co-authored-by: Renaud Chaput <renchap@gmail.com>
This commit is contained in:
Echo 2025-05-06 11:32:52 +02:00 committed by ChaosExAnima
parent b68f93fc47
commit f2b85d4696
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
56 changed files with 2472 additions and 1128 deletions

View File

@ -9,6 +9,7 @@ services:
environment:
RAILS_ENV: development
NODE_ENV: development
VITE_RUBY_HOST: 0.0.0.0
BIND: 0.0.0.0
BOOTSNAP_CACHE_DIR: /tmp
REDIS_HOST: redis
@ -27,6 +28,7 @@ services:
ports:
- '3000:3000'
- '3035:3035'
- '3036:3036'
- '4000:4000'
networks:
- external_network

View File

@ -49,7 +49,7 @@ jobs:
public/assets
public/packs
public/packs-test
tmp/cache/webpacker
tmp/cache/vite
key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
@ -63,7 +63,7 @@ jobs:
- name: Archive asset artifacts
run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs*
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json
- uses: actions/upload-artifact@v4
if: matrix.mode == 'test'

3
.gitignore vendored
View File

@ -21,6 +21,7 @@
/public/system
/public/assets
/public/packs
/public/packs-dev
/public/packs-test
.env
.env.production
@ -74,5 +75,3 @@ docker-compose.override.yml
# Ignore local-only rspec configuration
.rspec-local
/.dist

View File

@ -18,10 +18,6 @@
!/log/.keep
/tmp
/coverage
/public/system
/public/assets
/public/packs
/public/packs-test
.env
.env.production
.env.development
@ -60,6 +56,7 @@ docker-compose.override.yml
/public/packs
/public/packs-test
/public/system
/public/vite*
# Ignore emoji map file
/app/javascript/mastodon/features/emoji/emoji_map.json

View File

@ -307,6 +307,7 @@ RUN \
ldconfig; \
# Use Ruby on Rails to create Mastodon assets
SECRET_KEY_BASE_DUMMY=1 \
# Do not run `yarn` when precompiling assets, we already ran it before
bundle exec rails assets:precompile; \
# Cleanup temporary files
rm -fr /opt/mastodon/tmp;

View File

@ -95,7 +95,6 @@ gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2023'
gem 'webauthn', '~> 3.0'
gem 'webpacker', '~> 5.4'
gem 'webpush', github: 'mastodon/webpush', ref: '9631ac63045cfabddacc69fc06e919b4c13eb913'
gem 'json-ld'
@ -230,3 +229,5 @@ gem 'rubyzip', '~> 2.3'
gem 'hcaptcha', '~> 7.1'
gem 'mail', '~> 2.8'
gem 'vite_rails', '~> 3.0.19'

View File

@ -203,6 +203,7 @@ GEM
railties (>= 5)
dotenv (3.1.8)
drb (2.2.1)
dry-cli (1.2.0)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11)
@ -806,7 +807,6 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.1.0)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (6.5.12)
@ -892,6 +892,15 @@ GEM
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
vite_rails (3.0.19)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.9.2)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.4.0)
@ -910,11 +919,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.4)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.7.7)
@ -1078,9 +1082,9 @@ DEPENDENCIES
tty-prompt (~> 0.23)
twitter-text (~> 3.1.0)
tzinfo-data (~> 1.2023)
vite_rails (~> 3.0.19)
webauthn (~> 3.0)
webmock (~> 3.18)
webpacker (~> 5.4)
webpush!
xorcist (~> 1.1)

View File

@ -4,7 +4,7 @@ module RoutingHelper
extend ActiveSupport::Concern
include ActionView::Helpers::AssetTagHelper
include Webpacker::Helper
include ViteRails::TagHelpers
included do
include Rails.application.routes.url_helpers
@ -25,7 +25,7 @@ module RoutingHelper
end
def frontend_asset_path(source, **)
asset_pack_path("media/#{source}", **)
vite_asset_path(source, **)
end
def frontend_asset_url(source, **)

View File

@ -4,11 +4,14 @@ module ThemeHelper
def theme_style_tags(theme)
if theme == 'system'
''.html_safe.tap do |tags|
tags << stylesheet_pack_tag('mastodon-light', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
tags << stylesheet_pack_tag('default', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
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')
end
# TODO: Determine why default doesn't map correctly.
elsif theme == 'default'
vite_stylesheet_tag 'styles/application.scss', media: 'all', crossorigin: 'anonymous'
else
stylesheet_pack_tag theme, media: 'all', crossorigin: 'anonymous'
vite_stylesheet_tag "styles/#{theme}.scss", media: 'all', crossorigin: 'anonymous'
end
end

View File

@ -272,7 +272,7 @@ async function mountReactComponent(element: Element) {
);
const { default: Component } = (await import(
`@/mastodon/components/admin/${componentName}`
`@/mastodon/components/admin/${componentName}.jsx`
)) as { default: React.ComponentType };
const root = createRoot(element);

View File

@ -0,0 +1,8 @@
<html>
<head>
<script type="module" src="./application.ts"></script>
</head>
<body>
<div id="mastodon"></div>
</body>
</html>

View File

@ -1,4 +0,0 @@
/* Placeholder file to have `inert.scss` compiled by Vite
This is used by the `wicg-inert` polyfill */
import '../styles/inert.scss';

View File

@ -1 +0,0 @@
import '../styles/mailer.scss';

View File

@ -9,7 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';

View File

@ -15,10 +15,6 @@ import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { useSpring, animated } from '@react-spring/web';
import Textarea from 'react-textarea-autosize';
import { length } from 'stringz';
// eslint-disable-next-line import/extensions
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
// eslint-disable-next-line import/no-extraneous-dependencies
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
import { showAlertForError } from 'mastodon/actions/alerts';
import { uploadThumbnail } from 'mastodon/actions/compose';
@ -350,9 +346,15 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
fetchTesseract()
.then(async ({ createWorker }) => {
const [tesseractWorkerPath, tesseractCorePath] = await Promise.all([
// eslint-disable-next-line import/extensions
import('tesseract.js/dist/worker.min.js?url'),
// eslint-disable-next-line import/no-extraneous-dependencies
import('tesseract.js-core/tesseract-core.wasm.js?url'),
]);
const worker = await createWorker('eng', 1, {
workerPath: tesseractWorkerPath as string,
corePath: tesseractCorePath as string,
workerPath: tesseractWorkerPath.default,
corePath: tesseractCorePath.default,
langPath: `${assetHost}/ocr/lang-data`,
cacheMethod: 'write',
});
@ -501,5 +503,4 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
);
},
);
AltTextModal.displayName = 'AltTextModal';

View File

@ -22,9 +22,9 @@ export async function loadLocale() {
if (isLocaleLoaded()) return;
// If there is no locale file, then fallback to english
const localeFile = Object.hasOwn(localeFiles, '`./${locale}.json`')
const localeFile = Object.hasOwn(localeFiles, `./${locale}.json`)
? localeFiles[`./${locale}.json`]
: localeFiles[`./en.json`];
: localeFiles['./en.json'];
if (!localeFile) throw new Error('Could not load the locale JSON file');

View File

@ -7,17 +7,19 @@ import * as perf from 'mastodon/performance';
import ready from 'mastodon/ready';
import { store } from 'mastodon/store';
import { isProduction } from './utils/environment';
import { isProduction, isDevelopment } from './utils/environment';
/**
* @returns {Promise<void>}
*/
function main() {
perf.start('main()');
return ready(async () => {
const mountNode = document.getElementById('mastodon');
const props = JSON.parse(mountNode.getAttribute('data-props'));
if (!mountNode) {
throw new Error('Mount node not found');
}
const props = JSON.parse(
mountNode.getAttribute('data-props') ?? '{}',
) as Record<string, unknown>;
const root = createRoot(mountNode);
root.render(<Mastodon {...props} />);
@ -25,8 +27,10 @@ function main() {
if (isProduction() && me && 'serviceWorker' in navigator) {
const { Workbox } = await import('workbox-window');
const wb = new Workbox('/sw.js');
/** @type {ServiceWorkerRegistration} */
const wb = new Workbox(
isDevelopment() ? '/packs-dev/dev-sw.js?dev-sw' : '/sw.js',
{ type: 'module', scope: '/' },
);
let registration;
try {
@ -35,8 +39,14 @@ function main() {
console.error(err);
}
if (registration && 'Notification' in window && Notification.permission === 'granted') {
const registerPushNotifications = await import('mastodon/actions/push_notifications');
if (
registration &&
'Notification' in window &&
Notification.permission === 'granted'
) {
const registerPushNotifications = await import(
'mastodon/actions/push_notifications'
);
store.dispatch(registerPushNotifications.register());
}
@ -46,4 +56,5 @@ function main() {
});
}
// eslint-disable-next-line import/no-default-export
export default main;

View File

@ -1,5 +1,5 @@
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
// import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
@ -15,10 +15,10 @@ function fetchRoot() {
return fetch('/', { credentials: 'include', redirect: 'manual' });
}
precacheAndRoute(self.__WB_MANIFEST);
// precacheAndRoute(self.__WB_MANIFEST);
registerRoute(
/locale_.*\.js$/,
/intl\/.*\.js$/,
new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}locales`,
plugins: [

View File

@ -1,41 +0,0 @@
/* @preval */
const fs = require('fs');
const path = require('path');
const { defineMessages } = require('react-intl');
const messages = defineMessages({
mentioned_you: { id: 'notification.mentioned_you', defaultMessage: '{name} mentioned you' },
});
const filtered = {};
const filenames = fs.readdirSync(path.resolve(__dirname, '../locales'));
filenames.forEach(filename => {
if (!filename.match(/\.json$/)) return;
const content = fs.readFileSync(path.resolve(__dirname, `../locales/${filename}`), 'utf-8');
const full = JSON.parse(content);
const locale = filename.split('.')[0];
filtered[locale] = {
'notification.favourite': full['notification.favourite'] || '',
'notification.follow': full['notification.follow'] || '',
'notification.follow_request': full['notification.follow_request'] || '',
'notification.mention': full[messages.mentioned_you.id] || '',
'notification.reblog': full['notification.reblog'] || '',
'notification.poll': full['notification.poll'] || '',
'notification.status': full['notification.status'] || '',
'notification.update': full['notification.update'] || '',
'notification.admin.sign_up': full['notification.admin.sign_up'] || '',
'status.show_more': full['status.show_more'] || '',
'status.reblog': full['status.reblog'] || '',
'status.favourite': full['status.favourite'] || '',
'notifications.group': full['notifications.group'] || '',
};
});
module.exports = JSON.parse(JSON.stringify(filtered));

View File

@ -1,8 +1,10 @@
import { IntlMessageFormat } from 'intl-messageformat';
import { unescape } from 'lodash';
import locales from './web_push_locales';
// see config/vite/plugins/sw-locales
// it needs to be updated when new locale keys are used in this file
// eslint-disable-next-line import/no-unresolved
import locales from "virtual:mastodon-sw-locales";
const MAX_NOTIFICATIONS = 5;
const GROUP_TAG = 'tag';

View File

@ -1,7 +1,11 @@
export function isDevelopment() {
return process.env.NODE_ENV === 'development';
if (typeof process !== 'undefined')
return process.env.NODE_ENV === 'development';
else return import.meta.env.DEV;
}
export function isProduction() {
return process.env.NODE_ENV === 'production';
if (typeof process !== 'undefined')
return process.env.NODE_ENV === 'production';
else return import.meta.env.PROD;
}

View File

@ -393,7 +393,7 @@ code {
max-width: 100%;
height: auto;
border-radius: var(--avatar-border-radius);
background: url('images/void.png');
background: url('@/images/void.png');
&[src$='missing.png'] {
visibility: hidden;

View File

@ -1,3 +1,5 @@
/// <reference types="vite-plugin-svgr/client" />
/* eslint-disable import/no-default-export */
declare module '*.avif' {
const path: string;
@ -19,23 +21,6 @@ declare module '*.png' {
export default path;
}
declare module '*.svg' {
const path: string;
export default path;
}
declare module '*.svg?react' {
import type React from 'react';
interface SVGPropsWithTitle extends React.SVGProps<SVGSVGElement> {
title?: string;
}
const ReactComponent: React.FC<SVGPropsWithTitle>;
export default ReactComponent;
}
declare module '*.webp' {
const path: string;
export default path;

View File

@ -1,7 +1,7 @@
- content_for :page_title do
= t('auth.login')
= javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous'
= vite_typescript_tag 'two_factor_authentication.ts', crossorigin: 'anonymous'
- if webauthn_enabled?
= render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }

View File

@ -1,7 +1,7 @@
- content_for :page_title do
= t('auth.setup.title')
= javascript_pack_tag 'sign_up', crossorigin: 'anonymous'
= vite_typescript_tag 'sign_up.ts', crossorigin: 'anonymous'
= simple_form_for(@user, url: auth_setup_path) do |f|
= render 'auth/shared/progress', stage: 'confirm'

View File

@ -1,7 +1,7 @@
- content_for :header_tags do
= render_initial_state
= javascript_pack_tag 'public', crossorigin: 'anonymous'
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= vite_typescript_tag 'public.tsx', crossorigin: 'anonymous'
= vite_typescript_tag 'admin.tsx', crossorigin: 'anonymous'
- content_for :body_classes, 'admin'

View File

@ -26,11 +26,12 @@
%title= html_title
= theme_style_tags current_theme
= vite_client_tag
= vite_react_refresh_tag
-# Needed for the wicg-inert polyfill. It needs to be on it's own <style> tag, with this `id`
= stylesheet_pack_tag 'inert', media: 'all', crossorigin: 'anonymous', id: 'inert-style'
= vite_stylesheet_tag 'styles/inert.scss', media: 'all', id: 'inert-style'
= javascript_pack_tag 'common', crossorigin: 'anonymous'
= preload_pack_asset "locale/#{I18n.locale}-json.js"
-# = preload_pack_asset "locale/#{I18n.locale}-json.js"
= csrf_meta_tags unless skip_csrf_meta_tags?
%meta{ name: 'style-nonce', content: request.content_security_policy_nonce }

View File

@ -1,5 +1,5 @@
- content_for :header_tags do
= javascript_pack_tag 'public', crossorigin: 'anonymous'
= vite_typescript_tag 'public.tsx', crossorigin: 'anonymous'
- content_for :content do
.container-alt

View File

@ -11,11 +11,12 @@
- if storage_host?
%link{ rel: 'dns-prefetch', href: storage_host }/
= vite_client_tag
= vite_react_refresh_tag
= theme_style_tags 'mastodon-light'
= javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
= preload_pack_asset "locale/#{I18n.locale}-json.js"
-# = preload_pack_asset "locale/#{I18n.locale}-json.js"
= render_initial_state
= javascript_pack_tag 'embed', integrity: true, crossorigin: 'anonymous'
= vite_typescript_tag 'embed.tsx', integrity: true, crossorigin: 'anonymous'
%body.embed
= yield

View File

@ -5,9 +5,10 @@
%meta{ charset: 'utf-8' }/
%title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ')
%meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/
= vite_client_tag
= vite_react_refresh_tag
= theme_style_tags Setting.default_settings['theme']
= javascript_pack_tag 'common', crossorigin: 'anonymous'
= javascript_pack_tag 'error', crossorigin: 'anonymous'
= vite_typescript_tag 'error.ts', crossorigin: 'anonymous'
%body.error
.dialog
.dialog__illustration

View File

@ -3,6 +3,4 @@
%head
%meta{ charset: 'utf-8' }/
= javascript_pack_tag 'common', crossorigin: 'anonymous'
= yield :header_tags

View File

@ -18,7 +18,7 @@
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
= stylesheet_pack_tag 'mailer'
= vite_stylesheet_tag 'styles/mailer.scss'
%body
.email{ dir: locale_direction }
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }

View File

@ -1,5 +1,5 @@
- content_for :header_tags do
= javascript_pack_tag 'public', crossorigin: 'anonymous'
= vite_typescript_tag 'public.tsx', crossorigin: 'anonymous'
- content_for :body_classes, 'modal-layout compose-standalone'

View File

@ -1,6 +1,6 @@
- content_for :header_tags do
= render_initial_state
= javascript_pack_tag 'public', crossorigin: 'anonymous'
= vite_typescript_tag 'public.tsx', crossorigin: 'anonymous'
- content_for :body_classes, 'player'

View File

@ -2,7 +2,7 @@
= t('settings.relationships')
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= vite_typescript_tag 'admin.tsx', crossorigin: 'anonymous'
.filters
.filter-subset

View File

@ -1,4 +1,4 @@
- content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/
= javascript_pack_tag 'remote_interaction_helper', crossorigin: 'anonymous'
= vite_typescript_tag 'remote_interaction_helper.ts', crossorigin: 'anonymous'

View File

@ -13,4 +13,4 @@
.actions
= f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
= javascript_pack_tag 'two_factor_authentication', crossorigin: 'anonymous'
= vite_typescript_tag 'two_factor_authentication.ts', crossorigin: 'anonymous'

View File

@ -1,15 +1,16 @@
- content_for :body_classes, 'app-body'
- content_for :header_tags do
- if user_signed_in?
= preload_pack_asset 'features/compose.js'
= preload_pack_asset 'features/home_timeline.js'
= preload_pack_asset 'features/notifications.js'
-#
= preload_pack_asset 'features/compose.js'
= preload_pack_asset 'features/home_timeline.js'
= preload_pack_asset 'features/notifications.js'
%meta{ name: 'initialPath', content: request.path }
%meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key }
= render_initial_state
= javascript_pack_tag 'application', crossorigin: 'anonymous'
= vite_typescript_tag 'application.ts', crossorigin: 'anonymous'
.notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } }
%noscript

View File

@ -1,5 +1,5 @@
- content_for :header_tags do
= render_initial_state
= javascript_pack_tag 'share', crossorigin: 'anonymous'
= vite_typescript_tag 'share.tsx', crossorigin: 'anonymous'
#mastodon-compose{ data: { props: Oj.dump(default_props) } }

27
bin/vite Executable file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'vite' is installed as part of a gem, and
# this file is here to facilitate running it.
#
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
bundle_binstub = File.expand_path("bundle", __dir__)
if File.file?(bundle_binstub)
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
load(bundle_binstub)
else
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
end
end
require "rubygems"
require "bundler/setup"
load Gem.bin_path("vite_ruby", "vite")

View File

@ -46,14 +46,13 @@ require_relative '../lib/chewy/settings_extensions'
require_relative '../lib/chewy/index_extensions'
require_relative '../lib/chewy/strategy/mastodon'
require_relative '../lib/chewy/strategy/bypass_with_warning'
require_relative '../lib/webpacker/manifest_extensions'
require_relative '../lib/webpacker/helper_extensions'
require_relative '../lib/rails/engine_extensions'
require_relative '../lib/action_dispatch/remote_ip_extensions'
require_relative '../lib/stoplight/redis_data_store_extensions'
require_relative '../lib/active_record/database_tasks_extensions'
require_relative '../lib/active_record/batches'
require_relative '../lib/simple_navigation/item_extensions'
require_relative '../lib/vite_ruby/sri_extensions'
Bundler.require(:pam_authentication) if ENV['PAM_ENABLED'] == 'true'

View File

@ -18,7 +18,6 @@ Rails.application.config.content_security_policy do |p|
p.frame_ancestors :none
p.font_src :self, assets_host
p.img_src :self, :data, :blob, *media_hosts
p.style_src :self, assets_host
p.media_src :self, :data, *media_hosts
p.manifest_src :self, assets_host
@ -32,16 +31,18 @@ Rails.application.config.content_security_policy do |p|
p.worker_src :self, :blob, assets_host
if Rails.env.development?
webpacker_public_host = ENV.fetch('WEBPACKER_DEV_SERVER_PUBLIC', Webpacker.config.dev_server[:public])
front_end_build_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{webpacker_public_host}" }
# Hacky solution to force CSP to correctly allow localhost, even if ViteRuby is bound to 0.0.0.0.
front_end_build_urls = %w(ws http).map { |protocol| "#{protocol}#{ViteRuby.config.https ? 's' : ''}://localhost:#{ViteRuby.config.port}" }
p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url, *front_end_build_urls
p.script_src :self, :unsafe_inline, :unsafe_eval, assets_host
p.frame_src :self, :https, :http
p.style_src :self, assets_host, :unsafe_inline
else
p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url
p.script_src :self, assets_host, "'wasm-unsafe-eval'"
p.frame_src :self, :https
p.style_src :self, assets_host
end
end

View File

@ -1,17 +1,20 @@
{
"all": {
"sourceCodeDir": "app/javascript",
"additionalEntrypoints": ["~/{icons,images}/**/*", "~/styles/*.scss"],
"additionalEntrypoints": ["~/{fonts,icons,images}/**/*", "~/styles/*.scss"],
"watchAdditionalPaths": []
},
"production": {
"publicOutputDir": "packs"
},
"development": {
"autoBuild": true,
"publicOutputDir": "vite-dev",
"publicOutputDir": "packs-dev",
"port": 3036
},
"test": {
"autoBuild": true,
"publicOutputDir": "vite-test",
"publicOutputDir": "packs-test",
"port": 3037
}
}

View File

@ -0,0 +1,82 @@
/* This plugin provides the `virtual:mastodon-sw-locales` import
which exports translations for every locales, but only with the
keys defined below.
This is used by the notifications code in the service-worker, to
provide localised texts without having to load all the translations
*/
import fs from 'node:fs';
import path from 'node:path';
import type { Plugin, ResolvedConfig } from 'vite';
const KEEP_KEYS = [
'notification.favourite',
'notification.follow',
'notification.follow_request',
'notification.mention',
'notification.reblog',
'notification.poll',
'notification.status',
'notification.update',
'notification.admin.sign_up',
'status.show_more',
'status.reblog',
'status.favourite',
'notifications.group',
];
export function MastodonServiceWorkerLocales(): Plugin {
const virtualModuleId = 'virtual:mastodon-sw-locales';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
let config: ResolvedConfig;
return {
name: 'mastodon-sw-locales',
configResolved(resolvedConfig) {
config = resolvedConfig;
},
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
return undefined;
},
load(id) {
if (id === resolvedVirtualModuleId) {
const filteredLocales: Record<string, Record<string, string>> = {};
const localesPath = path.resolve(config.root, 'mastodon/locales');
const filenames = fs.readdirSync(localesPath);
filenames
.filter((filename) => /[a-zA-Z-]+\.json$/.exec(filename))
.forEach((filename) => {
const content = fs.readFileSync(
path.resolve(localesPath, filename),
'utf-8',
);
const full = JSON.parse(content) as Record<string, string>;
const locale = filename.split('.')[0];
if (!locale)
throw new Error('Could not parse locale from filename');
const filteredLocale: Record<string, string> = {};
Object.entries(full).forEach(([key, value]) => {
if (KEEP_KEYS.includes(key)) filteredLocale[key] = value;
});
filteredLocales[locale] = filteredLocale;
});
return `const locales = ${JSON.stringify(filteredLocales)}; \n export default locales;`;
}
return undefined;
},
};
}

View File

@ -1,94 +0,0 @@
# Note: You must restart bin/webpack-dev-server for changes to take effect
default: &default
source_path: app/javascript
source_entry_path: entrypoints
public_root_path: public
public_output_path: packs
cache_path: tmp/cache/webpacker
check_yarn_integrity: false
webpack_compile_output: false
# Additional paths webpack should lookup modules
# ['app/assets', 'engine/foo/app/assets']
resolved_paths: []
# Cache manifest.json for performance
cache_manifest: true
# Extract and emit a css file
extract_css: true
static_assets_extensions:
- .jpg
- .jpeg
- .png
- .tiff
- .ico
- .svg
- .eot
- .otf
- .ttf
- .woff
- .woff2
extensions:
- .mjs
- .js
- .jsx
- .ts
- .tsx
- .sass
- .scss
- .css
- .module.sass
- .module.scss
- .module.css
- .png
- .svg
- .gif
- .jpeg
- .jpg
development:
<<: *default
compile: true
# Reload manifest in development environment so we pick up changes
cache_manifest: false
# Reference: https://webpack.js.org/configuration/dev-server/
dev_server:
https: false
host: 0.0.0.0
port: 3035
public: localhost:3035
hmr: false
# Inline should be set to true if using HMR
inline: true
overlay: true
compress: true
disable_host_check: true
use_local_ip: false
quiet: false
headers:
'Access-Control-Allow-Origin': '*'
watch_options:
ignored: '**/node_modules/**'
test:
<<: *default
# CI precompiles packs prior to running the tests.
# Also avoids race conditions in parallel_tests.
compile: false
# Compile test packs to a separate directory
public_output_path: packs-test
production:
<<: *default
# Production depends on precompilation of packs prior to booting for performance.
compile: false

View File

@ -2,21 +2,26 @@
module PremailerBundledAssetStrategy
def load(url)
asset_host = ENV['CDN_HOST'] || ENV['WEB_DOMAIN'] || ENV.fetch('LOCAL_DOMAIN', nil)
if ViteRuby.instance.dev_server_running?
# Request from the dev server
return unless url.start_with?("/#{ViteRuby.config.public_output_dir}/")
if Webpacker.dev_server.running?
asset_host = "#{Webpacker.dev_server.protocol}://#{Webpacker.dev_server.host_with_port}"
url = File.join(asset_host, url)
headers = {}
# Vite dev server wants this header for CSS files, otherwise it will respond with a JS file that inserts the CSS (to support hot reloading)
headers['Accept'] = 'text/css' if url.end_with?('.scss', '.css')
Net::HTTP.get(
URI("#{ViteRuby.config.origin}#{url}"),
headers
).presence
else
path = Rails.public_path.join(url.delete_prefix('/'))
return unless path.exist?
path.read
end
css = if url.start_with?('http')
HTTP.get(url).to_s
else
url = url[1..] if url.start_with?('/')
Rails.public_path.join(url).read
end
css.gsub(%r{url\(/}, "url(#{asset_host}/")
rescue ViteRuby::MissingEntrypointError
# If the path is not in the manifest, ignore it
end
module_function :load

View File

@ -14,7 +14,9 @@ end
if Rake::Task.task_defined?('assets:precompile')
Rake::Task['assets:precompile'].enhance do
Webpacker.manifest.refresh
Rake::Task['assets:generate_static_pages'].invoke
end
end
# We don't want vite_ruby to run yarn, we do that in a separate step
Rake::Task['vite:install_dependencies'].clear

View File

@ -1,34 +0,0 @@
# frozen_string_literal: true
# Disable this task as we use pnpm
require 'semantic_range'
Rake::Task['webpacker:check_yarn'].clear
namespace :webpacker do
desc 'Verifies if Yarn is installed'
task check_yarn: :environment do
begin
yarn_version = `yarn --version`.strip
raise Errno::ENOENT if yarn_version.blank?
yarn_range = '>=4 <5'
is_valid = begin
SemanticRange.satisfies?(yarn_version, yarn_range)
rescue
false
end
unless is_valid
warn "Mastodon and Webpacker requires Yarn \"#{yarn_range}\" and you are using #{yarn_version}"
warn 'Exiting!'
exit!
end
rescue Errno::ENOENT
warn 'Yarn not installed. Please see the Mastodon documentation to install the correct version.'
warn 'Exiting!'
exit!
end
end
end

View File

@ -0,0 +1,100 @@
# frozen_string_literal: true
module ViteRuby::ManifestIntegrityExtension
def path_and_integrity_for(name, **)
entry = lookup!(name, **)
{ path: entry.fetch('file'), integrity: entry.fetch('integrity', nil) }
end
# Find a manifest entry by the *final* file name
def integrity_hash_for_file(file_name)
@integrity_cache ||= {}
@integrity_cache[file_name] ||= begin
entry = manifest.find { |_key, entry| entry['file'] == file_name }
entry[1].fetch('integrity', nil) if entry
end
end
def resolve_entries_with_integrity(*names, **options)
entries = names.map { |name| lookup!(name, **options) }
script_paths = entries.map do |entry|
{
file: entry.fetch('file'),
# TODO: Secure this so we require the integrity hash outside of dev
integrity: entry['integrity'],
}
end
imports = dev_server_running? ? [] : entries.flat_map { |entry| entry['imports'] }.compact
{
scripts: script_paths,
imports: imports.filter_map { |entry| { file: entry.fetch('file'), integrity: entry.fetch('integrity') } }.uniq,
stylesheets: dev_server_running? ? [] : (entries + imports).flat_map { |entry| entry['css'] }.compact.uniq,
}
end
end
ViteRuby::Manifest.prepend ViteRuby::ManifestIntegrityExtension
module ViteRails::TagHelpers::IntegrityExtension
def vite_javascript_tag(*names,
type: 'module',
asset_type: :javascript,
skip_preload_tags: false,
skip_style_tags: false,
crossorigin: 'anonymous',
media: 'screen',
**options)
entries = vite_manifest.resolve_entries_with_integrity(*names, type: asset_type)
''.html_safe.tap do |tags|
entries.fetch(:scripts).each do |script|
tags << javascript_include_tag(
script[:file],
integrity: script[:integrity],
crossorigin: crossorigin,
type: type,
extname: false,
**options
)
end
unless skip_preload_tags
entries.fetch(:imports).each do |import|
tags << vite_preload_tag(import[:file], integrity: import[:integrity], crossorigin: crossorigin, **options)
end
end
options[:extname] = false if Rails::VERSION::MAJOR >= 7
unless skip_style_tags
entries.fetch(:stylesheets).each do |stylesheet|
# This is for stylesheets imported from Javascript. The entry for the JS entrypoint only contains the final CSS file name, so we need to look it up in the manifest
tags << stylesheet_link_tag(
stylesheet,
integrity: vite_manifest.integrity_hash_for_file(stylesheet),
media: media,
**options
)
end
end
end
end
def vite_stylesheet_tag(*names, **options)
''.html_safe.tap do |tags|
names.each do |name|
entry = vite_manifest.path_and_integrity_for(name, type: :stylesheet)
options[:extname] = false if Rails::VERSION::MAJOR >= 7
tags << stylesheet_link_tag(entry[:path], integrity: entry[:integrity], **options)
end
end
end
end
ViteRails::TagHelpers.prepend ViteRails::TagHelpers::IntegrityExtension

View File

@ -1,27 +0,0 @@
# frozen_string_literal: true
module Webpacker::HelperExtensions
def javascript_pack_tag(name, **options)
src, integrity = current_webpacker_instance.manifest.lookup!(name, type: :javascript, with_integrity: true)
javascript_include_tag(src, options.merge(integrity: integrity))
end
def stylesheet_pack_tag(name, **options)
src, integrity = current_webpacker_instance.manifest.lookup!(name, type: :stylesheet, with_integrity: true)
stylesheet_link_tag(src, options.merge(integrity: integrity))
end
def preload_pack_asset(name, **options)
src, integrity = current_webpacker_instance.manifest.lookup!(name, with_integrity: true)
# This attribute will only work if the assets are on a different domain.
# And Webpack will (correctly) only add it in this case, so we need to conditionally set it here
# otherwise the preloaded request and the real request will have different crossorigin values
# and the preloaded file wont be loaded
crossorigin = 'anonymous' if Rails.configuration.action_controller.asset_host.present?
preload_link_tag(src, options.merge(integrity: integrity, crossorigin: crossorigin))
end
end
Webpacker::Helper.prepend(Webpacker::HelperExtensions)

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
module Webpacker::ManifestExtensions
def lookup(name, pack_type = {})
asset = super
if pack_type[:with_integrity] && asset.respond_to?(:dig)
[asset['src'], asset['integrity']]
elsif asset.respond_to?(:dig)
asset['src']
else
asset
end
end
end
Webpacker::Manifest.prepend(Webpacker::ManifestExtensions)

View File

@ -11,8 +11,8 @@
],
"scripts": {
"dev": "vite dev",
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development ./bin/vite build",
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production ./bin/vite build",
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development vite build",
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production vite build",
"fix:js": "eslint . --cache --fix",
"fix:css": "stylelint --fix \"**/*.{css,scss}\"",
"fix": "yarn fix:js && yarn fix:css",
@ -42,13 +42,18 @@
"@formatjs/intl-pluralrules": "^5.4.4",
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",
"@optimize-lodash/rollup-plugin": "^5.0.2",
"@rails/ujs": "7.1.501",
"@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "^4.2.1",
"arrow-key-navigation": "^1.2.0",
"async-mutex": "^0.5.0",
"axios": "^1.4.0",
"babel-plugin-formatjs": "^10.5.37",
"babel-plugin-preval": "^5.1.0",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"blurhash": "^2.0.5",
"classnames": "^2.3.2",
"color-blend": "^4.0.0",
@ -101,10 +106,13 @@
"tiny-queue": "^0.2.1",
"twitter-text": "3.1.0",
"use-debounce": "^10.0.0",
"vite": "^6.2.6",
"vite": "^6.3.0",
"vite-bundle-analyzer": "^0.18.1",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-rails": "^0.5.0",
"vite-plugin-svgr": "^4.3.0",
"wicg-inert": "^3.1.2",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0",
"workbox-window": "^7.0.0"
@ -113,7 +121,6 @@
"@eslint/js": "^9.23.0",
"@formatjs/cli": "^6.1.1",
"@testing-library/dom": "^10.2.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@types/emoji-mart": "3.0.14",
"@types/escape-html": "^1.0.2",
@ -138,7 +145,6 @@
"@types/react-toggle": "^4.0.3",
"@types/redux-immutable": "^4.0.3",
"@types/requestidlecallback": "^0.3.5",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^9.23.0",
"eslint-import-resolver-typescript": "^4.2.5",
"eslint-plugin-formatjs": "^5.3.1",
@ -158,9 +164,7 @@
"stylelint-config-standard-scss": "^14.0.0",
"typescript": "~5.7.3",
"typescript-eslint": "^8.29.1",
"vite-plugin-rails": "^0.5.0",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^3.1.1"
"vitest": "^3.1.2"
},
"resolutions": {
"@types/react": "^18.2.7",

View File

@ -17,7 +17,7 @@ RSpec.describe ThemeHelper do
)
expect(html_links.last.attributes.symbolize_keys)
.to include(
href: have_attributes(value: match(/default/)),
href: have_attributes(value: match(/application/)),
media: have_attributes(value: '(prefers-color-scheme: dark)')
)
end

View File

@ -1,22 +1,23 @@
/// <reference types="vitest" />
import path from 'node:path';
import fs from 'node:fs/promises';
import path from 'node:path';
import { optimizeLodashImports } from '@optimize-lodash/rollup-plugin';
import react from '@vitejs/plugin-react';
import { PluginOption } from 'vite';
import svgr from 'vite-plugin-svgr';
import {
defineConfig,
configDefaults,
UserConfigFnPromise,
} from 'vitest/config';
import { analyzer } from 'vite-bundle-analyzer';
import RailsPlugin from 'vite-plugin-rails';
import { VitePWA } from 'vite-plugin-pwa';
import { defineConfig, UserConfigFnPromise, UserConfig } from 'vite';
import postcssPresetEnv from 'postcss-preset-env';
import { manifestSRI } from './config/vite/plugin-manifest-sri';
import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales';
const entrypointRoot = path.resolve(__dirname, 'app/javascript/entrypoints');
const jsRoot = path.resolve(__dirname, 'app/javascript');
const entrypointRoot = path.resolve(jsRoot, 'entrypoints');
const config: UserConfigFnPromise = async () => {
export const config: UserConfigFnPromise = async ({ mode, command }) => {
const entrypointFiles = await fs.readdir(entrypointRoot);
const entrypoints: Record<string, string> = entrypointFiles.reduce(
(acc, file) => {
@ -27,7 +28,7 @@ const config: UserConfigFnPromise = async () => {
{} as Record<string, string>,
);
return {
root: path.resolve(__dirname, 'app/javascript'),
root: jsRoot,
css: {
postcss: {
plugins: [
@ -41,15 +42,22 @@ const config: UserConfigFnPromise = async () => {
},
resolve: {
alias: {
mastodon: path.resolve(__dirname, 'app/javascript/mastodon'),
'@': path.resolve(__dirname, 'app/javascript'),
mastodon: path.resolve(jsRoot, 'mastodon'),
'@': jsRoot,
},
},
server: {
headers: {
// This is needed in dev environment because we load the worker from `/dev-sw/dev-sw.js`,
// but it needs to be scoped to the whole domain
'Service-Worker-Allowed': '/',
},
},
build: {
commonjsOptions: { transformMixedEsModules: true },
outDir: path.resolve(__dirname, '.dist'),
emptyOutDir: true,
chunkSizeWarningLimit: 1 * 1024 * 1024, // 1MB
manifest: 'manifest.json',
sourcemap: true,
rollupOptions: {
input: entrypoints,
output: {
@ -90,25 +98,46 @@ const config: UserConfigFnPromise = async () => {
},
},
},
plugins: [react(), svgr(), manifestSRI()],
test: {
environment: 'jsdom',
include: [
...configDefaults.include,
'**/__tests__/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
],
exclude: [
...configDefaults.exclude,
'**/node_modules/**',
'vendor/**',
'config/**',
'log/**',
'public/**',
'tmp/**',
],
globals: true,
},
};
plugins: [
RailsPlugin({
compress: mode !== 'production' && command === 'build',
}),
react({
babel: {
plugins: ['formatjs', 'transform-react-remove-prop-types'],
},
}),
MastodonServiceWorkerLocales(),
VitePWA({
srcDir: 'mastodon/service_worker',
// We need to use injectManifest because we use our own service worker
strategies: 'injectManifest',
manifest: false,
injectRegister: false,
injectManifest: {
// Do not inject a manifest, we dont use precache
injectionPoint: undefined,
buildPlugins: {
vite: [
// Provide a virtual import with only the locales used in the ServiceWorker
MastodonServiceWorkerLocales(),
],
},
// Force the output location, because we have a symlink in `public/sw.js`
},
outDir: path.resolve(__dirname, 'public/packs'),
devOptions: {
enabled: true,
type: 'module',
},
}),
svgr(),
// manifestSRI(),
// Old library types need to be converted
optimizeLodashImports() as PluginOption,
!!process.env.ANALYZE_BUNDLE_SIZE && analyzer({ analyzerMode: 'static' }),
],
} satisfies UserConfig;
};
export default defineConfig(config);

26
vitest.config.mts Normal file
View File

@ -0,0 +1,26 @@
import { configDefaults, defineConfig } from 'vitest/config';
import { config as viteConfig } from './vite.config.mjs';
export default defineConfig(async (context) => {
return {
...(await viteConfig(context)),
test: {
environment: 'jsdom',
include: [
...configDefaults.include,
'**/__tests__/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
],
exclude: [
...configDefaults.exclude,
'**/node_modules/**',
'vendor/**',
'config/**',
'log/**',
'public/**',
'tmp/**',
],
globals: true,
},
};
});

2766
yarn.lock

File diff suppressed because it is too large Load Diff