Files
remark42/frontend/apps/remark42/webpack.config.js
T
Dmitry VerkhoturovandGitHub fc4e10573c Replace react-intl and remove React from the widget (#2176)
Second and final step of #2166. `react-intl` is replaced by
`app/common/intl.tsx`, a small i18n binding over Preact context, and with
`react-redux` already gone nothing holds the React compatibility alias.

React is now absent from the lockfile, the installed tree, the config and
the bundles: `react`, `react-dom`, `react-intl`, `@types/react`,
`@preact/compat`, `use-sync-external-store` and `intl-messageformat` are
all gone, along with the `paths` entries in `tsconfig.json` and three
babel-loader excludes. Runtime dependencies go from 15 to 10.

`preact/compat` goes too, which matters more than its 3.8 kB. Importing
it anywhere installs hooks on preact's shared `options` that remap
`onFocus`/`onBlur` to `focusin`/`focusout` for every element and make
`@testing-library/preact` rewrite `change` to `input`, the two bugs
behind #2166, still live until now. `Button` was wrapped in `forwardRef`
with no caller passing one, and `TextareaAutosize` now takes its ref as
an ordinary prop. The workaround in `sort-picker.spec.tsx` is gone with
them, since `fireEvent.change` reaches a `<select>` again.

Gzipped, against master: `remark.mjs` 76.17 kB to 56.47, `last-comments.mjs`
37.97 to 18.26, `deleteme.mjs` 14.51 to 8.42. The limits move with them and
keep more relative headroom than master shipped.

### The binding

`IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,
`FormattedMessage` and `IntlShape`. 32 files change only their import.

The export names copy react-intl's deliberately: `formatjs extract` finds
messages by recognising `defineMessages`, `FormattedMessage` and
`intl.formatMessage` in the AST rather than by import source, so renaming
one silently empties the catalogue. `frontend/CLAUDE.md` records that,
along with the destructive part: `translation:generate` would then strip
the unextracted keys from all 24 catalogues and the check would pass.

A message the binding cannot parse falls back to the message in the
source: a broken, unhandled or nested tag, a brace that is not a
well-formed placeholder, and a placeholder naming a value the caller did
not supply. `mk.json` and `th.json` carried broken markup and rendered in
English; both are repaired, so a catalogue sweep over every locale can now
require well-formed markup with no exceptions listed.

`translation:check` gained the validation that would have caught them when
they were proposed: a translation's tags have to be well-formed pairs of the
names the English string uses, with no attributes, and its placeholders have
to be ones the English string provides. Leaving a tag or a placeholder out
stays allowed. Run against master's catalogues it reports both.

### enzyme

`@types/enzyme` was the last thing pulling `@types/react`, so React could
not leave while enzyme stayed. Its three test files move to
`@testing-library/preact`, which now has no rival: `@testing-library/preact-hooks`
had one import left and its own unmet peer warning. `intersection-observer`
was a runtime dependency nothing imported, and the `cheerio` override lost
its last dependent with enzyme.

Enzyme's `.find(X).prop()` threw unless exactly one node matched, so the
converted tests assert node counts explicitly to keep that.

### Verified

All 181 message ids formatted across all 24 catalogues through both real
react-intl and this binding: 4344 comparisons, no differences. From a wiped
`node_modules`: `pnpm install --frozen-lockfile`, `pnpm lint`,
`pnpm type-check`, `pnpm test` (392 tests, 42 suites), `pnpm build`,
`pnpm size-check`, `pnpm translation-check`.
2026-08-21 02:30:26 -05:00

377 lines
10 KiB
JavaScript

require('dotenv').config();
const path = require('path');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const RefreshPlugin = require('@prefresh/webpack');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const incstr = require('incstr');
const babelConfig = require('./.babelrc.js');
const NODE_ID = 'remark42';
const PUBLIC_PATH = '/web/';
const PORT = process.env.PORT || 9000;
const REMARK_API_BASE_URL = process.env.REMARK_API_BASE_URL || 'http://127.0.0.1:8080';
const DEVSERVER_BASE_PATH = process.env.DEVSERVER_BASE_PATH || `http://127.0.0.1:${PORT}`;
const PUBLIC_FOLDER_PATH = path.resolve(__dirname, 'public');
const CUSTOM_PROPERTIES_PATH = path.resolve(__dirname, './app/styles/custom-properties.css');
const genId = incstr.idGenerator();
const modulesMap = {};
function getLocalIdent(loaderContext, _, localName, options) {
if (!options.context) {
options.context = loaderContext.rootContext;
}
const filepath = path.relative(options.context, loaderContext.resourcePath).replace(/\\/g, '/');
if (!modulesMap[filepath]) {
modulesMap[filepath] = { id: genId(), genId: incstr.idGenerator(), classNames: {} };
}
const m = modulesMap[filepath];
if (!m.classNames[localName]) {
m.classNames[localName] = m.genId();
}
return `${m.id}_${m.classNames[localName]}`;
}
/**
* Generates excludes for babel-loader
*
* Exclude is a module that has >=es6 code and resides in node_modules.
* By default, babel-loader ignores everything from node_modules,
* so we have to exclude from ignore these modules
*/
const exclude = [
'@github/markdown-toolbar-element',
'@github/text-expander-element',
].map((m) => path.resolve(__dirname, 'node_modules', m));
const htmlMinifyOptions = {
minifyCSS: true,
minifyJS: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
sortAttributes: true,
sortClassName: true,
useShortDoctype: true,
};
module.exports = (_, { mode, analyze }) => {
const isDev = mode === 'development';
// Use REMARK_URL or predefined host in dev environment
// In development: We use `http://127.0.0.1:9000` for access to backend and backend is accessible via dev server proxy
// In production: {% REMARK_URL %} will be replaced by `sed` on start of prod
const REMARK_URL = isDev ? DEVSERVER_BASE_PATH : '{% REMARK_URL %}';
// Add debug lib only for development throw webpack chunks and keep code clear
const preactDebug = isDev ? ['preact/debug'] : [];
const entry = {
embed: './app/embed.ts',
counter: './app/counter.ts',
deleteme: './app/deleteme.ts',
'last-comments': [...preactDebug, CUSTOM_PROPERTIES_PATH, './app/last-comments.tsx'],
remark: [...preactDebug, CUSTOM_PROPERTIES_PATH, './app/remark.tsx'],
};
const resolve = {
extensions: ['.ts', '.tsx', '.js'],
plugins: [new TsconfigPathsPlugin()],
};
const output = {
path: PUBLIC_FOLDER_PATH,
publicPath: PUBLIC_PATH,
};
const getTsRule = (babelConfig = {}) => {
return {
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
exclude,
cacheDirectory: true,
...babelConfig,
},
},
{
loader: 'ts-loader',
options: {
transpileOnly: true,
// tsconfig targets the automatic runtime so type checking resolves JSX from
// preact; the bundle keeps JSX intact here so babel can still strip test ids
compilerOptions: { jsx: 'preserve' },
},
},
],
};
};
const cssRule = {
test: /\.css$/,
exclude: [/\.module\.css$/, /node_modules/],
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader',
{
loader: 'postcss-loader',
options: {
sourceMap: isDev,
postcssOptions: {
plugins: [
[
'postcss-preset-env',
{
browsers: 'defaults, not IE 11, not samsung 12',
stage: 0,
features: {
'custom-properties': CUSTOM_PROPERTIES_PATH,
},
},
],
'cssnano',
],
},
},
},
],
};
const cssModulesRule = {
test: /\.module\.css$/,
exclude: /node_modules/,
use: [
isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
importLoaders: 1,
modules: {
localIdentName: '[name]__[local]_[hash:5]',
getLocalIdent: isDev ? undefined : getLocalIdent,
},
},
},
{
loader: 'postcss-loader',
options: {
sourceMap: isDev,
postcssOptions: {
plugins: [
['postcss-preset-env', { stage: 0 }],
['postcss-custom-properties', { importFrom: CUSTOM_PROPERTIES_PATH }],
'cssnano',
],
},
},
},
],
};
const urlRule = {
test: /\.(png|jpg|jpeg|gif|svg)$/,
exclude: /node_modules/,
use: {
loader: 'url-loader',
options: {
name: '[name].[ext]',
publicPath: PUBLIC_PATH,
limit: false,
},
},
};
const rules = [cssRule, cssModulesRule, urlRule];
const devServer = {
port: PORT,
devMiddleware: {
stats: 'minimal',
},
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
},
static: {
staticOptions: {
contentBase: PUBLIC_FOLDER_PATH,
watchOptions: {
ignored: [PUBLIC_FOLDER_PATH, path.resolve(__dirname, 'node_modules')],
},
},
watch: true,
},
allowedHosts: 'all',
hot: true,
proxy: [
{ path: '/api', target: REMARK_API_BASE_URL, changeOrigin: true },
{ path: '/auth', target: REMARK_API_BASE_URL, changeOrigin: true },
],
client: {
overlay: false,
},
};
const plugins = [
...(isDev ? [new CleanWebpackPlugin(), new RefreshPlugin()] : []),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(mode),
'process.env.REMARK_NODE': JSON.stringify(NODE_ID),
'process.env.REMARK_URL': isDev ? 'window.location.origin' : JSON.stringify(REMARK_URL),
}),
new MiniCssExtractPlugin({
filename: '[name].css',
}),
];
const optimization = {
// doc: https://webpack.js.org/plugins/css-minimizer-webpack-plugin/
minimizer: [`...`, new CssMinimizerPlugin()],
};
const config = {
entry,
devtool: isDev ? 'source-map' : false,
resolve,
optimization,
};
const legacyConfig = {
...config,
output: {
...output,
filename: '[name].js',
chunkFilename: '[name].js',
},
module: {
rules: [getTsRule(), ...rules],
},
plugins: [
...plugins,
...(analyze
? [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'report-legacy.html',
reportTitle: 'Legacy build',
}),
]
: []),
],
};
const modernConfig = {
...config,
output: {
...output,
filename: '[name].mjs',
chunkFilename: '[name].mjs',
},
module: {
rules: [
getTsRule({
...babelConfig.env.modern,
plugins: [...babelConfig.env.modern.plugins, ...(isDev ? ['@prefresh/babel-plugin'] : [])],
}),
...rules,
],
},
plugins: [
...plugins,
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, 'templates/400x400.jpeg'),
to: PUBLIC_FOLDER_PATH,
},
],
}),
new ForkTsCheckerWebpackPlugin(),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'templates/iframe.ejs'),
filename: 'iframe.html',
inject: false,
env: mode,
minify: htmlMinifyOptions,
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'templates/demo.ejs'),
filename: 'index.html',
inject: false,
REMARK_URL,
minify: htmlMinifyOptions,
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'templates/counter.ejs'),
filename: 'counter.html',
inject: false,
REMARK_URL,
minify: htmlMinifyOptions,
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'templates/last-comments.ejs'),
filename: 'last-comments.html',
inject: false,
env: mode,
REMARK_URL,
minify: htmlMinifyOptions,
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'templates/deleteme.ejs'),
filename: 'deleteme.html',
inject: false,
REMARK_URL,
minify: htmlMinifyOptions,
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'templates/markdown-help.html'),
filename: 'markdown-help.html',
inject: false,
minify: htmlMinifyOptions,
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'templates/privacy.html'),
filename: 'privacy.html',
inject: false,
minify: htmlMinifyOptions,
}),
...(analyze
? [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'report-modern.html',
reportTitle: 'Modern build',
}),
]
: []),
],
devServer,
};
if (isDev) {
return modernConfig;
}
return [legacyConfig, modernConfig];
};
module.exports.CUSTOM_PROPERTIES_PATH = CUSTOM_PROPERTIES_PATH;
module.exports.exclude = exclude;