awesome-gadgets
Version:
Storage, management, compilation, and automatic deployment of MediaWiki gadgets.
213 lines (194 loc) • 4.87 kB
text/typescript
/* eslint-disable camelcase, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
/**
* @file Automatically import any missing polyfills
* @see {@link https://github.com/zloirock/core-js#missing-polyfills}
* @see {@link https://github.com/mrhenry/core-web/tree/main/packages/core-web/modules}
* @see ../../patches/@mrhenry__babel-plugin-core-web.patch
*/
import {type BabelAPI, declare} from '@babel/helper-plugin-utils';
import type {CallExpression, NewExpression, Program} from '@babel/types';
import type {NodePath} from 'babel__traverse';
import {__rootDir} from '../utils/general-util';
/**
* @see {@link https://babeljs.io/docs/babel-helper-compilation-targets#filteritems}
*/
// @ts-expect-error TS7016
import {filterItems} from '@babel/helper-compilation-targets';
import {getSupport} from 'caniuse-api';
import path from 'node:path';
/**
* @private
*/
type Features =
| 'AudioContext'
| 'BroadcastChannel'
| 'Proxy'
// String.prototype.normalize
| 'normalize';
/**
* @private
* @param {Exclude<Features, 'normalize'>} feature
* @return {Record<string, string>}
*/
const getTargets = (feature: Exclude<Features, 'normalize'>) => {
const browserSupport = getSupport(feature.toLowerCase());
return Object.entries(browserSupport).reduce<Record<string, string>>((accumulator, [browser, versions]) => {
const target = versions.y?.toString();
if (target) {
accumulator[browser] = target;
}
return accumulator;
}, {});
};
/**
* @private
*/
const compatData = {
AudioContext: {
and_chr: '119',
and_ff: '119',
and_uc: '15.5',
android: '119',
chrome: '42',
edge: '14',
firefox: '40',
ios_saf: '9',
op_mob: '73',
opera: '29',
safari: '9',
samsung: '4',
},
BroadcastChannel: getTargets('BroadcastChannel'),
Proxy: getTargets('Proxy'),
// String.prototype.normalize
normalize: {
and_chr: '119',
and_ff: '119',
android: '119',
chrome: '34',
edge: '12',
firefox: '31',
ios_saf: '10',
op_mob: '73',
opera: '21',
safari: '10',
samsung: '10',
},
} as const satisfies Record<Features, ReturnType<typeof getTargets>>;
/**
* @private
*/
const polyfills = {
AudioContext: {
package: path.join(__rootDir, 'scripts/modules/polyfills/AudioContext'),
type: 'NewExpression',
},
BroadcastChannel: {
package: 'broadcastchannel-polyfill',
type: 'NewExpression',
},
Proxy: {
package: 'proxy-polyfill/proxy.min',
type: 'NewExpression',
},
// String.prototype.normalize
normalize: {
package: 'unorm',
type: 'CallExpression',
},
} as const satisfies Record<
Features,
{
package: string;
type: 'CallExpression' | 'NewExpression';
}
>;
/**
* @private
* @param {Object} nodePath
* @param {Object} types
* @param {string} packageName
*/
const addImport = (
nodePath: NodePath<CallExpression | NewExpression>,
types: BabelAPI['types'],
packageName: (typeof polyfills)[Features]['package']
) => {
const stringLiteral = types.stringLiteral(packageName);
const importDeclaration = types.importDeclaration([], stringLiteral);
(
nodePath.findParent((parent) => {
return parent.isProgram();
}) as NodePath<Program>
)?.unshiftContainer('body', importDeclaration);
};
const plugin = declare((api) => {
const {types} = api;
const {isIdentifier, isMemberExpression, isStringLiteral} = types;
const needPolyfills: Set<string> = filterItems(
compatData,
new Set(),
new Set(),
(
api as unknown as {
targets: () => Record<string, string>;
}
).targets()
);
return {
visitor: {
CallExpression(nodePath) {
const {
node: {callee, arguments: args},
} = nodePath;
for (const [name, {package: packageName, type}] of Object.entries(polyfills)) {
if (type !== 'CallExpression' || !needPolyfills.has(name)) {
continue;
}
switch (name) {
// polyfill call expressions in `String.prototype`
case 'normalize':
if (
args.length !== 1 ||
!isStringLiteral(args[0]) || // `''`
!isMemberExpression(callee) || // `''.`
!isIdentifier(callee.property, {
name, // `''.normalize()`
})
) {
continue;
}
break;
// polyfill other call expressions
default:
if (
!args.length ||
!isIdentifier(callee, {
name, // `name()`
})
) {
continue;
}
}
addImport(nodePath, types, packageName);
}
},
NewExpression(nodePath) {
const {callee} = nodePath.node;
for (const [name, {package: packageName, type}] of Object.entries(polyfills)) {
if (
type !== 'NewExpression' ||
!needPolyfills.has(name) ||
!isIdentifier(callee, {
name, // `new name()`
})
) {
continue;
}
addImport(nodePath, types, packageName);
}
},
},
};
});
export default plugin;