diff --git a/.gitignore b/.gitignore
index a74317bd7d8..61bcbeb67bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,3 +74,5 @@ docker-compose.override.yml
# Ignore local-only rspec configuration
.rspec-local
+
+/.dist
diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx
index 225cb16330f..8f5c2d9f5e0 100644
--- a/app/javascript/entrypoints/admin.tsx
+++ b/app/javascript/entrypoints/admin.tsx
@@ -1,4 +1,3 @@
-import './public-path';
import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
diff --git a/app/javascript/entrypoints/application.ts b/app/javascript/entrypoints/application.ts
index 1087b1c4cb5..a2b9fce06c0 100644
--- a/app/javascript/entrypoints/application.ts
+++ b/app/javascript/entrypoints/application.ts
@@ -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();
diff --git a/app/javascript/entrypoints/embed.tsx b/app/javascript/entrypoints/embed.tsx
index 6c091e4d077..c1cd32e6a2f 100644
--- a/app/javascript/entrypoints/embed.tsx
+++ b/app/javascript/entrypoints/embed.tsx
@@ -1,4 +1,3 @@
-import './public-path';
import { createRoot } from 'react-dom/client';
import { afterInitialRender } from 'mastodon/hooks/useRenderSignal';
diff --git a/app/javascript/entrypoints/error.ts b/app/javascript/entrypoints/error.ts
index db68484f3a8..af9d484de18 100644
--- a/app/javascript/entrypoints/error.ts
+++ b/app/javascript/entrypoints/error.ts
@@ -1,4 +1,3 @@
-import './public-path';
import ready from '../mastodon/ready';
ready(() => {
diff --git a/app/javascript/entrypoints/index.html b/app/javascript/entrypoints/index.html
new file mode 100644
index 00000000000..025030ba46d
--- /dev/null
+++ b/app/javascript/entrypoints/index.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/entrypoints/mailer.ts b/app/javascript/entrypoints/mailer.ts
index a2ad5e73ac2..22b3ef6ecdc 100644
--- a/app/javascript/entrypoints/mailer.ts
+++ b/app/javascript/entrypoints/mailer.ts
@@ -1,3 +1 @@
import '../styles/mailer.scss';
-
-require.context('../icons');
diff --git a/app/javascript/entrypoints/public-path.ts b/app/javascript/entrypoints/public-path.ts
deleted file mode 100644
index ac4b9355b95..00000000000
--- a/app/javascript/entrypoints/public-path.ts
+++ /dev/null
@@ -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('meta[name=cdn-host]');
-
-__webpack_public_path__ = formatPublicPath(
- cdnHost ? cdnHost.content : '',
- process.env.PUBLIC_OUTPUT_PATH,
-);
diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx
index 9374d6b2d1e..86487503db2 100644
--- a/app/javascript/entrypoints/public.tsx
+++ b/app/javascript/entrypoints/public.tsx
@@ -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';
diff --git a/app/javascript/entrypoints/remote_interaction_helper.ts b/app/javascript/entrypoints/remote_interaction_helper.ts
index 419571c8964..f50203747d8 100644
--- a/app/javascript/entrypoints/remote_interaction_helper.ts
+++ b/app/javascript/entrypoints/remote_interaction_helper.ts
@@ -8,8 +8,6 @@ and performs no other task.
*/
-import './public-path';
-
import axios from 'axios';
interface JRDLink {
diff --git a/app/javascript/entrypoints/share.tsx b/app/javascript/entrypoints/share.tsx
index 79262508510..f6fda68a39b 100644
--- a/app/javascript/entrypoints/share.tsx
+++ b/app/javascript/entrypoints/share.tsx
@@ -1,4 +1,3 @@
-import './public-path';
import { createRoot } from 'react-dom/client';
import { start } from '../mastodon/common';
diff --git a/app/javascript/entrypoints/sign_up.ts b/app/javascript/entrypoints/sign_up.ts
index 880738fcb77..21fdce190ee 100644
--- a/app/javascript/entrypoints/sign_up.ts
+++ b/app/javascript/entrypoints/sign_up.ts
@@ -1,4 +1,3 @@
-import './public-path';
import axios from 'axios';
import ready from '../mastodon/ready';
diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js
index 5cd3a1317de..b707f017f52 100644
--- a/app/javascript/mastodon/features/emoji/emoji_compressed.js
+++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js
@@ -10,18 +10,17 @@
// version: 3
// This json file contains the names of the categories.
-const emojiMart5LocalesData = require('@emoji-mart/data/i18n/en.json');
-const emojiMart5Data = require('@emoji-mart/data/sets/15/all.json');
-const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
-const _ = require('lodash');
+import emojiMart5LocalesData from '@emoji-mart/data/i18n/en.json';
+import emojiMart5Data from '@emoji-mart/data/sets/15/all.json';
+import { uncompress as emojiMartUncompress } from 'emoji-mart/dist/utils/data';
+import _ from 'lodash';
-
-const emojiMap = require('./emoji_map.json');
+import emojiMap from './emoji_map.json';
// This json file is downloaded from https://github.com/iamcal/emoji-data/
// and is used to correct the sheet coordinates since we're using that repo's sheet
-const emojiSheetData = require('./emoji_sheet.json');
-const { unicodeToFilename } = require('./unicode_to_filename');
-const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+import emojiSheetData from './emoji_sheet.json';
+import { unicodeToFilename } from './unicode_to_filename';
+import { unicodeToUnifiedName } from './unicode_to_unified_name';
// Grabbed from `emoji_utils` to avoid circular dependency
function unifiedToNative(unified) {
@@ -181,7 +180,7 @@ Object.keys(emojiMartData.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([
+export default JSON.parse(JSON.stringify([
shortCodesToEmojiData,
/*
* The property `skins` is not found in the current context.
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename.js b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
index c75c4cd7d05..cfe5539c7b9 100644
--- a/app/javascript/mastodon/features/emoji/unicode_to_filename.js
+++ b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
@@ -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;
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
index d29550f1226..15f60aa7c30 100644
--- a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
+++ b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
@@ -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) {
diff --git a/app/javascript/mastodon/locales/load_locale.ts b/app/javascript/mastodon/locales/load_locale.ts
index d21675b1797..8a6116f3245 100644
--- a/app/javascript/mastodon/locales/load_locale.ts
+++ b/app/javascript/mastodon/locales/load_locale.ts
@@ -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('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 });
});
diff --git a/app/javascript/mastodon/polyfills/intl.ts b/app/javascript/mastodon/polyfills/intl.ts
index b825da66214..b7a06e557af 100644
--- a/app/javascript/mastodon/polyfills/intl.ts
+++ b/app/javascript/mastodon/polyfills/intl.ts
@@ -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`
);
}
diff --git a/config/vite/plugin-manifest-sri.ts b/config/vite/plugin-manifest-sri.ts
new file mode 100644
index 00000000000..4c768abfa5e
--- /dev/null
+++ b/config/vite/plugin-manifest-sri.ts
@@ -0,0 +1,90 @@
+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[];
+
+ /**
+ * Path of the manifest files that should be read and augmented with the
+ * integrity hash, relative to `outDir`.
+ * @default ['manifest.json', 'manifest-assets.json']
+ */
+ manifestPaths?: string[];
+}
+
+declare module 'vite' {
+ interface ManifestChunk {
+ integrity: string;
+ }
+}
+
+export function manifestSRI(options: Options = {}): Plugin {
+ const {
+ algorithms = ['sha384'],
+ manifestPaths = [
+ '.vite/manifest.json',
+ '.vite/manifest-assets.json',
+ 'manifest.json',
+ 'manifest-assets.json',
+ ],
+ } = options;
+
+ return {
+ name: 'vite-plugin-manifest-sri',
+ apply: 'build',
+ enforce: 'post',
+ async writeBundle({ dir }) {
+ await Promise.all(
+ manifestPaths.map((path) =>
+ augmentManifest(path, 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) {
+ await Promise.all(
+ Object.values(manifest).map(async (chunk) => {
+ chunk.integrity = integrityForAsset(
+ await fs.readFile(resolveInOutDir(chunk.file)),
+ 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}`;
+}
diff --git a/tsconfig.json b/tsconfig.json
index 4eb94e43189..8e9955ce641 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"jsx": "react-jsx",
"target": "esnext",
- "module": "CommonJS",
+ "module": "ES2022",
"moduleResolution": "node",
"allowJs": true,
"noEmit": true,
@@ -11,7 +11,7 @@
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
- "types": ["vitest/globals", "@types/webpack-env"],
+ "types": ["vite/client", "vitest/globals", "@types/webpack-env"],
"baseUrl": "./",
"incremental": true,
"tsBuildInfoFile": "tmp/cache/tsconfig.tsbuildinfo",
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000000..f7fe769e09d
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,54 @@
+import path from 'node:path';
+
+import { defineConfig } from 'vite';
+
+import { manifestSRI } from './config/vite/plugin-manifest-sri';
+
+// eslint-disable-next-line import/no-default-export
+export default defineConfig({
+ root: './app/javascript/entrypoints',
+ build: {
+ commonjsOptions: { transformMixedEsModules: true },
+ outDir: path.resolve(__dirname, '.dist'),
+ emptyOutDir: true,
+ manifest: 'manifest.json',
+ rollupOptions: {
+ output: {
+ chunkFileNames: (chunkInfo) => {
+ if (
+ chunkInfo.facadeModuleId?.match(
+ /mastodon\/locales\/[a-zA-Z-]+\.json/,
+ )
+ ) {
+ // put all locale files in `intl/`
+ return `intl/[name]-[hash].js`;
+ } else if (
+ chunkInfo.facadeModuleId?.match(/node_modules\/@formatjs\//)
+ ) {
+ // 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: [manifestSRI()],
+ resolve: {
+ alias: {
+ mastodon: path.resolve(__dirname, 'app/javascript/mastodon'),
+ '@': path.resolve(__dirname, 'app/javascript'),
+ },
+ },
+});
diff --git a/yarn.lock b/yarn.lock
index 0a9d4d8be44..2f1682711a9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2533,14 +2533,7 @@ __metadata:
languageName: node
linkType: hard
-"@eslint-community/regexpp@npm:^4.10.0":
- version: 4.10.0
- resolution: "@eslint-community/regexpp@npm:4.10.0"
- checksum: 10c0/c5f60ef1f1ea7649fa7af0e80a5a79f64b55a8a8fa5086de4727eb4c86c652aedee407a9c143b8995d2c0b2d75c1222bec9ba5d73dbfc1f314550554f0979ef4
- languageName: node
- linkType: hard
-
-"@eslint-community/regexpp@npm:^4.12.1":
+"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.12.1":
version: 4.12.1
resolution: "@eslint-community/regexpp@npm:4.12.1"
checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6
@@ -4118,16 +4111,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/babel__traverse@npm:*":
- version: 7.20.3
- resolution: "@types/babel__traverse@npm:7.20.3"
- dependencies:
- "@babel/types": "npm:^7.20.7"
- checksum: 10c0/295ed9b837e62e17ee43be0df45d90fff5208986bd43af593c9020d152d3b2c55328e038c2f8585926b63cc22f887f28bf3f4c805aa881e2dd0bdd5ead92ece0
- languageName: node
- linkType: hard
-
-"@types/babel__traverse@npm:^7.20.6":
+"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.20.6":
version: 7.20.6
resolution: "@types/babel__traverse@npm:7.20.6"
dependencies:
@@ -12068,17 +12052,7 @@ __metadata:
languageName: node
linkType: hard
-"micromatch@npm:^4.0.4":
- version: 4.0.7
- resolution: "micromatch@npm:4.0.7"
- dependencies:
- braces: "npm:^3.0.3"
- picomatch: "npm:^2.3.1"
- checksum: 10c0/58fa99bc5265edec206e9163a1d2cec5fabc46a5b473c45f4a700adce88c2520456ae35f2b301e4410fb3afb27e9521fb2813f6fc96be0a48a89430e0916a772
- languageName: node
- linkType: hard
-
-"micromatch@npm:^4.0.5, micromatch@npm:^4.0.8, micromatch@npm:~4.0.8":
+"micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8, micromatch@npm:~4.0.8":
version: 4.0.8
resolution: "micromatch@npm:4.0.8"
dependencies: