1
0
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:
Sami Mazouz
2023-06-29 18:57:53 +01:00
committed by GitHub
parent cf70865aa6
commit 016503d8c3
74 changed files with 2206 additions and 1076 deletions

View 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);
};

View 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',
};
};