gitlab-org--gitlab-foss/config/webpack.config.js

615 lines
20 KiB
JavaScript
Raw Normal View History

const fs = require('fs');
2018-02-28 02:19:10 -05:00
const path = require('path');
const glob = require('glob');
const webpack = require('webpack');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
2018-02-28 02:19:10 -05:00
const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin;
const CompressionPlugin = require('compression-webpack-plugin');
const MonacoWebpackPlugin = require('./plugins/monaco_webpack');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const CopyWebpackPlugin = require('copy-webpack-plugin');
const vendorDllHash = require('./helpers/vendor_dll_hash');
const createIncrementalWebpackCompiler = require('./helpers/incremental_webpack_compiler');
2018-02-28 02:19:10 -05:00
const ROOT_PATH = path.resolve(__dirname, '..');
const VENDOR_DLL = process.env.WEBPACK_VENDOR_DLL && process.env.WEBPACK_VENDOR_DLL !== 'false';
const CACHE_PATH = process.env.WEBPACK_CACHE_PATH || path.join(ROOT_PATH, 'tmp/cache');
2018-02-28 02:19:10 -05:00
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
const IS_DEV_SERVER = process.env.WEBPACK_DEV_SERVER === 'true';
2019-03-28 13:02:56 -04:00
const IS_EE = require('./helpers/is_ee_env');
2018-02-28 02:19:10 -05:00
const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost';
const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808;
const DEV_SERVER_PUBLIC_ADDR = process.env.DEV_SERVER_PUBLIC_ADDR;
const DEV_SERVER_ALLOWED_HOSTS =
process.env.DEV_SERVER_ALLOWED_HOSTS && process.env.DEV_SERVER_ALLOWED_HOSTS.split(',');
const DEV_SERVER_HTTPS = process.env.DEV_SERVER_HTTPS && process.env.DEV_SERVER_HTTPS !== 'false';
2018-05-18 19:23:28 -04:00
const DEV_SERVER_LIVERELOAD = IS_DEV_SERVER && process.env.DEV_SERVER_LIVERELOAD !== 'false';
const INCREMENTAL_COMPILER_ENABLED =
IS_DEV_SERVER &&
process.env.DEV_SERVER_INCREMENTAL &&
process.env.DEV_SERVER_INCREMENTAL !== 'false';
const WEBPACK_REPORT = process.env.WEBPACK_REPORT && process.env.WEBPACK_REPORT !== 'false';
const WEBPACK_MEMORY_TEST =
process.env.WEBPACK_MEMORY_TEST && process.env.WEBPACK_MEMORY_TEST !== 'false';
const NO_COMPRESSION = process.env.NO_COMPRESSION && process.env.NO_COMPRESSION !== 'false';
const NO_SOURCEMAPS = process.env.NO_SOURCEMAPS && process.env.NO_SOURCEMAPS !== 'false';
2018-02-28 02:19:10 -05:00
const WEBPACK_OUTPUT_PATH = path.join(ROOT_PATH, 'public/assets/webpack');
const WEBPACK_PUBLIC_PATH = '/assets/webpack/';
const SOURCEGRAPH_PACKAGE = '@sourcegraph/code-host-integration';
const VUE_VERSION = require('vue/package.json').version;
const VUE_LOADER_VERSION = require('vue-loader/package.json').version;
const WEBPACK_VERSION = require('webpack/package.json').version;
const SOURCEGRAPH_VERSION = require(path.join(SOURCEGRAPH_PACKAGE, 'package.json')).version;
const SOURCEGRAPH_PATH = path.join('sourcegraph', SOURCEGRAPH_VERSION, '/');
const SOURCEGRAPH_OUTPUT_PATH = path.join(WEBPACK_OUTPUT_PATH, SOURCEGRAPH_PATH);
const SOURCEGRAPH_PUBLIC_PATH = path.join(WEBPACK_PUBLIC_PATH, SOURCEGRAPH_PATH);
const devtool = IS_PRODUCTION ? 'source-map' : 'cheap-module-eval-source-map';
2018-02-28 02:19:10 -05:00
let autoEntriesCount = 0;
let watchAutoEntries = [];
2018-05-06 02:19:04 -04:00
const defaultEntries = ['./main'];
const incrementalCompiler = createIncrementalWebpackCompiler(
INCREMENTAL_COMPILER_ENABLED,
path.join(CACHE_PATH, 'incremental-webpack-compiler-history.json'),
);
function generateEntries() {
// generate automatic entry points
2018-02-28 02:19:10 -05:00
const autoEntries = {};
const autoEntriesMap = {};
2018-03-23 12:52:54 -04:00
const pageEntries = glob.sync('pages/**/index.js', {
cwd: path.join(ROOT_PATH, 'app/assets/javascripts'),
});
watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')];
function generateAutoEntries(path, prefix = '.') {
const chunkPath = path.replace(/\/index\.js$/, '');
const chunkName = chunkPath.replace(/\//g, '.');
autoEntriesMap[chunkName] = `${prefix}/${path}`;
}
pageEntries.forEach((path) => generateAutoEntries(path));
if (IS_EE) {
const eePageEntries = glob.sync('pages/**/index.js', {
cwd: path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
});
eePageEntries.forEach((path) => generateAutoEntries(path, 'ee'));
watchAutoEntries.push(path.join(ROOT_PATH, 'ee/app/assets/javascripts/pages/'));
}
const autoEntryKeys = Object.keys(autoEntriesMap);
autoEntriesCount = autoEntryKeys.length;
// import ancestor entrypoints within their children
autoEntryKeys.forEach((entry) => {
const entryPaths = [autoEntriesMap[entry]];
const segments = entry.split('.');
while (segments.pop()) {
const ancestor = segments.join('.');
if (autoEntryKeys.includes(ancestor)) {
entryPaths.unshift(autoEntriesMap[ancestor]);
}
}
autoEntries[entry] = defaultEntries.concat(entryPaths);
});
const manualEntries = {
default: defaultEntries,
// sentry: './sentry/index.js', Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
performance_bar: './performance_bar/index.js',
chrome_84_icon_fix: './lib/chrome_84_icon_fix.js',
jira_connect_app: './jira_connect/index.js',
};
return Object.assign(manualEntries, incrementalCompiler.filterEntryPoints(autoEntries));
}
const alias = {
'~': path.join(ROOT_PATH, 'app/assets/javascripts'),
emojis: path.join(ROOT_PATH, 'fixtures/emojis'),
empty_states: path.join(ROOT_PATH, 'app/views/shared/empty_states'),
icons: path.join(ROOT_PATH, 'app/views/shared/icons'),
images: path.join(ROOT_PATH, 'app/assets/images'),
vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'),
vue$: 'vue/dist/vue.esm.js',
spec: path.join(ROOT_PATH, 'spec/javascripts'),
jest: path.join(ROOT_PATH, 'spec/frontend'),
shared_queries: path.join(ROOT_PATH, 'app/graphql/queries'),
// the following resolves files which are different between CE and EE
ee_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'),
// override loader path for icons.svg so we do not duplicate this asset
'@gitlab/svgs/dist/icons.svg': path.join(
ROOT_PATH,
'app/assets/javascripts/lib/utils/icons_path.js',
),
};
if (IS_EE) {
Object.assign(alias, {
ee: path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
ee_component: path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
ee_empty_states: path.join(ROOT_PATH, 'ee/app/views/shared/empty_states'),
ee_icons: path.join(ROOT_PATH, 'ee/app/views/shared/icons'),
ee_images: path.join(ROOT_PATH, 'ee/app/assets/images'),
ee_spec: path.join(ROOT_PATH, 'ee/spec/javascripts'),
ee_jest: path.join(ROOT_PATH, 'ee/spec/frontend'),
ee_else_ce: path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
});
}
if (!IS_PRODUCTION) {
const fixtureDir = IS_EE ? 'fixtures-ee' : 'fixtures';
Object.assign(alias, {
test_fixtures: path.join(ROOT_PATH, `tmp/tests/frontend/${fixtureDir}`),
test_helpers: path.join(ROOT_PATH, 'spec/frontend_integration/test_helpers'),
});
}
let dll;
if (VENDOR_DLL && !IS_PRODUCTION) {
const dllHash = vendorDllHash();
const dllCachePath = path.join(ROOT_PATH, `tmp/cache/webpack-dlls/${dllHash}`);
dll = {
manifestPath: path.join(dllCachePath, 'vendor.dll.manifest.json'),
cacheFrom: dllCachePath,
cacheTo: path.join(WEBPACK_OUTPUT_PATH, `dll.${dllHash}/`),
publicPath: `dll.${dllHash}/vendor.dll.bundle.js`,
exists: null,
};
}
2018-05-18 19:23:28 -04:00
module.exports = {
2018-03-02 16:12:49 -05:00
mode: IS_PRODUCTION ? 'production' : 'development',
context: path.join(ROOT_PATH, 'app/assets/javascripts'),
entry: generateEntries,
output: {
path: WEBPACK_OUTPUT_PATH,
publicPath: WEBPACK_PUBLIC_PATH,
filename: IS_PRODUCTION ? '[name].[contenthash:8].bundle.js' : '[name].bundle.js',
chunkFilename: IS_PRODUCTION ? '[name].[contenthash:8].chunk.js' : '[name].chunk.js',
globalObject: 'this', // allow HMR and web workers to play nice
},
2018-05-18 19:23:28 -04:00
resolve: {
extensions: ['.js', '.gql', '.graphql'],
alias,
2018-03-02 16:12:49 -05:00
},
2016-10-19 15:29:43 -04:00
module: {
2018-05-18 19:23:28 -04:00
strictExportPresence: true,
2017-02-08 16:24:08 -05:00
rules: [
{
type: 'javascript/auto',
test: /\.mjs$/,
use: [],
},
2016-10-19 15:29:43 -04:00
{
test: /\.js$/,
exclude: (path) =>
/node_modules\/(?!tributejs)|node_modules|vendor[\\/]assets/.test(path) &&
!/\.vue\.js/.test(path),
2017-04-06 16:45:12 -04:00
loader: 'babel-loader',
options: {
cacheDirectory: path.join(CACHE_PATH, 'babel-loader'),
},
2017-02-24 13:35:25 -05:00
},
{
test: /\.vue$/,
2017-04-06 16:45:12 -04:00
loader: 'vue-loader',
options: {
cacheDirectory: path.join(CACHE_PATH, 'vue-loader'),
cacheIdentifier: [
process.env.NODE_ENV || 'development',
webpack.version,
VUE_VERSION,
VUE_LOADER_VERSION,
].join('|'),
},
},
{
test: /\.(graphql|gql)$/,
exclude: /node_modules/,
loader: 'graphql-tag/loader',
},
2019-07-29 16:25:35 -04:00
{
test: /icons\.svg$/,
loader: 'file-loader',
options: {
name: '[name].[contenthash:8].[ext]',
2019-07-29 16:25:35 -04:00
},
},
2017-02-24 13:35:25 -05:00
{
test: /\.svg$/,
2019-07-29 16:25:35 -04:00
exclude: /icons\.svg$/,
2017-04-06 16:45:12 -04:00
loader: 'raw-loader',
},
2017-04-11 22:44:22 -04:00
{
test: /\.(gif|png|mp4)$/,
2017-04-11 22:44:22 -04:00
loader: 'url-loader',
2017-04-15 19:38:07 -04:00
options: { limit: 2048 },
2017-04-11 22:44:22 -04:00
},
2017-11-22 11:40:06 -05:00
{
test: /_worker\.js$/,
use: [
{
loader: 'worker-loader',
options: {
name: '[name].[contenthash:8].worker.js',
inline: IS_DEV_SERVER,
},
},
'babel-loader',
],
2017-11-22 11:40:06 -05:00
},
2017-04-06 16:45:12 -04:00
{
2017-08-07 03:47:29 -04:00
test: /\.(worker(\.min)?\.js|pdf|bmpr)$/,
2017-04-03 14:39:50 -04:00
exclude: /node_modules/,
loader: 'file-loader',
2017-08-07 03:47:29 -04:00
options: {
name: '[name].[contenthash:8].[ext]',
2018-03-23 12:52:54 -04:00
},
2017-04-03 14:39:50 -04:00
},
{
test: /.css$/,
use: [
'vue-style-loader',
{
loader: 'css-loader',
options: {
modules: 'global',
localIdentName: '[name].[contenthash:8].[ext]',
2018-03-23 12:52:54 -04:00
},
},
],
},
{
test: /\.(eot|ttf|woff|woff2)$/,
include: /node_modules\/(katex\/dist\/fonts|monaco-editor)/,
loader: 'file-loader',
options: {
name: '[name].[contenthash:8].[ext]',
esModule: false,
2018-03-23 12:52:54 -04:00
},
},
],
2018-05-18 19:23:28 -04:00
},
optimization: {
// Replace 'hashed' with 'deterministic' in webpack 5
moduleIds: 'hashed',
2018-05-18 19:23:28 -04:00
runtimeChunk: 'single',
splitChunks: {
maxInitialRequests: 20,
// In order to prevent firewalls tripping up: https://gitlab.com/gitlab-org/gitlab/-/issues/22648
automaticNameDelimiter: '-',
2018-05-18 19:23:28 -04:00
cacheGroups: {
default: false,
common: () => ({
priority: 20,
name: 'main',
chunks: 'initial',
minChunks: autoEntriesCount * 0.9,
}),
graphql: {
priority: 16,
name: 'graphql',
chunks: 'all',
test: /[\\/]node_modules[\\/][^\\/]*(immer|apollo|graphql|zen-observable)[^\\/]*[\\/]/,
minChunks: 2,
reuseExistingChunk: true,
},
monaco: {
priority: 15,
name: 'monaco',
chunks: 'all',
test: /[\\/]node_modules[\\/]monaco-editor[\\/]/,
minChunks: 2,
reuseExistingChunk: true,
},
echarts: {
priority: 14,
name: 'echarts',
chunks: 'all',
test: /[\\/]node_modules[\\/](echarts|zrender)[\\/]/,
minChunks: 2,
reuseExistingChunk: true,
},
security_reports: {
priority: 13,
name: 'security_reports',
chunks: 'initial',
test: /[\\/](vue_shared[\\/](security_reports|license_compliance)|security_dashboard)[\\/]/,
minChunks: 2,
reuseExistingChunk: true,
},
2018-05-18 19:23:28 -04:00
vendors: {
priority: 10,
chunks: 'async',
test: /[\\/](node_modules|vendor[\\/]assets[\\/]javascripts)[\\/]/,
},
commons: {
chunks: 'all',
minChunks: 2,
reuseExistingChunk: true,
},
},
},
2016-10-19 15:29:43 -04:00
},
plugins: [
// manifest filename must match config.webpack.manifest_filename
// webpack-rails only needs assetsByChunkName to function properly
new StatsWriterPlugin({
filename: 'manifest.json',
transform: function (data, opts) {
2018-02-28 02:19:10 -05:00
const stats = opts.compiler.getStats().toJson({
chunkModules: false,
source: false,
chunks: false,
modules: false,
2018-03-23 12:52:54 -04:00
assets: true,
errors: !IS_PRODUCTION,
warnings: !IS_PRODUCTION,
});
// tell our rails helper where to find the DLL files
if (dll) {
stats.dllAssets = dll.publicPath;
}
return JSON.stringify(stats, null, 2);
2018-03-23 12:52:54 -04:00
},
2017-01-13 11:11:35 -05:00
}),
2017-03-01 16:47:52 -05:00
// enable vue-loader to use existing loader rules for other module types
new VueLoaderPlugin(),
// automatically configure monaco editor web workers
new MonacoWebpackPlugin(),
// fix legacy jQuery plugins which depend on globals
new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
Popper: ['popper.js', 'default'],
Alert: 'exports-loader?Alert!bootstrap/js/dist/alert',
Button: 'exports-loader?Button!bootstrap/js/dist/button',
Carousel: 'exports-loader?Carousel!bootstrap/js/dist/carousel',
Collapse: 'exports-loader?Collapse!bootstrap/js/dist/collapse',
Dropdown: 'exports-loader?Dropdown!bootstrap/js/dist/dropdown',
Modal: 'exports-loader?Modal!bootstrap/js/dist/modal',
Popover: 'exports-loader?Popover!bootstrap/js/dist/popover',
Scrollspy: 'exports-loader?Scrollspy!bootstrap/js/dist/scrollspy',
Tab: 'exports-loader?Tab!bootstrap/js/dist/tab',
Tooltip: 'exports-loader?Tooltip!bootstrap/js/dist/tooltip',
Util: 'exports-loader?Util!bootstrap/js/dist/util',
}),
// if DLLs are enabled, detect whether the DLL exists and create it automatically if necessary
dll && {
apply(compiler) {
compiler.hooks.beforeCompile.tapAsync('DllAutoCompilePlugin', (params, callback) => {
if (dll.exists) {
callback();
} else if (fs.existsSync(dll.manifestPath)) {
console.log(`Using vendor DLL found at: ${dll.cacheFrom}`);
dll.exists = true;
callback();
} else {
console.log(
`Warning: No vendor DLL found at: ${dll.cacheFrom}. Compiling DLL automatically.`,
);
const dllConfig = require('./webpack.vendor.config.js');
const dllCompiler = webpack(dllConfig);
dllCompiler.run((err, stats) => {
if (err) {
return callback(err);
}
const info = stats.toJson();
if (stats.hasErrors()) {
console.error(info.errors.join('\n\n'));
return callback('DLL not compiled successfully.');
}
if (stats.hasWarnings()) {
console.warn(info.warnings.join('\n\n'));
console.warn('DLL compiled with warnings.');
} else {
console.log('DLL compiled successfully.');
}
dll.exists = true;
callback();
});
}
});
},
},
// reference our compiled DLL modules
dll &&
new webpack.DllReferencePlugin({
context: ROOT_PATH,
manifest: dll.manifestPath,
}),
dll &&
new CopyWebpackPlugin([
{
from: dll.cacheFrom,
to: dll.cacheTo,
},
]),
!IS_EE &&
new webpack.NormalModuleReplacementPlugin(/^ee_component\/(.*)\.vue/, (resource) => {
resource.request = path.join(
ROOT_PATH,
'app/assets/javascripts/vue_shared/components/empty_component.js',
);
}),
new CopyWebpackPlugin([
{
from: path.join(ROOT_PATH, 'node_modules/pdfjs-dist/cmaps/'),
to: path.join(WEBPACK_OUTPUT_PATH, 'cmaps/'),
},
{
from: path.join(ROOT_PATH, 'node_modules', SOURCEGRAPH_PACKAGE, '/'),
to: SOURCEGRAPH_OUTPUT_PATH,
ignore: ['package.json'],
},
{
from: path.join(
ROOT_PATH,
'node_modules/@gitlab/visual-review-tools/dist/visual_review_toolbar.js',
),
to: WEBPACK_OUTPUT_PATH,
},
]),
2018-05-18 19:23:28 -04:00
// compression can require a lot of compute time and is disabled in CI
IS_PRODUCTION && !NO_COMPRESSION && new CompressionPlugin(),
2018-05-18 19:23:28 -04:00
// WatchForChangesPlugin
// TODO: publish this as a separate plugin
IS_DEV_SERVER && {
apply(compiler) {
compiler.hooks.emit.tapAsync('WatchForChangesPlugin', (compilation, callback) => {
const missingDeps = Array.from(compilation.missingDependencies);
const nodeModulesPath = path.join(ROOT_PATH, 'node_modules');
const hasMissingNodeModules = missingDeps.some(
(file) => file.indexOf(nodeModulesPath) !== -1,
2018-05-18 19:23:28 -04:00
);
2018-05-18 19:23:28 -04:00
// watch for changes to missing node_modules
if (hasMissingNodeModules) compilation.contextDependencies.add(nodeModulesPath);
// watch for changes to automatic entrypoints
watchAutoEntries.forEach((watchPath) => compilation.contextDependencies.add(watchPath));
2018-05-18 19:23:28 -04:00
// report our auto-generated bundle count
if (incrementalCompiler.enabled) {
incrementalCompiler.logStatus(autoEntriesCount);
} else {
console.log(
`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`,
);
}
2018-05-18 19:23:28 -04:00
callback();
});
},
},
2019-09-11 12:36:18 -04:00
// output the in-memory heap size upon compilation and exit
WEBPACK_MEMORY_TEST && {
apply(compiler) {
compiler.hooks.emit.tapAsync('ReportMemoryConsumptionPlugin', (compilation, callback) => {
console.log('Assets compiled...');
if (global.gc) {
console.log('Running garbage collection...');
global.gc();
} else {
console.error(
"WARNING: you must use the --expose-gc node option to accurately measure webpack's heap size",
);
}
const memoryUsage = process.memoryUsage().heapUsed;
const toMB = (bytes) => Math.floor(bytes / 1024 / 1024);
2019-09-11 12:36:18 -04:00
console.log(`Webpack heap size: ${toMB(memoryUsage)} MB`);
const webpackStatistics = {
memoryUsage,
date: Date.now(), // milliseconds
commitSHA: process.env.CI_COMMIT_SHA,
nodeVersion: process.versions.node,
webpackVersion: WEBPACK_VERSION,
};
console.log(webpackStatistics);
fs.writeFileSync(
path.join(ROOT_PATH, 'webpack-dev-server.json'),
JSON.stringify(webpackStatistics),
);
2019-09-11 12:36:18 -04:00
// exit in case we're running webpack-dev-server
IS_DEV_SERVER && process.exit();
});
},
},
2018-05-18 19:23:28 -04:00
// enable HMR only in webpack-dev-server
DEV_SERVER_LIVERELOAD && new webpack.HotModuleReplacementPlugin(),
// optionally generate webpack bundle analysis
WEBPACK_REPORT &&
new BundleAnalyzerPlugin({
analyzerMode: 'static',
generateStatsFile: true,
openAnalyzer: false,
reportFilename: path.join(ROOT_PATH, 'webpack-report/index.html'),
statsFilename: path.join(ROOT_PATH, 'webpack-report/stats.json'),
statsOptions: {
source: false,
},
2018-05-18 19:23:28 -04:00
}),
new webpack.DefinePlugin({
// This one is used to define window.gon.ee and other things properly in tests:
'process.env.IS_EE': JSON.stringify(IS_EE),
// This one is used to check against "EE" properly in application code
IS_EE: IS_EE ? 'window.gon && window.gon.ee' : JSON.stringify(false),
// This is used by Sourcegraph because these assets are loaded dnamically
'process.env.SOURCEGRAPH_PUBLIC_PATH': JSON.stringify(SOURCEGRAPH_PUBLIC_PATH),
}),
/* Pikaday has a optional dependency to moment.
We are currently not utilizing moment.
Ignoring this import removes warning from our development build.
Upstream reference:
https://github.com/Pikaday/Pikaday/blob/5c1a7559be/pikaday.js#L14
*/
new webpack.IgnorePlugin(/moment/, /pikaday/),
2018-05-18 19:23:28 -04:00
].filter(Boolean),
devServer: {
before(app, server) {
incrementalCompiler.setupMiddleware(app, server);
},
host: DEV_SERVER_HOST,
port: DEV_SERVER_PORT,
public: DEV_SERVER_PUBLIC_ADDR,
allowedHosts: DEV_SERVER_ALLOWED_HOSTS,
https: DEV_SERVER_HTTPS,
contentBase: false,
stats: 'errors-only',
2017-06-15 03:46:15 -04:00
hot: DEV_SERVER_LIVERELOAD,
2018-03-23 12:52:54 -04:00
inline: DEV_SERVER_LIVERELOAD,
2018-05-18 19:23:28 -04:00
},
devtool: NO_SOURCEMAPS ? false : devtool,
node: {
fs: 'empty', // sqljs requires fs
setImmediate: false,
},
2018-05-18 19:23:28 -04:00
};