mirror of
https://github.com/flarum/core.git
synced 2025-08-09 09:57:06 +02:00
feat: export registry (#3842)
* feat: registry first iteration Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * feat: improve webpack auto export loader Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * chore: remove `compat` API Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * chore: cleanup Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> --------- Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
This commit is contained in:
185
js-packages/webpack-config/src/autoExportLoader.cjs
Normal file
185
js-packages/webpack-config/src/autoExportLoader.cjs
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Auto Export Loader
|
||||
*
|
||||
* This loader will automatically pick up all core and extension exports and add them to the registry.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { validate } = require('schema-utils');
|
||||
const { getOptions, interpolateName } = require('loader-utils');
|
||||
|
||||
const optionsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
extension: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let namespace;
|
||||
|
||||
function addAutoExports(source, pathToModule, moduleName) {
|
||||
let addition = '';
|
||||
|
||||
const defaultExportMatches = [...source.matchAll(/export\s+?default\s(?:abstract\s)?(?:(?:function|abstract|class)\s)?([A-Za-z_]*)/gm)];
|
||||
const defaultExport = defaultExportMatches.length ? defaultExportMatches[0][1] : null;
|
||||
|
||||
// In case of an index.js file that exports multiple modules
|
||||
// we need to add the directory as a module.
|
||||
// For an example checkout the `common/extenders/index.js` file.
|
||||
if (moduleName === 'index') {
|
||||
const id = pathToModule.substring(0, pathToModule.length - 1);
|
||||
|
||||
// Add code at the end of the file to add the file to registry
|
||||
addition += `\nflarum.reg.add('${namespace}', '${id}', ${defaultExport})`;
|
||||
}
|
||||
|
||||
// In a normal case, we do one of two things:
|
||||
else {
|
||||
// 1. If there is a default export, we add the module to the registry with the default export.
|
||||
// Example: `export default class Foo {}` will be added to the registry as `Foo`,
|
||||
// and can be imported using `import Foo from 'flarum/../Foo'`.
|
||||
if (defaultExport) {
|
||||
// Add code at the end of the file to add the file to registry
|
||||
addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', ${defaultExport})`;
|
||||
}
|
||||
|
||||
// 2. If there is no default export, then there are named exports,
|
||||
// so we add the module to the registry with the map of named exports.
|
||||
// Example: `export class Foo {}` will be added to the registry as `{ Foo: 'Foo' }`,
|
||||
// and can be imported using `import { Foo } from 'flarum/../Foo'`,
|
||||
// (checkout the `common/utils/string.ts` file for an example).
|
||||
else {
|
||||
// Another two case scenarios is when using `export { A, B } from 'x'`.
|
||||
// 2.1. If there is a default export, we add the module to the registry with the default export and ignore the named exports.
|
||||
// Example: `export { nanoid as default, x } from 'nanoid'` will be added to the registry as `nanoid`,
|
||||
// and can be imported using `import nanoid from 'flarum/../nanoid'`. x will be ignored.
|
||||
const objectExportWithDefaultMatches = [...source.matchAll(/export\s+?{.*as\s+?default.*}\s+?from\s+?['"](.*)['"]/gm)];
|
||||
|
||||
if (objectExportWithDefaultMatches.length) {
|
||||
let objectDefaultExport = null;
|
||||
|
||||
source = source.replace(/export\s+?{\s?([A-z_0-9]*)\s?as\s+?default.*}\s+?from\s+?['"](.*)['"]/gm, (match, defaultExport, path) => {
|
||||
objectDefaultExport = defaultExport;
|
||||
|
||||
return `import { ${defaultExport} } from '${path}';\nexport default ${defaultExport}`;
|
||||
});
|
||||
|
||||
addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', ${objectDefaultExport})`;
|
||||
}
|
||||
// 2.2. If there is no default export, check for direct exports from other modules.
|
||||
// We add the module to the registry with the map of named exports.
|
||||
// Example: `export { A, B } from 'nanoid'` will be added to the registry as `{ A, B }`,
|
||||
// and can be imported using `import { A, B } from 'flarum/../nanoid'`.
|
||||
else {
|
||||
const exportCurlyPattern = /export\s+?{(.*)}\s+?from\s+?['"](.*)['"]/gm;
|
||||
const namedExportMatches = [...source.matchAll(exportCurlyPattern)];
|
||||
|
||||
if (namedExportMatches.length) {
|
||||
source = source.replaceAll(exportCurlyPattern, (match, names, path) => {
|
||||
return names
|
||||
.split(',')
|
||||
.map((name) => `import { ${name} } from '${path}';\nexport { ${name} }`)
|
||||
.join('\n');
|
||||
});
|
||||
|
||||
// Addition to the registry is taken care of in step 2.3
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3. Finally, we check for all named exports
|
||||
// these can be `export function|class|.. Name ..`
|
||||
// or `export { ... };
|
||||
{
|
||||
const matches = [...source.matchAll(/export\s+?(?:\* as|function|{\s*([A-z0-9, ]+)+\s?}|const|abstract\s?|class)+?\s?([A-Za-z_]*)?/gm)];
|
||||
|
||||
if (matches.length) {
|
||||
const map = matches.reduce((map, match) => {
|
||||
const names = match[1] ? match[1].split(',') : (match[2] ? [match[2]] : null);
|
||||
|
||||
if (!names) {
|
||||
return map;
|
||||
}
|
||||
|
||||
for (let name of names) {
|
||||
name = name.trim();
|
||||
|
||||
if (name === 'interface' || name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
map += `${name}: ${name},`;
|
||||
}
|
||||
|
||||
return map;
|
||||
}, '');
|
||||
|
||||
// Add code at the end of the file to add the file to registry
|
||||
if (map) addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', { ${map} })`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source + addition;
|
||||
}
|
||||
|
||||
// Custom loader logic
|
||||
module.exports = function autoExportLoader(source) {
|
||||
const options = getOptions(this) || {};
|
||||
|
||||
validate(optionsSchema, options, {
|
||||
name: 'Flarum Webpack Loader',
|
||||
composerPath: 'Path to the extension composer.json file',
|
||||
});
|
||||
|
||||
// Ensure that composer.json is watched for changes
|
||||
// so that the loader is run again when composer.json
|
||||
// is updated.
|
||||
const composerJsonPath = path.resolve(options.composerPath || '../composer.json');
|
||||
this.addDependency(composerJsonPath);
|
||||
|
||||
// Get the namespace of the module to be exported
|
||||
// the namespace is essentially just the usual extension ID.
|
||||
if (!namespace) {
|
||||
const composerJson = JSON.parse(fs.readFileSync(composerJsonPath, 'utf8'));
|
||||
|
||||
// Get the value of the 'name' property
|
||||
namespace =
|
||||
composerJson.name === 'flarum/core' ? 'core' : composerJson.name.replace('/flarum-ext-', '-').replace('/flarum-', '').replace('/', '-');
|
||||
}
|
||||
|
||||
// Get the type of the module to be exported
|
||||
const location = interpolateName(this, '[folder]/', {
|
||||
context: this.rootContext || this.context,
|
||||
});
|
||||
|
||||
// Get the name of module to be exported
|
||||
const moduleName = interpolateName(this, '[name]', {
|
||||
context: this.rootContext || this.context,
|
||||
});
|
||||
|
||||
// Don't export low level files
|
||||
if ((/(admin|forum)\/$/.test(location) && moduleName !== 'app') || /(compat|ExportRegistry|registry)$/.test(moduleName)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
// Don't export index.js of common
|
||||
if (moduleName === 'index' && location === 'common/') {
|
||||
return source;
|
||||
}
|
||||
|
||||
// Don't export extend.js of extensions
|
||||
if (namespace !== 'core' && /extend$/.test(moduleName)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
// Get the path of the module to be exported
|
||||
// relative to the src directory.
|
||||
// Example: src/forum/components/UserCard.js => forum/components
|
||||
const pathToModule = this.resourcePath.replace(path.resolve(this.rootContext, 'src') + '/', '').replace(/[A-Za-z_]+\.[A-Za-z_]+$/, '');
|
||||
|
||||
return addAutoExports(source, pathToModule, moduleName);
|
||||
};
|
120
js-packages/webpack-config/src/index.cjs
Normal file
120
js-packages/webpack-config/src/index.cjs
Normal file
@@ -0,0 +1,120 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { NormalModuleReplacementPlugin } = require('webpack');
|
||||
|
||||
const entryPointNames = ['forum', 'admin'];
|
||||
const entryPointExts = ['js', 'ts'];
|
||||
|
||||
function getEntryPoints() {
|
||||
const entries = {};
|
||||
|
||||
appLoop: for (const app of entryPointNames) {
|
||||
for (const ext of entryPointExts) {
|
||||
const file = path.resolve(process.cwd(), `${app}.${ext}`);
|
||||
|
||||
if (fs.existsSync(file)) {
|
||||
entries[app] = file;
|
||||
continue appLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
console.error('ERROR: No JS entrypoints could be found.');
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
const useBundleAnalyzer = process.env.ANALYZER === 'true';
|
||||
const plugins = [];
|
||||
|
||||
/**
|
||||
* Yarn Plug'n'Play means that dependency hoisting doesn't work like it normally
|
||||
* would with the standard `node_modules` configuration. This is by design, as
|
||||
* hoisting is unpredictable.
|
||||
*
|
||||
* This plugin works around this by ensuring references to `@babel/runtime` (which
|
||||
* is required at build-time from an extension/core's scope) are redirected to the
|
||||
* copy of `@babel/runtime` which is a dependency of this package.
|
||||
*
|
||||
* This removes the need for hoisting, and allows for Plug'n'Play compatibility.
|
||||
*
|
||||
* Thanks goes to Yarn's lead maintainer @arcanis for helping me get to this
|
||||
* solution.
|
||||
*/
|
||||
plugins.push(
|
||||
new NormalModuleReplacementPlugin(/^@babel\/runtime(.*)/, (resource) => {
|
||||
const path = resource.request.split('@babel/runtime')[1];
|
||||
|
||||
resource.request = require.resolve(`@babel/runtime${path}`);
|
||||
})
|
||||
);
|
||||
|
||||
if (useBundleAnalyzer) {
|
||||
plugins.push(new (require('webpack-bundle-analyzer').BundleAnalyzerPlugin)());
|
||||
}
|
||||
|
||||
module.exports = function () {
|
||||
return {
|
||||
// Set up entry points for each of the forum + admin apps, but only
|
||||
// if they exist.
|
||||
entry: getEntryPoints(),
|
||||
|
||||
plugins,
|
||||
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
include: /src/, // Only apply this loader to files in the src directory
|
||||
loader: path.resolve(__dirname, './autoExportLoader.cjs'),
|
||||
},
|
||||
{
|
||||
// Matches .js, .jsx, .ts, .tsx
|
||||
test: /\.[jt]sx?$/,
|
||||
loader: require.resolve('babel-loader'),
|
||||
options: require('../babel.config.cjs'),
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
output: {
|
||||
path: path.resolve(process.cwd(), 'dist'),
|
||||
library: 'module.exports',
|
||||
libraryTarget: 'assign',
|
||||
devtoolNamespace: require(path.resolve(process.cwd(), 'package.json')).name,
|
||||
},
|
||||
|
||||
externals: [
|
||||
{
|
||||
jquery: 'jQuery',
|
||||
},
|
||||
|
||||
function ({ request }, callback) {
|
||||
let namespace;
|
||||
let id;
|
||||
let matches;
|
||||
if ((matches = /^flarum\/(.+)$/.exec(request))) {
|
||||
namespace = 'core';
|
||||
id = matches[1];
|
||||
} else if ((matches = /^ext:([^\/]+)\/(?:flarum-(?:ext-)?)?([^\/]+)(?:\/(.+))?$/.exec(request))) {
|
||||
namespace = `${matches[1]}-${matches[2]}`;
|
||||
id = matches[3];
|
||||
} else {
|
||||
return callback();
|
||||
}
|
||||
|
||||
return callback(null, `root flarum.reg.get('${namespace}', '${id}')`);
|
||||
},
|
||||
],
|
||||
|
||||
devtool: 'source-map',
|
||||
};
|
||||
};
|
Reference in New Issue
Block a user