This commit is contained in:
Echo 2025-05-06 22:02:45 +02:00 committed by GitHub
commit f4610464b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 1765 additions and 8525 deletions

View File

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

View File

@ -25,23 +25,6 @@
'tesseract.js', // 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
'history',
'react-router-dom',

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb
sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq
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
include ActionView::Helpers::AssetTagHelper
include Webpacker::Helper
include ViteRails::TagHelpers
included do
include Rails.application.routes.url_helpers
@ -25,7 +25,7 @@ module RoutingHelper
end
def frontend_asset_path(source, **)
asset_pack_path("media/#{source}", **)
vite_asset_path(source, **)
end
def frontend_asset_url(source, **)

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import './public-path';
import { createRoot } from 'react-dom/client';
import { afterInitialRender } from 'mastodon/hooks/useRenderSignal';

View File

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

View File

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

View File

@ -1,4 +0,0 @@
/* Placeholder file to have `inert.scss` compiled by 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 './public-path';
import { IntlMessageFormat } from 'intl-messageformat';
import type { MessageDescriptor, PrimitiveType } from 'react-intl';
import { defineMessages } from 'react-intl';
@ -18,8 +16,6 @@ import { loadLocale, getLocale } from '../mastodon/locales';
import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready';
import 'cocoon-js-vanilla';
start();
const messages = defineMessages({
@ -153,9 +149,7 @@ function loaded() {
const reactComponents = document.querySelectorAll('[data-component]');
if (reactComponents.length > 0) {
import(
/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container'
)
import('../mastodon/containers/media_container')
.then(({ default: MediaContainer }) => {
reactComponents.forEach((component) => {
Array.from(component.children).forEach((child) => {

View File

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

View File

@ -1,4 +1,3 @@
import './public-path';
import { createRoot } from 'react-dom/client';
import { start } from '../mastodon/common';

View File

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

View File

@ -1,7 +1,8 @@
import Rails from '@rails/ujs';
export function start() {
require.context('../images/', true, /\.(jpg|png|svg)$/);
// TODO: Find alternative to this
// require.context('../images/', true, /\.(jpg|png|svg)$/);
try {
Rails.start();

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import emojiCompressed from 'mastodon/features/emoji/emoji_compressed';
import emojiCompressed from '@/mastodon/features/emoji/emoji_compressed.mjs';
import { assetHost } from 'mastodon/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';

View File

@ -9,28 +9,27 @@
// to ensure that the prevaled file is regenerated by Babel
// version: 4
const { NimbleEmojiIndex } = require('emoji-mart');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
import { NimbleEmojiIndex } from 'emoji-mart';
import { uncompress as emojiMartUncompress } from 'emoji-mart/dist/utils/data';
let data = require('./emoji_data.json');
const emojiMap = require('./emoji_map.json');
const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
import data from './emoji_data.json';
import emojiMap from './emoji_map.json';
import { unicodeToFilename } from './unicode_to_filename';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
emojiMartUncompress(data);
const emojiMartData = data;
const emojiIndex = new NimbleEmojiIndex(emojiMartData);
const excluded = ['®', '©', '™'];
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
const excluded = ['®', '©', '™'];
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => {
Object.keys(emojiIndex.emojis).forEach((key) => {
let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this
@ -41,22 +40,22 @@ Object.keys(emojiIndex.emojis).forEach(key => {
shortcodeMap[emoji.native] = emoji.id;
});
const stripModifiers = unicode => {
skinTones.forEach(tone => {
const stripModifiers = (unicode) => {
skinTones.forEach((tone) => {
unicode = unicode.replace(tone, '');
});
return unicode;
};
Object.keys(emojiMap).forEach(key => {
Object.keys(emojiMap).forEach((key) => {
if (excluded.includes(key)) {
delete emojiMap[key];
return;
}
const normalizedKey = stripModifiers(key);
let shortcode = shortcodeMap[normalizedKey];
let shortcode = shortcodeMap[normalizedKey];
if (!shortcode) {
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];
// 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];
if (short_names[0] !== key) {
throw new Error('The compressor expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.');
throw new Error(
'The compressor expects the first short_code to be the ' +
'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
@ -117,21 +118,23 @@ Object.keys(emojiIndex.emojis).forEach(key => {
// JSON.parse/stringify is to emulate what @preval is doing and avoid any
// inconsistent behavior in dev mode
module.exports = JSON.parse(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
* that expect the presence of `skins` property.
* Currently, no definitions or references to `skins` property can be found in:
* - {@link node_modules/emoji-mart/dist/utils/data.js}
* - {@link node_modules/emoji-mart/data/all.json}
* - {@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.aliases,
emojisWithoutShortCodes,
emojiMartData
]));
export default JSON.parse(
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
* that expect the presence of `skins` property.
* Currently, no definitions or references to `skins` property can be found in:
* - {@link node_modules/emoji-mart/dist/utils/data.js}
* - {@link node_modules/emoji-mart/data/all.json}
* - {@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.aliases,
emojisWithoutShortCodes,
emojiMartData,
]),
);

View File

@ -4,8 +4,8 @@
import type { BaseEmoji } from 'emoji-mart';
import type { Emoji } from 'emoji-mart/dist-es/utils/data';
import type { Search, ShortCodesToEmojiData } from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
import type { Search, ShortCodesToEmojiData } from './emoji_compressed.mjs';
import emojiCompressed from './emoji_compressed.mjs';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
type Emojis = Record<

View File

@ -5,8 +5,8 @@
import type {
FilenameData,
ShortCodesToEmojiDataKey,
} from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
} from './emoji_compressed.mjs';
import emojiCompressed from './emoji_compressed.mjs';
import { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = Record<

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
//
// Tools for performance debugging, only enabled in development mode.
// 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';

View File

@ -2,10 +2,13 @@
// 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).
// 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';
function importExtraPolyfills() {
return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
return import('./extra_polyfills');
}
export function loadPolyfills() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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/strategy/mastodon'
require_relative '../lib/chewy/strategy/bypass_with_warning'
require_relative '../lib/webpacker/manifest_extensions'
require_relative '../lib/webpacker/helper_extensions'
require_relative '../lib/rails/engine_extensions'
require_relative '../lib/action_dispatch/remote_ip_extensions'
require_relative '../lib/stoplight/redis_data_store_extensions'
require_relative '../lib/active_record/database_tasks_extensions'
require_relative '../lib/active_record/batches'
require_relative '../lib/simple_navigation/item_extensions'
require_relative '../lib/vite_ruby/sri_extensions'
Bundler.require(:pam_authentication) if ENV['PAM_ENABLED'] == 'true'

View File

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

View File

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

View File

@ -0,0 +1,85 @@
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import { resolve } from 'node:path';
import type { Plugin, Manifest } from 'vite';
export type Algorithm = 'sha256' | 'sha384' | 'sha512';
export interface Options {
/**
* Which hashing algorithms to use when calculate the integrity hash for each
* asset in the manifest.
* @default ['sha384']
*/
algorithms?: Algorithm[];
}
declare module 'vite' {
interface ManifestChunk {
integrity: string;
assetIntegrity: Record<string, string>;
}
}
export function manifestSRI(options: Options = {}): Plugin {
const { algorithms = ['sha384'] } = options;
return {
name: 'vite-plugin-manifest-sri',
apply: 'build',
enforce: 'post',
async writeBundle({ dir }) {
await augmentManifest('manifest.json', algorithms, dir ?? '');
},
};
}
async function augmentManifest(
manifestPath: string,
algorithms: string[],
outDir: string,
) {
const resolveInOutDir = (path: string) => resolve(outDir, path);
manifestPath = resolveInOutDir(manifestPath);
const manifest: Manifest | undefined = await fs
.readFile(manifestPath, 'utf-8')
.then((file) => JSON.parse(file) as Manifest);
if (!manifest) {
throw new Error(`Manifest file not found at ${manifestPath}`);
}
await Promise.all(
Object.values(manifest).map(async (chunk) => {
chunk.integrity = integrityForAsset(
await fs.readFile(resolveInOutDir(chunk.file)),
algorithms,
);
if (!chunk.assets && !chunk.css) {
return;
}
chunk.assetIntegrity = {};
await Promise.all(
(chunk.assets ?? []).concat(chunk.css ?? []).map(async (asset) => {
chunk.assetIntegrity[asset] = integrityForAsset(
await fs.readFile(resolveInOutDir(asset)),
algorithms,
);
}),
);
}),
);
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
}
function integrityForAsset(source: Buffer, algorithms: string[]) {
return algorithms
.map((algorithm) => calculateIntegrityHash(source, algorithm))
.join(' ');
}
export function calculateIntegrityHash(source: Buffer, algorithm: string) {
const hash = createHash(algorithm).update(source).digest().toString('base64');
return `${algorithm.toLowerCase()}-${hash}`;
}

View File

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

View File

@ -1,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,8 +10,9 @@
"streaming"
],
"scripts": {
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development ./bin/webpack",
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production ./bin/webpack",
"dev": "vite dev",
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development vite build",
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production vite build",
"fix:js": "eslint . --cache --fix",
"fix:css": "stylelint --fix \"**/*.{css,scss}\"",
"fix": "yarn fix:js && yarn fix:css",
@ -34,14 +35,6 @@
},
"private": true,
"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",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0",
@ -49,51 +42,39 @@
"@formatjs/intl-pluralrules": "^5.4.4",
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",
"@optimize-lodash/rollup-plugin": "^5.0.2",
"@rails/ujs": "7.1.501",
"@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1",
"@svgr/webpack": "^5.5.0",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-react": "^4.2.1",
"arrow-key-navigation": "^1.2.0",
"async-mutex": "^0.5.0",
"axios": "^1.4.0",
"babel-loader": "^8.3.0",
"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",
"blurhash": "^2.0.5",
"circular-dependency-plugin": "^5.2.2",
"classnames": "^2.3.2",
"cocoon-js-vanilla": "^1.3.0",
"color-blend": "^4.0.0",
"compression-webpack-plugin": "^6.1.2",
"core-js": "^3.30.2",
"cross-env": "^7.0.3",
"css-loader": "^5.2.7",
"cssnano": "^7.0.0",
"detect-passive-events": "^2.0.3",
"emoji-mart": "npm:emoji-mart-lazyload@latest",
"escape-html": "^1.0.3",
"file-loader": "^6.2.0",
"fuzzysort": "^3.0.0",
"glob": "^10.2.6",
"history": "^4.10.1",
"hoist-non-react-statics": "^3.3.2",
"http-link-header": "^1.1.1",
"immutable": "^4.3.0",
"imports-loader": "^1.2.0",
"intl-messageformat": "^10.7.16",
"js-yaml": "^4.1.0",
"lande": "^1.0.10",
"lodash": "^4.17.21",
"mark-loader": "^0.1.6",
"marky": "^1.2.5",
"mini-css-extract-plugin": "^1.6.2",
"path-complete-extname": "^1.0.0",
"postcss": "^8.4.24",
"postcss-loader": "^4.3.0",
"postcss-preset-env": "^10.0.0",
"postcss-preset-env": "^10.1.5",
"prop-types": "^15.8.1",
"punycode": "^2.3.0",
"react": "^18.2.0",
@ -118,36 +99,29 @@
"regenerator-runtime": "^0.14.0",
"requestidlecallback": "^0.3.0",
"sass": "^1.62.1",
"sass-loader": "^10.2.0",
"stacktrace-js": "^2.0.2",
"stringz": "^2.1.0",
"substring-trie": "^1.0.2",
"terser-webpack-plugin": "^4.2.3",
"tesseract.js": "^6.0.0",
"tiny-queue": "^0.2.1",
"twitter-text": "3.1.0",
"use-debounce": "^10.0.0",
"vite": "^6.2.6",
"webpack": "^4.47.0",
"webpack-assets-manifest": "^4.0.6",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^3.3.12",
"webpack-merge": "^6.0.0",
"vite": "^6.3.0",
"vite-bundle-analyzer": "^0.18.1",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-rails": "^0.5.0",
"vite-plugin-svgr": "^4.3.0",
"wicg-inert": "^3.1.2",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0",
"workbox-webpack-plugin": "^7.0.0",
"workbox-window": "^7.0.0"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
"@formatjs/cli": "^6.1.1",
"@testing-library/dom": "^10.2.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@types/babel__core": "^7.20.1",
"@types/emoji-mart": "3.0.14",
"@types/escape-html": "^1.0.2",
"@types/hoist-non-react-statics": "^3.3.1",
@ -171,9 +145,6 @@
"@types/react-toggle": "^4.0.3",
"@types/redux-immutable": "^4.0.3",
"@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-import-resolver-typescript": "^4.2.5",
"eslint-plugin-formatjs": "^5.3.1",
@ -192,17 +163,13 @@
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^14.0.0",
"typescript": "~5.7.3",
"typescript-eslint": "^8.28.0",
"vite-plugin-rails": "^0.5.0",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^3.1.1",
"webpack-dev-server": "^3.11.3"
"typescript-eslint": "^8.29.1",
"vitest": "^3.1.2"
},
"resolutions": {
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",
"kind-of": "^6.0.3",
"webpack/terser-webpack-plugin": "^4.2.3"
"kind-of": "^6.0.3"
},
"peerDependenciesMeta": {
"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)
.to include(
href: have_attributes(value: match(/default/)),
href: have_attributes(value: match(/application/)),
media: have_attributes(value: '(prefers-color-scheme: dark)')
)
end

View File

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

View File

@ -1,58 +1,142 @@
/// <reference types="vitest" />
import fs from 'fs';
import path from 'path';
import fs from 'node:fs/promises';
import path from 'node:path';
import { optimizeLodashImports } from '@optimize-lodash/rollup-plugin';
import react from '@vitejs/plugin-react';
import RailsPlugin from 'vite-plugin-rails';
import { PluginOption } from 'vite';
import svgr from 'vite-plugin-svgr';
import { defineConfig, configDefaults } from 'vitest/config';
import { analyzer } from 'vite-bundle-analyzer';
import RailsPlugin from 'vite-plugin-rails';
import { VitePWA } from 'vite-plugin-pwa';
const sourceCodeDir = 'app/javascript';
const items = fs.readdirSync(sourceCodeDir);
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,
);
});
import { defineConfig, UserConfigFnPromise, UserConfig } from 'vite';
import postcssPresetEnv from 'postcss-preset-env';
export default defineConfig({
resolve: {
alias: {
...aliasesFromJavascriptRoot,
import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales';
const jsRoot = path.resolve(__dirname, 'app/javascript');
const entrypointRoot = path.resolve(jsRoot, 'entrypoints');
export const config: UserConfigFnPromise = async ({ mode, command }) => {
const entrypointFiles = await fs.readdir(entrypointRoot);
const entrypoints: Record<string, string> = entrypointFiles.reduce(
(acc, file) => {
const name = path.basename(file).replace(/\.tsx?$/, '');
acc[name] = path.resolve(entrypointRoot, file);
return acc;
},
},
plugins: [
RailsPlugin(),
react({
include: ['**/*.jsx', '**/*.tsx'],
babel: {
plugins: ['formatjs', 'preval', 'transform-react-remove-prop-types'],
{} as Record<string, string>,
);
return {
root: jsRoot,
css: {
postcss: {
plugins: [
postcssPresetEnv({
features: {
'logical-properties-and-values': false,
},
}),
],
},
}),
svgr(),
],
test: {
environment: 'jsdom',
include: [
...configDefaults.include,
'**/__tests__/**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
},
resolve: {
alias: {
mastodon: path.resolve(jsRoot, 'mastodon'),
'@': jsRoot,
},
},
server: {
headers: {
// This is needed in dev environment because we load the worker from `/dev-sw/dev-sw.js`,
// but it needs to be scoped to the whole domain
'Service-Worker-Allowed': '/',
},
},
build: {
commonjsOptions: { transformMixedEsModules: true },
chunkSizeWarningLimit: 1 * 1024 * 1024, // 1MB
manifest: 'manifest.json',
sourcemap: true,
rollupOptions: {
input: entrypoints,
output: {
chunkFileNames(chunkInfo) {
if (!chunkInfo.facadeModuleId) {
return '[name]-[hash].js';
}
if (
/mastodon\/locales\/[a-zA-Z-]+\.json/.exec(
chunkInfo.facadeModuleId,
)
) {
// put all locale files in `intl/`
return `intl/[name]-[hash].js`;
} else if (
/node_modules\/@formatjs\//.exec(chunkInfo.facadeModuleId)
) {
// use a custom name for formatjs polyfill files
const name = /node_modules\/@formatjs\/([^/]+)\//.exec(
chunkInfo.facadeModuleId,
);
if (name?.[1]) {
return `intl/[name]-${name[1]}-[hash].js`;
}
} else if (chunkInfo.name === 'index' && chunkInfo.facadeModuleId) {
// Use a custom name for chunks, to avoid having too many of them called "index"
const parts = chunkInfo.facadeModuleId.split('/');
const parent = parts.at(-2);
if (parent) {
return `${parent}-[name]-[hash].js`;
}
}
return `[name]-[hash].js`;
},
},
},
},
plugins: [
RailsPlugin({
compress: mode !== 'production' && command === 'build',
}),
react({
babel: {
plugins: ['formatjs', 'transform-react-remove-prop-types', 'preval'],
},
}),
MastodonServiceWorkerLocales(),
VitePWA({
srcDir: 'mastodon/service_worker',
// We need to use injectManifest because we use our own service worker
strategies: 'injectManifest',
manifest: false,
injectRegister: false,
injectManifest: {
// Do not inject a manifest, we dont use precache
injectionPoint: undefined,
buildPlugins: {
vite: [
// Provide a virtual import with only the locales used in the ServiceWorker
MastodonServiceWorkerLocales(),
],
},
// Force the output location, because we have a symlink in `public/sw.js`
},
outDir: path.resolve(__dirname, 'public/packs'),
devOptions: {
enabled: true,
type: 'module',
},
}),
svgr(),
// Old library types need to be converted
optimizeLodashImports() as PluginOption,
!!process.env.ANALYZE_BUNDLE_SIZE && analyzer({ analyzerMode: 'static' }),
],
exclude: [
...configDefaults.exclude,
'**/node_modules/**',
'vendor/**',
'config/**',
'log/**',
'public/**',
'tmp/**',
],
globals: true,
},
});
} satisfies UserConfig;
};
export default defineConfig(config);

26
vitest.config.mts Normal file
View File

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

8381
yarn.lock

File diff suppressed because it is too large Load Diff