Convert from Webpack to Vite (#34450)

Co-authored-by: Renaud Chaput <renchap@gmail.com>
This commit is contained in:
Echo 2025-05-16 15:26:12 +02:00 committed by GitHub
parent a5a2c6dc7e
commit c4f47adb49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 2031 additions and 7424 deletions

View file

@ -9,6 +9,7 @@ services:
environment: environment:
RAILS_ENV: development RAILS_ENV: development
NODE_ENV: development NODE_ENV: development
VITE_RUBY_HOST: 0.0.0.0
BIND: 0.0.0.0 BIND: 0.0.0.0
BOOTSNAP_CACHE_DIR: /tmp BOOTSNAP_CACHE_DIR: /tmp
REDIS_HOST: redis REDIS_HOST: redis
@ -22,11 +23,12 @@ services:
ES_PORT: '9200' ES_PORT: '9200'
LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000} LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000}
VITE_DEV_SERVER_PUBLIC: ${VITE_DEV_SERVER_PUBLIC:-localhost:3036}
# Overrides default command so things don't shut down after the process ends. # Overrides default command so things don't shut down after the process ends.
command: sleep infinity command: sleep infinity
ports: ports:
- '3000:3000' - '3000:3000'
- '3035:3035' - '3036:3036'
- '4000:4000' - '4000:4000'
networks: networks:
- external_network - external_network

View file

@ -25,23 +25,6 @@
'tesseract.js', // Requires code changes 'tesseract.js', // Requires code changes
'react-hotkeys', // Requires code changes 'react-hotkeys', // Requires code changes
// Requires Webpacker upgrade or replacement
'@svgr/webpack',
'@types/webpack',
'babel-loader',
'compression-webpack-plugin',
'css-loader',
'imports-loader',
'mini-css-extract-plugin',
'postcss-loader',
'sass-loader',
'terser-webpack-plugin',
'webpack',
'webpack-assets-manifest',
'webpack-bundle-analyzer',
'webpack-dev-server',
'webpack-cli',
// react-router: Requires manual upgrade // react-router: Requires manual upgrade
'history', 'history',
'react-router-dom', 'react-router-dom',

View file

@ -49,7 +49,7 @@ jobs:
public/assets public/assets
public/packs public/packs
public/packs-test public/packs-test
tmp/cache/webpacker tmp/cache/vite
key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }} key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
restore-keys: | restore-keys: |
${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }} ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
@ -63,7 +63,7 @@ jobs:
- name: Archive asset artifacts - name: Archive asset artifacts
run: | 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 - uses: actions/upload-artifact@v4
if: matrix.mode == 'test' if: matrix.mode == 'test'

3
.gitignore vendored
View file

@ -21,10 +21,11 @@
/public/system /public/system
/public/assets /public/assets
/public/packs /public/packs
/public/packs-dev
/public/packs-test /public/packs-test
.env .env
.env.production .env.production
/node_modules/ node_modules/
/build/ /build/
# Ignore Vagrant files # Ignore Vagrant files

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb
sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq
stream: env PORT=4000 yarn workspace @mastodon/streaming start stream: env PORT=4000 yarn workspace @mastodon/streaming start
webpack: bin/webpack-dev-server vite: yarn dev

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import './public-path';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs'; import Rails from '@rails/ujs';
@ -273,7 +272,7 @@ async function mountReactComponent(element: Element) {
); );
const { default: Component } = (await import( const { default: Component } = (await import(
`@/mastodon/components/admin/${componentName}` `@/mastodon/components/admin/${componentName}.jsx`
)) as { default: React.ComponentType }; )) as { default: React.ComponentType };
const root = createRoot(element); const root = createRoot(element);

View file

@ -1,11 +1,6 @@
import './public-path'; import { loadLocale } from 'mastodon/locales';
import main from 'mastodon/main'; import main from 'mastodon/main';
import { loadPolyfills } from 'mastodon/polyfills';
import { start } from '../mastodon/common';
import { loadLocale } from '../mastodon/locales';
import { loadPolyfills } from '../mastodon/polyfills';
start();
loadPolyfills() loadPolyfills()
.then(loadLocale) .then(loadLocale)

View file

@ -0,0 +1,3 @@
import { start } from 'mastodon/common';
start();

View file

@ -1,15 +1,11 @@
import './public-path';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterInitialRender } from 'mastodon/hooks/useRenderSignal'; import { afterInitialRender } from 'mastodon/hooks/useRenderSignal';
import { start } from '../mastodon/common';
import { Status } from '../mastodon/features/standalone/status'; import { Status } from '../mastodon/features/standalone/status';
import { loadPolyfills } from '../mastodon/polyfills'; import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
start();
function loaded() { function loaded() {
const mountNode = document.getElementById('mastodon-status'); const mountNode = document.getElementById('mastodon-status');

View file

@ -1,4 +1,3 @@
import './public-path';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
ready(() => { ready(() => {

View file

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

View file

@ -1,3 +0,0 @@
import '../styles/mailer.scss';
require.context('../icons');

View file

@ -1,23 +0,0 @@
// Dynamically set webpack's loading path depending on a meta header, in order
// to share the same assets regardless of instance configuration.
// See https://webpack.js.org/guides/public-path/#on-the-fly
function removeOuterSlashes(string: string) {
return string.replace(/^\/*/, '').replace(/\/*$/, '');
}
function formatPublicPath(host = '', path = '') {
let formattedHost = removeOuterSlashes(host);
if (formattedHost && !/^http/i.test(formattedHost)) {
formattedHost = `//${formattedHost}`;
}
const formattedPath = removeOuterSlashes(path);
return `${formattedHost}/${formattedPath}/`;
}
const cdnHost = document.querySelector<HTMLMetaElement>('meta[name=cdn-host]');
__webpack_public_path__ = formatPublicPath(
cdnHost ? cdnHost.content : '',
process.env.PUBLIC_OUTPUT_PATH,
);

View file

@ -1,7 +1,5 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './public-path';
import { IntlMessageFormat } from 'intl-messageformat'; import { IntlMessageFormat } from 'intl-messageformat';
import type { MessageDescriptor, PrimitiveType } from 'react-intl'; import type { MessageDescriptor, PrimitiveType } from 'react-intl';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
@ -10,7 +8,6 @@ import Rails from '@rails/ujs';
import axios from 'axios'; import axios from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { start } from '../mastodon/common';
import { timeAgoString } from '../mastodon/components/relative_timestamp'; import { timeAgoString } from '../mastodon/components/relative_timestamp';
import emojify from '../mastodon/features/emoji/emoji'; import emojify from '../mastodon/features/emoji/emoji';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
@ -20,8 +17,6 @@ import ready from '../mastodon/ready';
import 'cocoon-js-vanilla'; import 'cocoon-js-vanilla';
start();
const messages = defineMessages({ const messages = defineMessages({
usernameTaken: { usernameTaken: {
id: 'username.taken', id: 'username.taken',
@ -153,9 +148,7 @@ function loaded() {
const reactComponents = document.querySelectorAll('[data-component]'); const reactComponents = document.querySelectorAll('[data-component]');
if (reactComponents.length > 0) { if (reactComponents.length > 0) {
import( import('../mastodon/containers/media_container')
/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container'
)
.then(({ default: MediaContainer }) => { .then(({ default: MediaContainer }) => {
reactComponents.forEach((component) => { reactComponents.forEach((component) => {
Array.from(component.children).forEach((child) => { Array.from(component.children).forEach((child) => {

View file

@ -8,8 +8,6 @@ and performs no other task.
*/ */
import './public-path';
import axios from 'axios'; import axios from 'axios';
interface JRDLink { interface JRDLink {

View file

@ -1,13 +1,9 @@
import './public-path';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { start } from '../mastodon/common';
import ComposeContainer from '../mastodon/containers/compose_container'; import ComposeContainer from '../mastodon/containers/compose_container';
import { loadPolyfills } from '../mastodon/polyfills'; import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
start();
function loaded() { function loaded() {
const mountNode = document.getElementById('mastodon-compose'); const mountNode = document.getElementById('mastodon-compose');

View file

@ -1,4 +1,3 @@
import './public-path';
import axios from 'axios'; import axios from 'axios';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';

View file

@ -1,8 +1,6 @@
import Rails from '@rails/ujs'; import Rails from '@rails/ujs';
export function start() { export function start() {
require.context('../images/', true, /\.(jpg|png|svg)$/);
try { try {
Rails.start(); Rails.start();
} catch { } catch {

View file

@ -9,7 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; 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 BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.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 { useSpring, animated } from '@react-spring/web';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import { length } from 'stringz'; 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 { showAlertForError } from 'mastodon/actions/alerts';
import { uploadThumbnail } from 'mastodon/actions/compose'; import { uploadThumbnail } from 'mastodon/actions/compose';
@ -350,9 +346,15 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
fetchTesseract() fetchTesseract()
.then(async ({ createWorker }) => { .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, { const worker = await createWorker('eng', 1, {
workerPath: tesseractWorkerPath as string, workerPath: tesseractWorkerPath.default,
corePath: tesseractCorePath as string, corePath: tesseractCorePath.default,
langPath: `${assetHost}/ocr/lang-data`, langPath: `${assetHost}/ocr/lang-data`,
cacheMethod: 'write', cacheMethod: 'write',
}); });
@ -501,5 +503,4 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
); );
}, },
); );
AltTextModal.displayName = 'AltTextModal'; AltTextModal.displayName = 'AltTextModal';

View file

@ -9,28 +9,27 @@
// to ensure that the prevaled file is regenerated by Babel // to ensure that the prevaled file is regenerated by Babel
// version: 4 // version: 4
const { NimbleEmojiIndex } = require('emoji-mart'); import { NimbleEmojiIndex } from 'emoji-mart';
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data'); import { uncompress as emojiMartUncompress } from 'emoji-mart/dist/utils/data';
import data from './emoji_data.json';
let data = require('./emoji_data.json'); import emojiMap from './emoji_map.json';
const emojiMap = require('./emoji_map.json'); import { unicodeToFilename } from './unicode_to_filename';
const { unicodeToFilename } = require('./unicode_to_filename'); import { unicodeToUnifiedName } from './unicode_to_unified_name';
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
emojiMartUncompress(data); emojiMartUncompress(data);
const emojiMartData = data; const emojiMartData = data;
const emojiIndex = new NimbleEmojiIndex(emojiMartData); const emojiIndex = new NimbleEmojiIndex(emojiMartData);
const excluded = ['®', '©', '™']; const excluded = ['®', '©', '™'];
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿']; const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {}; const shortcodeMap = {};
const shortCodesToEmojiData = {}; const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = []; const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiIndex.emojis).forEach((key) => {
let emoji = emojiIndex.emojis[key]; let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this // Emojis with skin tone modifiers are stored like this
@ -41,22 +40,22 @@ Object.keys(emojiIndex.emojis).forEach(key => {
shortcodeMap[emoji.native] = emoji.id; shortcodeMap[emoji.native] = emoji.id;
}); });
const stripModifiers = unicode => { const stripModifiers = (unicode) => {
skinTones.forEach(tone => { skinTones.forEach((tone) => {
unicode = unicode.replace(tone, ''); unicode = unicode.replace(tone, '');
}); });
return unicode; return unicode;
}; };
Object.keys(emojiMap).forEach(key => { Object.keys(emojiMap).forEach((key) => {
if (excluded.includes(key)) { if (excluded.includes(key)) {
delete emojiMap[key]; delete emojiMap[key];
return; return;
} }
const normalizedKey = stripModifiers(key); const normalizedKey = stripModifiers(key);
let shortcode = shortcodeMap[normalizedKey]; let shortcode = shortcodeMap[normalizedKey];
if (!shortcode) { if (!shortcode) {
shortcode = shortcodeMap[normalizedKey + '\uFE0F']; shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
@ -82,7 +81,7 @@ Object.keys(emojiMap).forEach(key => {
} }
}); });
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiIndex.emojis).forEach((key) => {
let emoji = emojiIndex.emojis[key]; let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this // Emojis with skin tone modifiers are stored like this
@ -94,9 +93,11 @@ Object.keys(emojiIndex.emojis).forEach(key => {
let { short_names, search, unified } = emojiMartData.emojis[key]; let { short_names, search, unified } = emojiMartData.emojis[key];
if (short_names[0] !== key) { if (short_names[0] !== key) {
throw new Error('The compressor expects the first short_code to be the ' + throw new Error(
'key. It may need to be rewritten if the emoji change such that this ' + 'The compressor expects the first short_code to be the ' +
'is no longer the case.'); 'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.',
);
} }
short_names = short_names.slice(1); // first short name can be inferred from the key short_names = short_names.slice(1); // first short name can be inferred from the key
@ -117,20 +118,22 @@ Object.keys(emojiIndex.emojis).forEach(key => {
// JSON.parse/stringify is to emulate what @preval is doing and avoid any // JSON.parse/stringify is to emulate what @preval is doing and avoid any
// inconsistent behavior in dev mode // inconsistent behavior in dev mode
module.exports = JSON.parse(JSON.stringify([ export default JSON.parse(
shortCodesToEmojiData, JSON.stringify([
/* shortCodesToEmojiData,
* The property `skins` is not found in the current context. /*
* This could potentially lead to issues when interacting with modules or data structures * The property `skins` is not found in the current context.
* that expect the presence of `skins` property. * This could potentially lead to issues when interacting with modules or data structures
* Currently, no definitions or references to `skins` property can be found in: * that expect the presence of `skins` property.
* - {@link node_modules/emoji-mart/dist/utils/data.js} * Currently, no definitions or references to `skins` property can be found in:
* - {@link node_modules/emoji-mart/data/all.json} * - {@link node_modules/emoji-mart/dist/utils/data.js}
* - {@link app/javascript/mastodon/features/emoji/emoji_compressed.d.ts#Skins} * - {@link node_modules/emoji-mart/data/all.json}
* Future refactorings or updates should consider adding definitions or handling for `skins` property. * - {@link app/javascript/mastodon/features/emoji/emoji_compressed.d.ts#Skins}
*/ * Future refactorings or updates should consider adding definitions or handling for `skins` property.
emojiMartData.skins, */
emojiMartData.categories, emojiMartData.skins,
emojiMartData.aliases, emojiMartData.categories,
emojisWithoutShortCodes emojiMartData.aliases,
])); emojisWithoutShortCodes,
]),
);

View file

@ -3,9 +3,13 @@
// emojiIndex.search functionality. // emojiIndex.search functionality.
import type { BaseEmoji } from 'emoji-mart'; import type { BaseEmoji } from 'emoji-mart';
import type { Emoji } from 'emoji-mart/dist-es/utils/data'; import type { Emoji } from 'emoji-mart/dist-es/utils/data';
// eslint-disable-next-line import/no-unresolved
import emojiCompressed from 'virtual:mastodon-emoji-compressed';
import type {
Search,
ShortCodesToEmojiData,
} from 'virtual:mastodon-emoji-compressed';
import type { Search, ShortCodesToEmojiData } from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name'; import { unicodeToUnifiedName } from './unicode_to_unified_name';
type Emojis = Record< type Emojis = Record<

View file

@ -2,11 +2,13 @@
// (i.e. the svg filename) and a shortCode intended to be shown // (i.e. the svg filename) and a shortCode intended to be shown
// as a "title" attribute in an HTML element (aka tooltip). // as a "title" attribute in an HTML element (aka tooltip).
// eslint-disable-next-line import/no-unresolved
import emojiCompressed from 'virtual:mastodon-emoji-compressed';
import type { import type {
FilenameData, FilenameData,
ShortCodesToEmojiDataKey, ShortCodesToEmojiDataKey,
} from './emoji_compressed'; } from 'virtual:mastodon-emoji-compressed';
import emojiCompressed from './emoji_compressed';
import { unicodeToFilename } from './unicode_to_filename'; import { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = Record< type UnicodeMapping = Record<

View file

@ -1,6 +1,6 @@
// taken from: // taken from:
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866 // https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
exports.unicodeToFilename = (str) => { export const unicodeToFilename = (str) => {
let result = ''; let result = '';
let charCode = 0; let charCode = 0;
let p = 0; let p = 0;

View file

@ -6,7 +6,7 @@ function padLeft(str, num) {
return str; return str;
} }
exports.unicodeToUnifiedName = (str) => { export const unicodeToUnifiedName = (str) => {
let output = ''; let output = '';
for (let i = 0; i < str.length; i += 2) { for (let i = 0; i < str.length; i += 2) {

View file

@ -1,235 +1,235 @@
export function EmojiPicker () { export function EmojiPicker () {
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker'); return import('../../emoji/emoji_picker');
} }
export function Compose () { export function Compose () {
return import(/* webpackChunkName: "features/compose" */'../../compose'); return import('../../compose');
} }
export function Notifications () { export function Notifications () {
return import(/* webpackChunkName: "features/notifications" */'../../notifications_v2'); return import('../../notifications_v2');
} }
export function HomeTimeline () { export function HomeTimeline () {
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline'); return import('../../home_timeline');
} }
export function PublicTimeline () { export function PublicTimeline () {
return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline'); return import('../../public_timeline');
} }
export function CommunityTimeline () { export function CommunityTimeline () {
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline'); return import('../../community_timeline');
} }
export function Firehose () { export function Firehose () {
return import(/* webpackChunkName: "features/firehose" */'../../firehose'); return import('../../firehose');
} }
export function HashtagTimeline () { export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); return import('../../hashtag_timeline');
} }
export function DirectTimeline() { export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline'); return import('../../direct_timeline');
} }
export function ListTimeline () { export function ListTimeline () {
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline'); return import('../../list_timeline');
} }
export function Lists () { export function Lists () {
return import(/* webpackChunkName: "features/lists" */'../../lists'); return import('../../lists');
} }
export function Status () { export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status'); return import('../../status');
} }
export function GettingStarted () { export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started'); return import('../../getting_started');
} }
export function KeyboardShortcuts () { export function KeyboardShortcuts () {
return import(/* webpackChunkName: "features/keyboard_shortcuts" */'../../keyboard_shortcuts'); return import('../../keyboard_shortcuts');
} }
export function PinnedStatuses () { export function PinnedStatuses () {
return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses'); return import('../../pinned_statuses');
} }
export function AccountTimeline () { export function AccountTimeline () {
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline'); return import('../../account_timeline');
} }
export function AccountGallery () { export function AccountGallery () {
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery'); return import('../../account_gallery');
} }
export function AccountFeatured() { export function AccountFeatured() {
return import(/* webpackChunkName: "features/account_featured" */'../../account_featured'); return import('../../account_featured');
} }
export function Followers () { export function Followers () {
return import(/* webpackChunkName: "features/followers" */'../../followers'); return import('../../followers');
} }
export function Following () { export function Following () {
return import(/* webpackChunkName: "features/following" */'../../following'); return import('../../following');
} }
export function Reblogs () { export function Reblogs () {
return import(/* webpackChunkName: "features/reblogs" */'../../reblogs'); return import('../../reblogs');
} }
export function Favourites () { export function Favourites () {
return import(/* webpackChunkName: "features/favourites" */'../../favourites'); return import('../../favourites');
} }
export function FollowRequests () { export function FollowRequests () {
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); return import('../../follow_requests');
} }
export function FavouritedStatuses () { export function FavouritedStatuses () {
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses'); return import('../../favourited_statuses');
} }
export function FollowedTags () { export function FollowedTags () {
return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags'); return import('../../followed_tags');
} }
export function BookmarkedStatuses () { export function BookmarkedStatuses () {
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses'); return import('../../bookmarked_statuses');
} }
export function Blocks () { export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks'); return import('../../blocks');
} }
export function DomainBlocks () { export function DomainBlocks () {
return import(/* webpackChunkName: "features/domain_blocks" */'../../domain_blocks'); return import('../../domain_blocks');
} }
export function Mutes () { export function Mutes () {
return import(/* webpackChunkName: "features/mutes" */'../../mutes'); return import('../../mutes');
} }
export function MuteModal () { export function MuteModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal'); return import('../components/mute_modal');
} }
export function BlockModal () { export function BlockModal () {
return import(/* webpackChunkName: "modals/block_modal" */'../components/block_modal'); return import('../components/block_modal');
} }
export function DomainBlockModal () { export function DomainBlockModal () {
return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/domain_block_modal'); return import('../components/domain_block_modal');
} }
export function ReportModal () { export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); return import('../components/report_modal');
} }
export function IgnoreNotificationsModal () { export function IgnoreNotificationsModal () {
return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/ignore_notifications_modal'); return import('../components/ignore_notifications_modal');
} }
export function MediaGallery () { export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); return import('../../../components/media_gallery');
} }
export function Video () { export function Video () {
return import(/* webpackChunkName: "features/video" */'../../video'); return import('../../video');
} }
export function EmbedModal () { export function EmbedModal () {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); return import('../components/embed_modal');
} }
export function ListAdder () { export function ListAdder () {
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); return import('../../list_adder');
} }
export function Tesseract () { export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js'); return import('tesseract.js');
} }
export function Audio () { export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio'); return import('../../audio');
} }
export function Directory () { export function Directory () {
return import(/* webpackChunkName: "features/directory" */'../../directory'); return import('../../directory');
} }
export function OnboardingProfile () { export function OnboardingProfile () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/profile'); return import('../../onboarding/profile');
} }
export function OnboardingFollows () { export function OnboardingFollows () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/follows'); return import('../../onboarding/follows');
} }
export function CompareHistoryModal () { export function CompareHistoryModal () {
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal'); return import('../components/compare_history_modal');
} }
export function Explore () { export function Explore () {
return import(/* webpackChunkName: "features/explore" */'../../explore'); return import('../../explore');
} }
export function Search () { export function Search () {
return import(/* webpackChunkName: "features/explore" */'../../search'); return import('../../search');
} }
export function FilterModal () { export function FilterModal () {
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal'); return import('../components/filter_modal');
} }
export function InteractionModal () { export function InteractionModal () {
return import(/*webpackChunkName: "modals/interaction_modal" */'../../interaction_modal'); return import('../../interaction_modal');
} }
export function SubscribedLanguagesModal () { export function SubscribedLanguagesModal () {
return import(/*webpackChunkName: "modals/subscribed_languages_modal" */'../../subscribed_languages_modal'); return import('../../subscribed_languages_modal');
} }
export function ClosedRegistrationsModal () { export function ClosedRegistrationsModal () {
return import(/*webpackChunkName: "modals/closed_registrations_modal" */'../../closed_registrations_modal'); return import('../../closed_registrations_modal');
} }
export function About () { export function About () {
return import(/*webpackChunkName: "features/about" */'../../about'); return import('../../about');
} }
export function PrivacyPolicy () { export function PrivacyPolicy () {
return import(/*webpackChunkName: "features/privacy_policy" */'../../privacy_policy'); return import('../../privacy_policy');
} }
export function TermsOfService () { export function TermsOfService () {
return import(/*webpackChunkName: "features/terms_of_service" */'../../terms_of_service'); return import('../../terms_of_service');
} }
export function NotificationRequests () { export function NotificationRequests () {
return import(/*webpackChunkName: "features/notifications/requests" */'../../notifications/requests'); return import('../../notifications/requests');
} }
export function NotificationRequest () { export function NotificationRequest () {
return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request'); return import('../../notifications/request');
} }
export function LinkTimeline () { export function LinkTimeline () {
return import(/*webpackChunkName: "features/link_timeline" */'../../link_timeline'); return import('../../link_timeline');
} }
export function AnnualReportModal () { export function AnnualReportModal () {
return import(/*webpackChunkName: "modals/annual_report_modal" */'../components/annual_report_modal'); return import('../components/annual_report_modal');
} }
export function ListEdit () { export function ListEdit () {
return import(/*webpackChunkName: "features/lists" */'../../lists/new'); return import('../../lists/new');
} }
export function ListMembers () { export function ListMembers () {
return import(/* webpackChunkName: "features/lists" */'../../lists/members'); return import('../../lists/members');
} }

View file

@ -3,7 +3,7 @@
// can at least log in using KaiOS devices). // can at least log in using KaiOS devices).
function importArrowKeyNavigation() { function importArrowKeyNavigation() {
return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation'); return import('arrow-key-navigation');
} }
export default function loadKeyboardExtensions() { export default function loadKeyboardExtensions() {

View file

@ -5,6 +5,10 @@ import { isLocaleLoaded, setLocale } from './global_locale';
const localeLoadingSemaphore = new Semaphore(1); const localeLoadingSemaphore = new Semaphore(1);
const localeFiles = import.meta.glob<{ default: LocaleData['messages'] }>([
'./*.json',
]);
export async function loadLocale() { export async function loadLocale() {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en'; const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
@ -17,13 +21,14 @@ export async function loadLocale() {
// if the locale is already set, then do nothing // if the locale is already set, then do nothing
if (isLocaleLoaded()) return; if (isLocaleLoaded()) return;
const localeData = (await import( // If there is no locale file, then fallback to english
/* webpackMode: "lazy" */ const localeFile = Object.hasOwn(localeFiles, `./${locale}.json`)
/* webpackChunkName: "locale/[request]" */ ? localeFiles[`./${locale}.json`]
/* webpackInclude: /\.json$/ */ : localeFiles['./en.json'];
/* webpackPreload: true */
`mastodon/locales/${locale}.json` if (!localeFile) throw new Error('Could not load the locale JSON file');
)) as LocaleData['messages'];
const { default: localeData } = await localeFile();
setLocale({ messages: localeData, locale }); setLocale({ messages: localeData, locale });
}); });

View file

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

View file

@ -1,24 +1,11 @@
// //
// Tools for performance debugging, only enabled in development mode. // Tools for performance debugging, only enabled in development mode.
// Open up Chrome Dev Tools, then Timeline, then User Timing to see output. // Open up Chrome Dev Tools, then Timeline, then User Timing to see output.
// Also see config/webpack/loaders/mark.js for the webpack loader marks.
import * as marky from 'marky'; import * as marky from 'marky';
import { isDevelopment } from './utils/environment'; import { isDevelopment } from './utils/environment';
if (isDevelopment()) {
if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
// Increase Firefox's performance entry limit; otherwise it's capped to 150.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
performance.setResourceTimingBufferSize(Infinity);
}
// allows us to easily do e.g. ReactPerf.printWasted() while debugging
//window.ReactPerf = require('react-addons-perf');
//window.ReactPerf.start();
}
export function start(name) { export function start(name) {
if (isDevelopment()) { if (isDevelopment()) {
marky.mark(name); marky.mark(name);

View file

@ -2,10 +2,13 @@
// If there are no polyfills, then this is just Promise.resolve() which means // If there are no polyfills, then this is just Promise.resolve() which means
// it will execute in the same tick of the event loop (i.e. near-instant). // it will execute in the same tick of the event loop (i.e. near-instant).
// eslint-disable-next-line import/extensions -- This file is virtual so it thinks it has an extension
import 'vite/modulepreload-polyfill';
import { loadIntlPolyfills } from './intl'; import { loadIntlPolyfills } from './intl';
function importExtraPolyfills() { function importExtraPolyfills() {
return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills'); return import('./extra_polyfills');
} }
export function loadPolyfills() { export function loadPolyfills() {

View file

@ -54,11 +54,9 @@ async function loadIntlPluralRulesPolyfills(locale: string) {
return; return;
} }
// Load the polyfill 1st BEFORE loading data // Load the polyfill 1st BEFORE loading data
await import('@formatjs/intl-pluralrules/polyfill-force');
await import( await import(
/* webpackChunkName: "i18n-pluralrules-polyfill" */ '@formatjs/intl-pluralrules/polyfill-force' `../../../../node_modules/@formatjs/intl-pluralrules/locale-data/${unsupportedLocale}.js`
);
await import(
/* webpackChunkName: "i18n-pluralrules-polyfill-[request]" */ `@formatjs/intl-pluralrules/locale-data/${unsupportedLocale}`
); );
} }
@ -70,11 +68,9 @@ async function loadIntlPluralRulesPolyfills(locale: string) {
// } // }
// // Load the polyfill 1st BEFORE loading data // // Load the polyfill 1st BEFORE loading data
// await import( // await import(
// /* webpackChunkName: "i18n-relativetimeformat-polyfill" */
// '@formatjs/intl-relativetimeformat/polyfill-force' // '@formatjs/intl-relativetimeformat/polyfill-force'
// ); // );
// await import( // await import(
// /* webpackChunkName: "i18n-relativetimeformat-polyfill-[request]" */
// `@formatjs/intl-relativetimeformat/locale-data/${unsupportedLocale}` // `@formatjs/intl-relativetimeformat/locale-data/${unsupportedLocale}`
// ); // );
// } // }

View file

@ -1,5 +1,4 @@
import { ExpirationPlugin } from 'workbox-expiration'; import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing'; import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies'; import { CacheFirst } from 'workbox-strategies';
@ -15,10 +14,9 @@ function fetchRoot() {
return fetch('/', { credentials: 'include', redirect: 'manual' }); return fetch('/', { credentials: 'include', redirect: 'manual' });
} }
precacheAndRoute(self.__WB_MANIFEST);
registerRoute( registerRoute(
/locale_.*\.js$/, /intl\/.*\.js$/,
new CacheFirst({ new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}locales`, cacheName: `${CACHE_NAME_PREFIX}locales`,
plugins: [ 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 { IntlMessageFormat } from 'intl-messageformat';
import { unescape } from 'lodash'; import { unescape } from 'lodash';
// see config/vite/plugins/sw-locales
import locales from './web_push_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 MAX_NOTIFICATIONS = 5;
const GROUP_TAG = 'tag'; const GROUP_TAG = 'tag';

View file

@ -1,7 +1,11 @@
export function isDevelopment() { 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() { 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

@ -1,4 +1,4 @@
@use 'fonts/inter'; @use '../fonts/inter';
body { body {
accent-color: #6364ff; accent-color: #6364ff;
@ -259,7 +259,7 @@ table + p {
.email-header-td { .email-header-td {
padding: 16px 32px; padding: 16px 32px;
background-color: #1b001f; background-color: #1b001f;
background-image: url('../images/mailer-new/common/header-bg-start.png'); background-image: url('../../images/mailer-new/common/header-bg-start.png');
background-position: left top; background-position: left top;
background-repeat: repeat; background-repeat: repeat;
} }
@ -426,7 +426,7 @@ table + p {
// Body content // Body content
.email-body-td { .email-body-td {
background-image: url('../images/mailer-new/common/header-bg-end.png'); background-image: url('../../images/mailer-new/common/header-bg-end.png');
background-position: left top; background-position: left top;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
@ -922,7 +922,7 @@ table + p {
// Extra content on light purple background // Extra content on light purple background
.email-extra-wave { .email-extra-wave {
height: 42px; height: 42px;
background-image: url('../images/mailer-new/welcome/purple-extra-soft-wave.png'); background-image: url('../../images/mailer-new/welcome/purple-extra-soft-wave.png');
background-position: bottom center; background-position: bottom center;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
@ -930,7 +930,7 @@ table + p {
.email-extra-td { .email-extra-td {
padding: 32px 32px 24px; padding: 32px 32px 24px;
background-color: #f0f0ff; background-color: #f0f0ff;
background-image: url('../images/mailer-new/welcome/purple-extra-soft-spacer.png'); // Using an image to maintain the color even in forced dark modes background-image: url('../../images/mailer-new/welcome/purple-extra-soft-spacer.png'); // Using an image to maintain the color even in forced dark modes
.email-column-td { .email-column-td {
padding-top: 8px; padding-top: 8px;

View file

@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: Inter; font-family: Inter;
src: url('../fonts/inter/inter-variable-font-slnt-wght.woff2') src: url('../../fonts/inter/inter-variable-font-slnt-wght.woff2')
format('woff2-variations'); format('woff2-variations');
font-weight: 100 900; font-weight: 100 900;
font-style: normal; font-style: normal;

View file

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

View file

@ -1,3 +1,5 @@
/// <reference types="vite-plugin-svgr/client" />
/* eslint-disable import/no-default-export */ /* eslint-disable import/no-default-export */
declare module '*.avif' { declare module '*.avif' {
const path: string; const path: string;
@ -19,23 +21,6 @@ declare module '*.png' {
export default path; 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' { declare module '*.webp' {
const path: string; const path: string;
export default path; export default path;

59
app/javascript/types/virtual.d.ts vendored Normal file
View file

@ -0,0 +1,59 @@
declare module 'virtual:mastodon-emoji-compressed' {
import type { BaseEmoji, EmojiData, NimbleEmojiIndex } from 'emoji-mart';
import type { Category, Data, Emoji } from 'emoji-mart/dist-es/utils/data';
/*
* The 'search' property, although not defined in the [`Emoji`]{@link node_modules/@types/emoji-mart/dist-es/utils/data.d.ts#Emoji} type,
* is used in the application.
* This could be due to an oversight by the library maintainer.
* The `search` property is defined and used [here]{@link node_modules/emoji-mart/dist/utils/data.js#uncompress}.
*/
export type Search = string;
/*
* The 'skins' property does not exist in the application data.
* This could be a potential area of refactoring or error handling.
* The non-existence of 'skins' property is evident at [this location]{@link app/javascript/mastodon/features/emoji/emoji_compressed.js:121}.
*/
type Skins = null;
type Filename = string;
type UnicodeFilename = string;
export type FilenameData = [
filename: Filename,
unicodeFilename?: UnicodeFilename,
][];
export type ShortCodesToEmojiDataKey =
| EmojiData['id']
| BaseEmoji['native']
| keyof NimbleEmojiIndex['emojis'];
type SearchData = [
BaseEmoji['native'],
Emoji['short_names'],
Search,
Emoji['unified'],
];
export type ShortCodesToEmojiData = Record<
ShortCodesToEmojiDataKey,
[FilenameData, SearchData]
>;
type EmojisWithoutShortCodes = FilenameData;
type EmojiCompressed = [
ShortCodesToEmojiData,
Skins,
Category[],
Data['aliases'],
EmojisWithoutShortCodes,
Data,
];
/*
* `emoji_compressed.js` uses `babel-plugin-preval`, which makes it difficult to convert to TypeScript.
* As a temporary solution, we are allowing a default export here to apply the TypeScript type `EmojiCompressed` to the JS file export.
* - {@link app/javascript/mastodon/features/emoji/emoji_compressed.js}
*/
declare const emojiCompressed: EmojiCompressed;
export default emojiCompressed; // eslint-disable-line import/no-default-export
}

View file

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

View file

@ -1,7 +1,7 @@
- content_for :page_title do - content_for :page_title do
= t('auth.setup.title') = 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| = simple_form_for(@user, url: auth_setup_path) do |f|
= render 'auth/shared/progress', stage: 'confirm' = render 'auth/shared/progress', stage: 'confirm'

View file

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

View file

@ -26,11 +26,13 @@
%title= html_title %title= html_title
= theme_style_tags current_theme = 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` -# 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/entrypoints/inert.scss', media: 'all', id: 'inert-style'
= vite_typescript_tag 'common.ts', crossorigin: 'anonymous'
= javascript_pack_tag 'common', crossorigin: 'anonymous' = vite_preload_file_tag "mastodon/locales/#{I18n.locale}.json"
= preload_pack_asset "locale/#{I18n.locale}-json.js"
= csrf_meta_tags unless skip_csrf_meta_tags? = csrf_meta_tags unless skip_csrf_meta_tags?
%meta{ name: 'style-nonce', content: request.content_security_policy_nonce } %meta{ name: 'style-nonce', content: request.content_security_policy_nonce }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
- content_for :header_tags do - 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' - content_for :body_classes, 'modal-layout compose-standalone'

View file

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

View file

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

View file

@ -1,4 +1,4 @@
- content_for :header_tags do - content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/ %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 .actions
= f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit = 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,12 @@
- content_for :body_classes, 'app-body' - content_for :body_classes, 'app-body'
- content_for :header_tags do - content_for :header_tags do
- if user_signed_in? - if user_signed_in?
= 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: 'initialPath', content: request.path }
%meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key } %meta{ name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key }
= render_initial_state = 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) } } .notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } }
%noscript %noscript

View file

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

View file

@ -1,80 +0,0 @@
module.exports = (api) => {
const env = api.env();
const reactOptions = {
development: false,
runtime: 'automatic',
};
const envOptions = {
useBuiltIns: "usage",
corejs: { version: "3.30" },
debug: false,
include: [
'transform-numeric-separator',
'transform-optional-chaining',
'transform-nullish-coalescing-operator',
'transform-class-properties',
],
};
const plugins = [
['formatjs'],
'preval',
];
switch (env) {
case 'production':
plugins.push(...[
'lodash',
[
'transform-react-remove-prop-types',
{
mode: 'remove',
removeImport: true,
additionalLibraries: [
'react-immutable-proptypes',
],
},
],
'@babel/transform-react-inline-elements',
[
'@babel/transform-runtime',
{
helpers: true,
regenerator: false,
useESModules: true,
},
],
]);
break;
case 'development':
reactOptions.development = true;
envOptions.debug = true;
// We need Babel to not inject polyfills in dev, as this breaks `preval` files
envOptions.useBuiltIns = false;
envOptions.corejs = undefined;
break;
}
const config = {
presets: [
'@babel/preset-typescript',
['@babel/react', reactOptions],
['@babel/env', envOptions],
],
plugins,
overrides: [
{
test: [/tesseract\.js/, /fuzzysort\.js/],
presets: [
['@babel/env', { ...envOptions, modules: 'commonjs' }],
],
},
],
};
return config;
};

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

@ -1,19 +0,0 @@
#!/usr/bin/env ruby
ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"] ||= "development"
require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
Pathname.new(__FILE__).realpath)
require "rubygems"
require "bundler/setup"
require "webpacker"
require "webpacker/webpack_runner"
APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Webpacker::WebpackRunner.run(ARGV)
end

View file

@ -1,19 +0,0 @@
#!/usr/bin/env ruby
ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
ENV["NODE_ENV"] ||= "development"
require "pathname"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
Pathname.new(__FILE__).realpath)
require "rubygems"
require "bundler/setup"
require "webpacker"
require "webpacker/dev_server_runner"
APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Webpacker::DevServerRunner.run(ARGV)
end

View file

@ -46,14 +46,13 @@ require_relative '../lib/chewy/settings_extensions'
require_relative '../lib/chewy/index_extensions' require_relative '../lib/chewy/index_extensions'
require_relative '../lib/chewy/strategy/mastodon' require_relative '../lib/chewy/strategy/mastodon'
require_relative '../lib/chewy/strategy/bypass_with_warning' 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/rails/engine_extensions'
require_relative '../lib/action_dispatch/remote_ip_extensions' require_relative '../lib/action_dispatch/remote_ip_extensions'
require_relative '../lib/stoplight/redis_data_store_extensions' require_relative '../lib/stoplight/redis_data_store_extensions'
require_relative '../lib/active_record/database_tasks_extensions' require_relative '../lib/active_record/database_tasks_extensions'
require_relative '../lib/active_record/batches' require_relative '../lib/active_record/batches'
require_relative '../lib/simple_navigation/item_extensions' require_relative '../lib/simple_navigation/item_extensions'
require_relative '../lib/vite_ruby/sri_extensions'
Bundler.require(:pam_authentication) if ENV['PAM_ENABLED'] == 'true' 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.frame_ancestors :none
p.font_src :self, assets_host p.font_src :self, assets_host
p.img_src :self, :data, :blob, *media_hosts p.img_src :self, :data, :blob, *media_hosts
p.style_src :self, assets_host
p.media_src :self, :data, *media_hosts p.media_src :self, :data, *media_hosts
p.manifest_src :self, assets_host 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 p.worker_src :self, :blob, assets_host
if Rails.env.development? if Rails.env.development?
webpacker_public_host = ENV.fetch('WEBPACKER_DEV_SERVER_PUBLIC', Webpacker.config.dev_server[:public]) vite_public_host = ENV.fetch('VITE_DEV_SERVER_PUBLIC', "localhost:#{ViteRuby.config.port}")
front_end_build_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{webpacker_public_host}" } front_end_build_urls = %w(ws http).map { |protocol| "#{protocol}#{ViteRuby.config.https ? 's' : ''}://#{vite_public_host}" }
p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url, *front_end_build_urls 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.script_src :self, :unsafe_inline, :unsafe_eval, assets_host
p.frame_src :self, :https, :http p.frame_src :self, :https, :http
p.style_src :self, assets_host, :unsafe_inline
else else
p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url 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.script_src :self, assets_host, "'wasm-unsafe-eval'"
p.frame_src :self, :https p.frame_src :self, :https
p.style_src :self, assets_host
end end
end end

View file

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

View file

@ -0,0 +1,27 @@
import type { Plugin } from 'vite';
export function MastodonEmojiCompressed(): Plugin {
const virtualModuleId = 'virtual:mastodon-emoji-compressed';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
return {
name: 'mastodon-emoji-compressed',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
return undefined;
},
async load(id) {
if (id === resolvedVirtualModuleId) {
const { default: emojiCompressed } = await import(
'../../app/javascript/mastodon/features/emoji/emoji_compressed.mjs'
);
return `export default ${JSON.stringify(emojiCompressed)};`;
}
return undefined;
},
};
}

View file

@ -0,0 +1,102 @@
/* 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 { defineMessages } from 'react-intl';
import type { Plugin, ResolvedConfig } from 'vite';
const translations = defineMessages({
mentioned_you: {
id: 'notification.mentioned_you',
defaultMessage: '{name} mentioned you',
},
});
const CUSTOM_TRANSLATIONS = {
'notification.mention': translations.mentioned_you.id,
};
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;
}
});
Object.entries(CUSTOM_TRANSLATIONS).forEach(([key, value]) => {
const translation = full[value];
if (translation) filteredLocale[key] = translation;
});
filteredLocales[locale] = filteredLocale;
});
return `const locales = ${JSON.stringify(filteredLocales)}; \n export default locales;`;
}
return undefined;
},
};
}

View file

@ -1,28 +0,0 @@
// Common configuration for webpacker loaded from config/webpacker.yml
const { readFileSync } = require('fs');
const { resolve } = require('path');
const { env } = require('process');
const { load } = require('js-yaml');
const configPath = resolve('config', 'webpacker.yml');
const settings = load(readFileSync(configPath), 'utf8')[env.RAILS_ENV || env.NODE_ENV];
const themePath = resolve('config', 'themes.yml');
const themes = load(readFileSync(themePath), 'utf8');
const output = {
path: resolve('public', settings.public_output_path),
publicPath: `/${settings.public_output_path}/`,
};
module.exports = {
settings,
themes,
env: {
NODE_ENV: env.NODE_ENV,
PUBLIC_OUTPUT_PATH: settings.public_output_path,
},
output,
};

View file

@ -1,62 +0,0 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
const { merge } = require('webpack-merge');
const { settings, output } = require('./configuration');
const sharedConfig = require('./shared');
const watchOptions = {};
if (process.env.VAGRANT) {
// If we are in Vagrant, we can't rely on inotify to update us with changed
// files, so we must poll instead. Here, we poll every second to see if
// anything has changed.
watchOptions.poll = 1000;
}
module.exports = merge(sharedConfig, {
mode: 'development',
cache: true,
devtool: 'cheap-module-eval-source-map',
stats: {
errorDetails: true,
},
output: {
pathinfo: true,
},
devServer: {
clientLogLevel: 'none',
compress: settings.dev_server.compress,
quiet: settings.dev_server.quiet,
disableHostCheck: settings.dev_server.disable_host_check,
host: settings.dev_server.host,
port: settings.dev_server.port,
https: settings.dev_server.https,
hot: settings.dev_server.hmr,
contentBase: output.path,
inline: settings.dev_server.inline,
useLocalIp: settings.dev_server.use_local_ip,
public: settings.dev_server.public,
publicPath: output.publicPath,
historyApiFallback: {
disableDotRule: true,
},
headers: settings.dev_server.headers,
overlay: settings.dev_server.overlay,
stats: {
entrypoints: false,
errorDetails: false,
modules: false,
moduleTrace: false,
},
watchOptions: Object.assign(
{},
settings.dev_server.watch_options,
watchOptions,
),
writeToDisk: filePath => /ocr/.test(filePath),
},
});

View file

@ -1,74 +0,0 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
const { createHash } = require('crypto');
const { readFileSync } = require('fs');
const { resolve } = require('path');
const CompressionPlugin = require('compression-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const { merge } = require('webpack-merge');
const { InjectManifest } = require('workbox-webpack-plugin');
const sharedConfig = require('./shared');
const root = resolve(__dirname, '..', '..');
module.exports = merge(sharedConfig, {
mode: 'production',
devtool: 'source-map',
stats: 'normal',
bail: true,
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true,
}),
],
},
plugins: [
new CompressionPlugin({
filename: '[path][base].gz[query]',
cache: true,
test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/,
}),
new CompressionPlugin({
filename: '[path][base].br[query]',
algorithm: 'brotliCompress',
cache: true,
test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/,
}),
new BundleAnalyzerPlugin({ // generates report.html
analyzerMode: 'static',
openAnalyzer: false,
logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
}),
new InjectManifest({
additionalManifestEntries: ['1f602.svg', 'sheet_15_1.png'].map((filename) => {
const path = resolve(root, 'public', 'emoji', filename);
const body = readFileSync(path);
const md5 = createHash('md5');
md5.update(body);
return {
revision: md5.digest('hex'),
url: `/emoji/${filename}`,
};
}),
exclude: [
/(?:base|extra)_polyfills-.*\.js$/,
/locale_.*\.js$/,
/mailer-.*\.(?:css|js)$/,
],
include: [/\.js$/, /\.css$/],
maximumFileSizeToCacheInBytes: 2 * 1_024 * 1_024, // 2 MiB
swDest: resolve(root, 'public', 'packs', 'sw.js'),
swSrc: resolve(root, 'app', 'javascript', 'mastodon', 'service_worker', 'entry.js'),
}),
],
});

View file

@ -1,28 +0,0 @@
const { join, resolve } = require('path');
const { env, settings } = require('../configuration');
// Those modules contain modern ES code that need to be transpiled for Webpack to process it
const nodeModulesToProcess = [
'@reduxjs', 'fuzzysort', 'toygrad', '@react-spring'
];
module.exports = {
test: /\.(js|jsx|mjs|ts|tsx)$/,
include: [
settings.source_path,
...settings.resolved_paths,
...nodeModulesToProcess.map(p => resolve(`node_modules/${p}`)),
].map(p => resolve(p)),
exclude: new RegExp('node_modules\\/(?!(' + nodeModulesToProcess.join('|')+')\\/).*'),
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: join(settings.cache_path, 'babel-loader'),
cacheCompression: env.NODE_ENV === 'production',
compact: env.NODE_ENV === 'production',
},
},
],
};

View file

@ -1,28 +0,0 @@
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
test: /\.s?css$/i,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
sourceMap: true,
importLoaders: 2,
},
},
{
loader: 'postcss-loader',
options: {
sourceMap: true,
},
},
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sourceMap: true,
},
},
],
};

View file

@ -1,22 +0,0 @@
const { join } = require('path');
const { settings } = require('../configuration');
module.exports = {
test: new RegExp(`(${settings.static_assets_extensions.join('|')})$`, 'i'),
exclude: [/material-icons/, /svg-icons/],
use: [
{
loader: 'file-loader',
options: {
name(file) {
if (file.includes(settings.source_path)) {
return 'media/[path][name]-[hash].[ext]';
}
return 'media/[folder]/[name]-[hash:8].[ext]';
},
context: join(settings.source_path),
},
},
],
};

View file

@ -1,16 +0,0 @@
const babel = require('./babel');
const css = require('./css');
const file = require('./file');
const materialIcons = require('./material_icons');
const tesseract = require('./tesseract');
// Webpack loaders are processed in reverse order
// https://webpack.js.org/concepts/loaders/#loader-features
// Lastly, process static files using file loader
module.exports = {
materialIcons,
file,
tesseract,
css,
babel,
};

View file

@ -1,8 +0,0 @@
if (process.env.NODE_ENV === 'production') {
module.exports = {};
} else {
module.exports = {
test: /\.js$/,
loader: 'mark-loader',
};
}

View file

@ -1,14 +0,0 @@
module.exports = {
test: /\.svg$/,
include: [/material-icons/, /svg-icons/],
issuer: /\.[jt]sx?$/,
use: [
{
loader: '@svgr/webpack',
options: {
svgo: false,
titleProp: true,
},
},
],
};

View file

@ -1,13 +0,0 @@
module.exports = {
test: [
/tesseract\.js\/dist\/worker\.min\.js$/,
/tesseract\.js\/dist\/worker\.min\.js\.map$/,
/tesseract\.js-core\/tesseract-core\.wasm\.js$/,
],
use: {
loader: 'file-loader',
options: {
name: 'ocr/[name]-[hash].[ext]',
},
},
};

View file

@ -1,113 +0,0 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
const { basename, dirname, join, relative, resolve } = require('path');
const CircularDependencyPlugin = require('circular-dependency-plugin');
const { sync } = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const extname = require('path-complete-extname');
const webpack = require('webpack');
const AssetsManifestPlugin = require('webpack-assets-manifest');
const { env, settings, themes, output } = require('./configuration');
const rules = require('./rules');
const extensionGlob = `**/*{${settings.extensions.join(',')}}*`;
const entryPath = join(settings.source_path, settings.source_entry_path);
const packPaths = sync(join(entryPath, extensionGlob));
module.exports = {
entry: Object.assign(
packPaths.reduce((map, entry) => {
const localMap = map;
const namespace = relative(join(entryPath), dirname(entry));
localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry);
return localMap;
}, {}),
Object.keys(themes).reduce((themePaths, name) => {
themePaths[name] = resolve(join(settings.source_path, themes[name]));
return themePaths;
}, {}),
),
output: {
filename: 'js/[name]-[chunkhash].js',
chunkFilename: 'js/[name]-[chunkhash].chunk.js',
hotUpdateChunkFilename: 'js/[id]-[hash].hot-update.js',
hashFunction: 'sha256',
crossOriginLoading: 'anonymous',
path: output.path,
publicPath: output.publicPath,
},
optimization: {
runtimeChunk: {
name: 'common',
},
splitChunks: {
cacheGroups: {
default: false,
vendors: false,
common: {
name: 'common',
chunks: 'all',
minChunks: 2,
minSize: 0,
test: /^(?!.*[\\/]node_modules[\\/]react-intl[\\/]).+$/,
},
},
},
occurrenceOrder: true,
},
module: {
rules: Object.keys(rules).map(key => rules[key]),
strictExportPresence: true,
},
plugins: [
new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(env))),
new webpack.NormalModuleReplacementPlugin(
/^history\//, (resource) => {
// temporary fix for https://github.com/ReactTraining/react-router/issues/5576
// to reduce bundle size
resource.request = resource.request.replace(/^history/, 'history/es');
},
),
new MiniCssExtractPlugin({
filename: 'css/[name]-[contenthash:8].css',
chunkFilename: 'css/[name]-[contenthash:8].chunk.css',
}),
new AssetsManifestPlugin({
integrity: true,
integrityHashes: ['sha256'],
entrypoints: true,
writeToDisk: true,
publicPath: true,
}),
new CircularDependencyPlugin({
failOnError: true,
})
],
resolve: {
extensions: settings.extensions,
modules: [
resolve(settings.source_path),
'node_modules',
],
alias: {
"@": resolve(settings.source_path),
}
},
resolveLoader: {
modules: ['node_modules'],
},
node: {
// Called by http-link-header in an API we never use, increases
// bundle size unnecessarily
Buffer: false,
},
};

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

@ -248,7 +248,6 @@ export default tseslint.config([
{ {
devDependencies: [ devDependencies: [
'eslint.config.mjs', 'eslint.config.mjs',
'config/webpack/**',
'app/javascript/mastodon/performance.js', 'app/javascript/mastodon/performance.js',
'app/javascript/mastodon/test_setup.js', 'app/javascript/mastodon/test_setup.js',
'app/javascript/mastodon/test_helpers.tsx', 'app/javascript/mastodon/test_helpers.tsx',
@ -256,7 +255,12 @@ export default tseslint.config([
], ],
}, },
], ],
'import/no-webpack-loader-syntax': 'error', 'import/no-unresolved': [
'error',
{
ignore: ['vite/modulepreload-polyfill'],
},
],
'react/jsx-filename-extension': [ 'react/jsx-filename-extension': [
'error', 'error',
@ -292,7 +296,6 @@ export default tseslint.config([
'**/*.config.js', '**/*.config.js',
'**/.*rc.js', '**/.*rc.js',
'**/ide-helper.js', '**/ide-helper.js',
'config/webpack/**/*',
'config/formatjs-formatter.js', 'config/formatjs-formatter.js',
], ],

View file

@ -2,21 +2,26 @@
module PremailerBundledAssetStrategy module PremailerBundledAssetStrategy
def load(url) 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? headers = {}
asset_host = "#{Webpacker.dev_server.protocol}://#{Webpacker.dev_server.host_with_port}" # 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)
url = File.join(asset_host, url) 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 end
rescue ViteRuby::MissingEntrypointError
css = if url.start_with?('http') # If the path is not in the manifest, ignore it
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}/")
end end
module_function :load module_function :load

View file

@ -14,7 +14,9 @@ end
if Rake::Task.task_defined?('assets:precompile') if Rake::Task.task_defined?('assets:precompile')
Rake::Task['assets:precompile'].enhance do Rake::Task['assets:precompile'].enhance do
Webpacker.manifest.refresh
Rake::Task['assets:generate_static_pages'].invoke Rake::Task['assets:generate_static_pages'].invoke
end end
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,124 @@
# 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
# We need to override this method to not include the manifest, as in our case it is too large and will cause a JSON max nesting error rather than raising the expected exception
def missing_entry_error(name, **)
raise ViteRuby::MissingEntrypointError.new(
file_name: resolve_entry_name(name, **),
last_build: builder.last_build_metadata,
manifest: '',
config: config
)
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
def vite_preload_file_tag(name,
asset_type: :javascript,
crossorigin: 'anonymous', **options)
''.html_safe.tap do |tags|
entries = vite_manifest.resolve_entries_with_integrity(name, type: asset_type)
entries.fetch(:scripts).each do |script|
tags << vite_preload_tag(script[:file], integrity: script[:integrity], crossorigin: crossorigin, **options)
end
end
rescue ViteRuby::MissingEntrypointError
# Ignore this error, it is not critical if the file is not preloaded
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

@ -10,14 +10,15 @@
"streaming" "streaming"
], ],
"scripts": { "scripts": {
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development ./bin/webpack", "dev": "vite dev",
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production ./bin/webpack", "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:js": "eslint . --cache --fix",
"fix:css": "stylelint --fix \"**/*.{css,scss}\"", "fix:css": "stylelint --fix \"**/*.{css,scss}\"",
"fix": "yarn fix:js && yarn fix:css", "fix": "yarn fix:js && yarn fix:css",
"format": "prettier --write --log-level warn .", "format": "prettier --write --log-level warn .",
"format:check": "prettier --check --ignore-unknown .", "format:check": "prettier --check --ignore-unknown .",
"i18n:extract": "formatjs extract 'app/javascript/**/*.{js,jsx,ts,tsx}' --ignore '**/*.d.ts' --out-file app/javascript/mastodon/locales/en.json --format config/formatjs-formatter.js", "i18n:extract": "formatjs extract 'app/javascript/**/*.{js,jsx,ts,tsx}' config/vite/plugin-sw-locales.ts --ignore '**/*.d.ts' --out-file app/javascript/mastodon/locales/en.json --format config/formatjs-formatter.js",
"lint:js": "cd $INIT_CWD && eslint --cache --report-unused-disable-directives", "lint:js": "cd $INIT_CWD && eslint --cache --report-unused-disable-directives",
"lint:css": "stylelint \"**/*.{css,scss}\"", "lint:css": "stylelint \"**/*.{css,scss}\"",
"lint": "yarn lint:js && yarn lint:css", "lint": "yarn lint:js && yarn lint:css",
@ -34,14 +35,6 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.22.1",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.22.3",
"@babel/plugin-transform-react-inline-elements": "^7.21.0",
"@babel/plugin-transform-runtime": "^7.22.4",
"@babel/preset-env": "^7.22.4",
"@babel/preset-react": "^7.22.3",
"@babel/preset-typescript": "^7.21.5",
"@babel/runtime": "^7.22.3",
"@csstools/stylelint-formatter-github": "^1.0.0", "@csstools/stylelint-formatter-github": "^1.0.0",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -49,51 +42,39 @@
"@formatjs/intl-pluralrules": "^5.4.4", "@formatjs/intl-pluralrules": "^5.4.4",
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1", "@github/webauthn-json": "^2.1.1",
"@optimize-lodash/rollup-plugin": "^5.0.2",
"@rails/ujs": "7.1.501", "@rails/ujs": "7.1.501",
"@react-spring/web": "^9.7.5", "@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@svgr/webpack": "^5.5.0",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "^4.2.1",
"arrow-key-navigation": "^1.2.0", "arrow-key-navigation": "^1.2.0",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"axios": "^1.4.0", "axios": "^1.4.0",
"babel-loader": "^8.3.0",
"babel-plugin-formatjs": "^10.5.37", "babel-plugin-formatjs": "^10.5.37",
"babel-plugin-lodash": "patch:babel-plugin-lodash@npm%3A3.3.4#~/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch",
"babel-plugin-preval": "^5.1.0",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24", "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"circular-dependency-plugin": "^5.2.2",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"cocoon-js-vanilla": "^1.3.0", "cocoon-js-vanilla": "^1.5.1",
"color-blend": "^4.0.0", "color-blend": "^4.0.0",
"compression-webpack-plugin": "^6.1.2",
"core-js": "^3.30.2", "core-js": "^3.30.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css-loader": "^5.2.7",
"cssnano": "^7.0.0",
"detect-passive-events": "^2.0.3", "detect-passive-events": "^2.0.3",
"emoji-mart": "npm:emoji-mart-lazyload@latest", "emoji-mart": "npm:emoji-mart-lazyload@latest",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"file-loader": "^6.2.0",
"fuzzysort": "^3.0.0", "fuzzysort": "^3.0.0",
"glob": "^10.2.6", "glob": "^10.2.6",
"history": "^4.10.1", "history": "^4.10.1",
"hoist-non-react-statics": "^3.3.2", "hoist-non-react-statics": "^3.3.2",
"http-link-header": "^1.1.1", "http-link-header": "^1.1.1",
"immutable": "^4.3.0", "immutable": "^4.3.0",
"imports-loader": "^1.2.0",
"intl-messageformat": "^10.7.16", "intl-messageformat": "^10.7.16",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lande": "^1.0.10", "lande": "^1.0.10",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mark-loader": "^0.1.6",
"marky": "^1.2.5", "marky": "^1.2.5",
"mini-css-extract-plugin": "^1.6.2",
"path-complete-extname": "^1.0.0", "path-complete-extname": "^1.0.0",
"postcss": "^8.4.24", "postcss-preset-env": "^10.1.5",
"postcss-loader": "^4.3.0",
"postcss-preset-env": "^10.0.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"punycode": "^2.3.0", "punycode": "^2.3.0",
"react": "^18.2.0", "react": "^18.2.0",
@ -117,28 +98,25 @@
"redux-immutable": "^4.0.0", "redux-immutable": "^4.0.0",
"regenerator-runtime": "^0.14.0", "regenerator-runtime": "^0.14.0",
"requestidlecallback": "^0.3.0", "requestidlecallback": "^0.3.0",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.62.1", "sass": "^1.62.1",
"sass-loader": "^10.2.0",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"stringz": "^2.1.0", "stringz": "^2.1.0",
"substring-trie": "^1.0.2", "substring-trie": "^1.0.2",
"terser-webpack-plugin": "^4.2.3",
"tesseract.js": "^6.0.0", "tesseract.js": "^6.0.0",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"twitter-text": "3.1.0", "twitter-text": "3.1.0",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"vite": "^6.3.5", "vite": "^6.3.5",
"webpack": "^4.47.0", "vite-plugin-pwa": "^1.0.0",
"webpack-assets-manifest": "^4.0.6", "vite-plugin-rails": "^0.5.0",
"webpack-bundle-analyzer": "^4.8.0", "vite-plugin-ruby": "^5.1.1",
"webpack-cli": "^3.3.12", "vite-plugin-svgr": "^4.3.0",
"webpack-merge": "^6.0.0", "vite-tsconfig-paths": "^5.1.4",
"wicg-inert": "^3.1.2", "wicg-inert": "^3.1.2",
"workbox-expiration": "^7.0.0", "workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0", "workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0", "workbox-strategies": "^7.0.0",
"workbox-webpack-plugin": "^7.0.0",
"workbox-window": "^7.0.0" "workbox-window": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -146,7 +124,6 @@
"@formatjs/cli": "^6.1.1", "@formatjs/cli": "^6.1.1",
"@testing-library/dom": "^10.2.0", "@testing-library/dom": "^10.2.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@types/babel__core": "^7.20.1",
"@types/emoji-mart": "3.0.14", "@types/emoji-mart": "3.0.14",
"@types/escape-html": "^1.0.2", "@types/escape-html": "^1.0.2",
"@types/hoist-non-react-statics": "^3.3.1", "@types/hoist-non-react-statics": "^3.3.1",
@ -170,9 +147,6 @@
"@types/react-toggle": "^4.0.3", "@types/react-toggle": "^4.0.3",
"@types/redux-immutable": "^4.0.3", "@types/redux-immutable": "^4.0.3",
"@types/requestidlecallback": "^0.3.5", "@types/requestidlecallback": "^0.3.5",
"@types/webpack": "^4.41.33",
"@types/webpack-env": "^1.18.4",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^9.23.0", "eslint": "^9.23.0",
"eslint-import-resolver-typescript": "^4.2.5", "eslint-import-resolver-typescript": "^4.2.5",
"eslint-plugin-formatjs": "^5.3.1", "eslint-plugin-formatjs": "^5.3.1",
@ -191,17 +165,16 @@
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^14.0.0", "stylelint-config-standard-scss": "^14.0.0",
"typescript": "~5.7.3", "typescript": "~5.7.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.29.1",
"vite-plugin-rails": "^0.5.0", "vite-plugin-rails": "^0.5.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vitest": "^3.1.3", "vitest": "^3.1.3"
"webpack-dev-server": "^3.11.3"
}, },
"resolutions": { "resolutions": {
"@types/react": "^18.2.7", "@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4", "@types/react-dom": "^18.2.4",
"kind-of": "^6.0.3", "kind-of": "^6.0.3",
"webpack/terser-webpack-plugin": "^4.2.3" "vite-plugin-ruby": "^5.1.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react": { "react": {

View file

@ -1,15 +0,0 @@
const postcssPresetEnv = require('postcss-preset-env');
/** @type {import('postcss-load-config').Config} */
const config = ({ env }) => ({
plugins: [
postcssPresetEnv({
features: {
'logical-properties-and-values': false
}
}),
env === 'production' ? require('cssnano') : '',
],
});
module.exports = config;

View file

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

View file

@ -31,7 +31,7 @@ module.exports = {
}, },
overrides: [ overrides: [
{ {
'files': ['app/javascript/styles/mailer.scss'], 'files': ['app/javascript/styles/entrypoints/mailer.scss'],
rules: { rules: {
'property-no-unknown': [ 'property-no-unknown': [
true, true,

View file

@ -2,17 +2,18 @@
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "esnext", "target": "esnext",
"module": "CommonJS", "module": "ES2022",
"moduleResolution": "node", "moduleResolution": "bundler",
"allowJs": true, "allowJs": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"noEmit": true, "noEmit": true,
"strict": true, "strict": true,
"isolatedModules": true, // Required by Vite
"noImplicitReturns": true, "noImplicitReturns": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"types": ["vitest/globals", "@types/webpack-env"], "types": ["vite/client", "vitest/globals"],
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "incremental": true,
"tsBuildInfoFile": "tmp/cache/tsconfig.tsbuildinfo", "tsBuildInfoFile": "tmp/cache/tsconfig.tsbuildinfo",
@ -26,6 +27,7 @@
}, },
"include": [ "include": [
"vite.config.mts", "vite.config.mts",
"config/vite",
"app/javascript/mastodon", "app/javascript/mastodon",
"app/javascript/entrypoints", "app/javascript/entrypoints",
"app/javascript/types" "app/javascript/types"

View file

@ -1,58 +1,154 @@
/// <reference types="vitest" /> import fs from 'node:fs/promises';
import path from 'node:path';
import fs from 'fs';
import path from 'path';
import { optimizeLodashImports } from '@optimize-lodash/rollup-plugin';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import RailsPlugin from 'vite-plugin-rails'; import { PluginOption } from 'vite';
import svgr from 'vite-plugin-svgr'; import svgr from 'vite-plugin-svgr';
import { defineConfig, configDefaults } from 'vitest/config'; import { visualizer } from 'rollup-plugin-visualizer';
import RailsPlugin from 'vite-plugin-rails';
import { VitePWA } from 'vite-plugin-pwa';
import tsconfigPaths from 'vite-tsconfig-paths';
import yaml from 'js-yaml';
const sourceCodeDir = 'app/javascript'; import { defineConfig, UserConfigFnPromise, UserConfig } from 'vite';
const items = fs.readdirSync(sourceCodeDir); import postcssPresetEnv from 'postcss-preset-env';
const directories = items.filter((item) =>
fs.lstatSync(path.join(sourceCodeDir, item)).isDirectory(),
);
const aliasesFromJavascriptRoot: Record<string, string> = {};
directories.forEach((directory) => {
aliasesFromJavascriptRoot[directory] = path.resolve(
__dirname,
sourceCodeDir,
directory,
);
});
export default defineConfig({ import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales';
resolve: { import { MastodonEmojiCompressed } from './config/vite/plugin-emoji-compressed';
alias: {
...aliasesFromJavascriptRoot, const jsRoot = path.resolve(__dirname, 'app/javascript');
}, const themesFile = path.resolve(__dirname, 'config/themes.yml');
},
plugins: [ export const config: UserConfigFnPromise = async ({ mode, command }) => {
RailsPlugin(), const entrypoints: Record<string, string> = {}; // All JS entrypoints are taken care of by Vite Ruby
react({
include: ['**/*.jsx', '**/*.tsx'], // Get all files mentioned in the themes.yml file.
babel: { const themesString = await fs.readFile(themesFile, 'utf8');
plugins: ['formatjs', 'preval', 'transform-react-remove-prop-types'], const themes = yaml.load(themesString, {
filename: 'themes.yml',
schema: yaml.FAILSAFE_SCHEMA,
});
if (!themes || typeof themes !== 'object') {
throw new Error('Invalid themes.yml file');
}
for (const themePath of Object.values(themes)) {
if (
typeof themePath !== 'string' ||
themePath.split('.').length !== 2 || // Ensure it has exactly one period
!themePath.endsWith('css')
) {
console.warn(`Invalid theme path "${themePath}" in themes.yml, skipping`);
continue;
}
entrypoints[path.basename(themePath)] = path.resolve(jsRoot, themePath);
}
return {
root: jsRoot,
css: {
postcss: {
plugins: [
postcssPresetEnv({
features: {
'logical-properties-and-values': false,
},
}),
],
}, },
}), },
svgr(), server: {
], headers: {
test: { // This is needed in dev environment because we load the worker from `/dev-sw/dev-sw.js`,
environment: 'jsdom', // but it needs to be scoped to the whole domain
include: [ 'Service-Worker-Allowed': '/',
...configDefaults.include, },
'**/__tests__/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', },
build: {
commonjsOptions: { transformMixedEsModules: true },
chunkSizeWarningLimit: 1 * 1024 * 1024, // 1MB
sourcemap: true,
rollupOptions: {
input: entrypoints,
output: {
chunkFileNames({ facadeModuleId, name }) {
if (!facadeModuleId) {
return '[name]-[hash].js';
}
if (/mastodon\/locales\/[a-zA-Z\-]+\.json/.exec(facadeModuleId)) {
// put all locale files in `intl/`
return 'intl/[name]-[hash].js';
} else if (/node_modules\/@formatjs\//.exec(facadeModuleId)) {
// use a custom name for formatjs polyfill files
const newName = /node_modules\/@formatjs\/([^/]+)\//.exec(
facadeModuleId,
);
if (newName?.[1]) {
return `intl/[name]-${newName[1]}-[hash].js`;
}
} else if (name === 'index') {
// Use a custom name for chunks, to avoid having too many of them called "index"
const parts = facadeModuleId.split('/');
const parent = parts.at(-2);
if (parent) {
return `${parent}-[name]-[hash].js`;
}
}
return '[name]-[hash].js';
},
},
},
},
plugins: [
tsconfigPaths(),
RailsPlugin({
compress: mode === 'production' && command === 'build',
sri: {
manifestPaths: ['.vite/manifest.json', '.vite/manifest-assets.json'],
},
}),
react({
babel: {
plugins: ['formatjs', 'transform-react-remove-prop-types'],
},
}),
MastodonServiceWorkerLocales(),
MastodonEmojiCompressed(),
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 don't use precache
injectionPoint: undefined,
buildPlugins: {
vite: [
// Provide a virtual import with only the locales used in the ServiceWorker
MastodonServiceWorkerLocales(),
MastodonEmojiCompressed(),
],
},
},
// 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(),
// Old library types need to be converted
optimizeLodashImports() as PluginOption,
!!process.env.ANALYZE_BUNDLE_SIZE && (visualizer() as PluginOption),
], ],
exclude: [ } satisfies UserConfig;
...configDefaults.exclude, };
'**/node_modules/**',
'vendor/**', export default defineConfig(config);
'config/**',
'log/**',
'public/**',
'tmp/**',
],
globals: true,
},
});

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,
},
};
});

7459
yarn.lock

File diff suppressed because it is too large Load diff