awesome-gadgets
Version:
Storage, management, compilation, and automatic deployment of MediaWiki gadgets.
787 lines (714 loc) • 21.5 kB
text/typescript
import * as PACKAGE from '../../../package.json';
import {BANNER, GLOBAL_REQUIRES_ES6, SOURCE_MAP} from '../../constant';
import {type BabelFileResult, type TransformOptions, transformAsync} from '@babel/core';
import type {BuiltFile, DefaultDefinition, SourceFiles} from '../types';
import {type OutputFile, build as esbuild} from 'esbuild';
import {
__rootDir,
generateArray,
generateBannerAndFooter,
generateDefinition,
readFileContent,
sortObject,
trim,
writeFileContent,
} from './general-util';
import {existsSync, mkdirSync} from 'node:fs';
import chalk from 'chalk';
import {esbuildOptions} from '../build-esbuild_options';
import {exit} from 'node:process';
import {globSync} from 'glob';
import path from 'node:path';
import {rimraf} from 'rimraf';
/**
* @private
* @param {string} outputFilePath
* @param {string} sourceCode
*/
const writeFile = (outputFilePath: string, sourceCode: string) => {
const outputDirectoryPath: string = path.dirname(outputFilePath);
mkdirSync(outputDirectoryPath, {
recursive: true,
});
writeFileContent(outputFilePath, sourceCode);
};
/**
* @private
* @param {string} inputFilePath
* @param {string} outputFilePath
* @param {Object} object
* @param {DefaultDefinition['dependencies']|undefined} [object.dependencies]
* @param {DefaultDefinition['externalPackages']|undefined} [object.externalPackages]
* @param {string} object.licenseText
* @return {Promise<BuiltFiles>}
*/
const build = async (
inputFilePath: string,
outputFilePath: string,
{
dependencies,
externalPackages,
licenseText,
}: {
dependencies?: DefaultDefinition['dependencies'] | undefined;
externalPackages?: DefaultDefinition['externalPackages'] | undefined;
licenseText: string | undefined;
}
) => {
const buildResult = await esbuild({
...esbuildOptions,
...generateBannerAndFooter({
licenseText,
isProcessJs: false,
}),
external: generateArray(dependencies, externalPackages),
entryPoints: [inputFilePath],
outfile: outputFilePath,
});
const {outputFiles} = buildResult;
if (!outputFiles) {
return [];
}
return outputFiles.map<BuiltFile>(({path: outputPath, text}) => {
return {
path: outputPath,
// See `generateBannerAndFooter()` comment for more details
text: text.replace(/^\/\*\*\n\s\*\n\s\*\/\n/, ''),
};
});
};
/**
* @private
* @param {string} outputFilePath
* @param {string} sourceCode
* @param {Object} object
* @param {DefaultDefinition['dependencies']|undefined} object.dependencies
* @param {DefaultDefinition['externalPackages']|undefined} object.externalPackages
* @param {string} object.licenseText
* @return {Promise<string>}
*/
const bundle = async (
outputFilePath: string,
sourceCode: string,
{
dependencies,
externalPackages,
licenseText,
}: {
dependencies: DefaultDefinition['dependencies'] | undefined;
externalPackages: DefaultDefinition['externalPackages'] | undefined;
licenseText: string | undefined;
}
) => {
const buildResult = await esbuild({
...esbuildOptions,
...generateBannerAndFooter({
licenseText,
}),
external: generateArray(dependencies, externalPackages),
stdin: {
contents: sourceCode,
resolveDir: __rootDir,
sourcefile: outputFilePath,
},
target: GLOBAL_REQUIRES_ES6 ? 'esnext' : 'es5',
});
const {outputFiles} = buildResult;
if (!outputFiles) {
return '';
}
const {text} = outputFiles[0] as OutputFile;
// See `generateBannerAndFooter()` comment for more details
return text.replace(/^\/\*\*\n\s\*\n\s\*\/\n/, '');
};
/**
* @private
* @return {TransformOptions}
*/
const generateTransformOptions = () => {
const options = {
presets: [
[
'@babel/preset-env',
{
bugfixes: true, // FIXME: Remove when updating to Babel 8
corejs: {
version: PACKAGE.devDependencies['core-js'].match(/\d+(?:.\d+){0,2}/)?.[0] ?? '3.41',
},
exclude: ['web.dom-collections.for-each', 'web.dom-collections.iterator'],
include: [] as string[],
modules: 'commonjs',
useBuiltIns: 'usage',
},
],
],
compact: false,
plugins: [
'@mrhenry/core-web',
path.join(__rootDir, 'scripts/modules/plugins/babel-plugin-convert-comments.ts'),
path.join(__rootDir, 'scripts/modules/plugins/babel-plugin-import-polyfills.ts'),
],
sourceMaps: SOURCE_MAP ? 'inline' : false,
} as const satisfies TransformOptions;
if (GLOBAL_REQUIRES_ES6) {
// 以下关键字和运算符无法被 MediaWiki(>= 1.39)的 JavaScript 压缩器良好支持,即使设置了 requiresES6 标识
// The following keywords and operators are not well supported by MediaWiki's (>= 1.39) JavaScript minifier, even if the `requiresES6` flag is true
options.presets[0][1].include.push(
// keywords
// ES2015
'transform-for-of', // transform for-of loops
'transform-template-literals', // `foo${bar}` -> 'foo'.concat(bar)
// ES2017
'transform-async-to-generator', // transform async/await to generator
// ES2018
'transform-async-generator-functions', // transform async generator to normal generator
// ES2020
'transform-optional-chaining', // foo?.bar
// operators
// ES2020
'transform-nullish-coalescing-operator', // foo ?? bar
// ES2021
'transform-logical-assignment-operators', // foo ??= bar
'transform-numeric-separator' // 1_000 -> 1000
);
} else {
// 以下关键字无法被旧版本的 MediaWiki(< 1.39)的 JavaScript 压缩器良好支持
// The following keywords are not well supported by the JavaScript minifier in older versions of MediaWiki (< 1.39)
options.presets[0][1].include.push(
// keywords
// ES3
'transform-member-expression-literals', // obj.const -> obj['const']
'transform-property-literals', // {const: 1} -> {'const': 1}
'transform-reserved-words' // const abstract = 1 -> const _abstract = 1
);
}
return options;
};
/**
* @private
*/
const transformOptions = generateTransformOptions();
/**
* @private
* @param {string} inputFilePath
* @param {string} sourceCode
* @return {Promise<string>}
*/
const transform = async (inputFilePath: string, sourceCode: string) => {
const babelFileResult = (await transformAsync(sourceCode, {
...transformOptions,
cwd: __rootDir,
filename: inputFilePath,
})) as BabelFileResult;
const {code: transformOutput} = babelFileResult;
return transformOutput as string;
};
/**
* @private
* @param {string} gadgetName
* @param {string} scriptFileName
* @param {Object} object
* @param {DefaultDefinition['dependencies']|undefined} object.dependencies
* @param {DefaultDefinition['externalPackages']|undefined} object.externalPackages
* @param {string} object.licenseText
* @return {Promise<string[]>}
*/
const buildScript = async (
gadgetName: string,
scriptFileName: string,
{
dependencies,
externalPackages,
licenseText,
}: {
dependencies: DefaultDefinition['dependencies'] | undefined;
externalPackages: DefaultDefinition['externalPackages'] | undefined;
licenseText: string | undefined;
}
) => {
const outputFileNames: string[] = [];
// The TypeScript file is always compiled into a JavaScript file, so replace the extension directly
const outputFileName = scriptFileName.replace(/\.[jt]sx?$/, '.js');
const inputFilePath = path.join(__rootDir, `src/${gadgetName}/${scriptFileName}`);
const outputFilePath = path.join(__rootDir, `dist/${gadgetName}/${outputFileName}`);
const builtFiles = await build(inputFilePath, outputFilePath, {
dependencies,
externalPackages,
licenseText,
});
for (const {path: outputPath, text} of builtFiles) {
const fileName = path.basename(outputPath);
outputFileNames.push(fileName);
const fileExt = path.extname(outputPath);
switch (fileExt) {
case '.css':
writeFile(outputPath, text);
break;
case '.js': {
const transformOutput = await transform(inputFilePath, text);
const bundleOutput = await bundle(outputFilePath, transformOutput, {
dependencies,
externalPackages,
licenseText,
});
if (!bundleOutput) {
continue;
}
writeFile(outputPath, bundleOutput);
break;
}
}
}
return outputFileNames;
};
/**
* @private
* @param {string} gadgetName
* @param {string} styleFileName
* @param {string|undefined} licenseText
* @return {Promise<string>}
*/
const buildStyle = async (gadgetName: string, styleFileName: string, licenseText: string | undefined) => {
// The Less file is always compiled into a CSS file, so replace the extension directly
const outputFileName = styleFileName.replace(/\.less$/, '.css');
const inputFilePath = path.join(__rootDir, `src/${gadgetName}/${styleFileName}`);
const outputFilePath = path.join(__rootDir, `dist/${gadgetName}/${outputFileName}`);
const builtFiles = await build(inputFilePath, outputFilePath, {
licenseText,
});
const buildOutput = builtFiles[0]!.text;
writeFile(outputFilePath, buildOutput);
return outputFileName;
};
/**
* @param {string} gadgetName The gadget name
* @param {'script'|'style'} type The type of target files
* @param {Object} object
* @param {DefaultDefinition['dependencies']} [object.dependencies]
* @param {DefaultDefinition['externalPackages']} [object.externalPackages]
* @param {string[]} object.files
* @param {string} object.licenseText
* @return {Promise<string[]>} The array of built file names
*/
async function buildFiles(
gadgetName: string,
type: 'script',
{
dependencies,
externalPackages,
files,
licenseText,
}: {
dependencies: DefaultDefinition['dependencies'];
externalPackages: DefaultDefinition['externalPackages'];
files: string[];
licenseText: string | undefined;
}
): Promise<string[]>;
async function buildFiles(
gadgetName: string,
type: 'style',
{
files,
licenseText,
}: {
files: string[];
licenseText: string | undefined;
}
): Promise<string[]>;
// eslint-disable-next-line func-style
async function buildFiles(
gadgetName: string,
type: 'script' | 'style',
{
dependencies,
externalPackages,
files,
licenseText,
}: {
dependencies?: DefaultDefinition['dependencies'];
externalPackages?: DefaultDefinition['externalPackages'];
files: string[];
licenseText: string | undefined;
}
): Promise<string[]> {
const outputFileNames: string[] = [];
for (const fileName of files) {
switch (type) {
case 'script':
outputFileNames.push(
...(await buildScript(gadgetName, fileName, {
dependencies,
externalPackages,
licenseText,
}))
);
break;
case 'style':
outputFileNames.push(await buildStyle(gadgetName, fileName, licenseText));
}
}
return outputFileNames;
}
/**
* Clean up the build output directory
*/
const cleanUpDist = async () => {
const paths = globSync('!(*.txt)', {
cwd: path.join(__rootDir, 'dist'),
withFileTypes: true,
}).map<string>((currentPath) => {
return currentPath.fullpath();
});
await rimraf(paths);
};
/**
* @private
* @param {SourceFiles} sourceFiles
*/
const fallbackDefinition = (sourceFiles: SourceFiles) => {
for (const [gadgetName, gadgetFiles] of Object.entries(sourceFiles)) {
if (gadgetFiles.definition) {
continue;
}
gadgetFiles.definition = generateDefinition(gadgetName);
}
};
/**
* @private
* @param {SourceFiles} sourceFiles
*/
const filterOutInvalidDependencies = (sourceFiles: SourceFiles) => {
const filter = (array: string[]) => {
return array
.filter((item) => {
return typeof item === 'string' && Boolean(item.trim());
})
.map<string>((item) => {
return trim(item, {
addNewline: false,
});
});
};
for (const gadgetFiles of Object.values(sourceFiles)) {
const {
definition: {dependencies, externalPackages},
} = gadgetFiles;
gadgetFiles.definition.dependencies = filter(dependencies);
gadgetFiles.definition.externalPackages = filter(externalPackages);
}
};
/**
* @return {SourceFiles} An object used to describe source files
*/
const findSourceFile = () => {
const sourceFiles: SourceFiles = {};
type Gadget = SourceFiles[keyof SourceFiles];
const files = globSync(['*/*.{js,jsx,ts,tsx,vue,css,less}', '*/definition.json', '*/LICENSE'], {
cwd: path.join(__rootDir, 'src'),
withFileTypes: true,
});
for (const file of files) {
const {name: fileName} = file;
if (fileName.endsWith('.d.ts')) {
// Skip typescript declaration files, no need when compiling
continue;
}
const {name: gadgetName} = file.parent as NonNullable<typeof file.parent>; // The parent folder name of the file
if (!/^[A-Za-z][A-Za-z0-9\-_.]*$/.test(gadgetName)) {
/**
* @summary Skip folder names that contain illegal characters not supported by the Gadget extension
* @see {@link https://www.mediawiki.org/wiki/Extension:Gadgets#Definition_format}
* @see {@link https://www.w3.org/TR/html4/types.html#type-id}
*/
console.log(
chalk.yellow(`The folder name ${chalk.bold(gadgetName)} contains illegal characters, skip it.`)
);
continue;
}
sourceFiles[gadgetName] ??= {} as Gadget;
const targetGadget = sourceFiles[gadgetName];
const fileExt = path.extname(fileName);
const isScriptFile = ['.js', '.jsx', '.ts', '.tsx', '.vue'].includes(fileExt);
const isStyleFile = ['.css', '.less'].includes(fileExt);
const {script, style} = targetGadget;
if (isScriptFile && style) {
delete targetGadget.style;
}
if (isStyleFile && script) {
continue;
}
switch (fileName) {
case 'definition.json':
targetGadget.definition = generateDefinition(gadgetName);
continue;
case 'index.js':
if (!script || !/^index\.[jt]sx?$/.test(script)) {
targetGadget.script = fileName;
}
continue;
case 'index.jsx':
if (!script || !/^index\.tsx?$/.test(script)) {
targetGadget.script = fileName;
}
continue;
case 'index.ts':
if (!script || script !== 'index.tsx') {
targetGadget.script = fileName;
}
continue;
case 'index.tsx':
targetGadget.script = fileName;
continue;
case 'index.vue':
break;
case `${gadgetName}.js`:
if (!script) {
targetGadget.script = fileName;
}
continue;
case `${gadgetName}.jsx`:
if (!script || (!/\.tsx?$/.test(script) && script !== 'index.js')) {
targetGadget.script = fileName;
}
continue;
case `${gadgetName}.ts`:
if (!script || (!/^index\.[jt]sx?$/.test(script) && script !== `${gadgetName}.tsx`)) {
targetGadget.script = fileName;
}
continue;
case `${gadgetName}.tsx`:
if (!script || !/^index\.[jt]sx?$/.test(script)) {
targetGadget.script = fileName;
}
continue;
case `${gadgetName}.vue`:
break;
case 'index.css':
if (!style || style !== 'index.less') {
targetGadget.style = fileName;
}
continue;
case 'index.less':
targetGadget.style = fileName;
continue;
case `${gadgetName}.css`:
if (!style) {
targetGadget.style = fileName;
}
continue;
case `${gadgetName}.less`:
if (!style || !/^index\.(?:css|less)/.test(style)) {
targetGadget.style = fileName;
}
continue;
case 'LICENSE':
targetGadget.license = fileName;
continue;
}
const removeFiles = (currentFiles: string[], ext: string) => {
return [
...new Set(
currentFiles.filter((currentFile) => {
return currentFile !== fileName.replace(new RegExp(`\\${fileExt}$`), ext);
})
),
];
};
if (isScriptFile) {
targetGadget.scripts ??= [];
const {scripts} = targetGadget;
scripts.push(fileName);
// If there are files with the same name in both JavaScript and TypeScript, only retain the TypeScript file
switch (fileExt) {
case '.ts':
targetGadget.scripts = removeFiles(scripts, '.js');
break;
case '.tsx':
targetGadget.scripts = removeFiles(scripts, '.jsx');
break;
}
}
if (isStyleFile) {
targetGadget.styles ??= [];
const {styles} = targetGadget;
styles.push(fileName);
// If there are files with the same name in both CSS and Less, only retain the Less file
if (fileExt === '.less') {
targetGadget.styles = removeFiles(styles, '.css');
}
}
}
// After completing the loop, if `targetGadget.definition` is undefined, utilize the default definition
fallbackDefinition(sourceFiles);
// Filter out invalid dependencies, only allow non-empty string
filterOutInvalidDependencies(sourceFiles);
const sourceFilesSorted: SourceFiles = sortObject(sourceFiles);
return sourceFilesSorted;
};
/**
* @param {string} gadgetName The gadget name
* @param {Object} definition The parsed `definition.json`
* @param {string} gadgetFiles All files used by this gadget
* @return {string} The Gadget definition (in the format of MediaWiki:Gadgets-definition item)
*/
const generateDefinitionItem = (
gadgetName: string,
definition: SourceFiles[keyof SourceFiles]['definition'],
gadgetFiles: string
) => {
let definitionText = '|';
for (const [key, value] of Object.entries(definition)) {
const isArray = Array.isArray(value);
if (
[
// Keys for internal use
'enable',
'description',
'section',
'excludeSites',
'externalPackages',
// Keys that no need to be specified
'package',
'targets',
'top',
'type',
].includes(key) ||
[false, undefined].includes(value as boolean | undefined) ||
(isArray && !(value as []).length)
) {
continue;
}
switch (typeof value) {
case 'boolean':
definitionText += `${key}|`;
break;
case 'object':
if (isArray) {
const valueFiltered = (value as [])
.filter((item: keyof []) => {
return ['number', 'string'].includes(typeof item) && Boolean(item.toString().trim());
})
.map<string>((item: number | string) => {
return trim(item.toString(), {
addNewline: false,
});
})
.join(',');
if (valueFiltered) {
definitionText += `${key}=${valueFiltered}|`;
}
}
break;
case 'string': {
const valueTrimmed = trim(value, {
addNewline: false,
});
if (valueTrimmed) {
definitionText += `${key}=${valueTrimmed}|`;
}
break;
}
}
}
definitionText = definitionText.replace(/\|$/, '');
const {section} = definition;
const sectionText = section
? `☀${trim(section.replace(/☀/g, ''), {
addNewline: false,
})}`
: '☀appear';
return `* ${gadgetName}[ResourceLoader${definitionText}]${gadgetFiles}${sectionText}`;
};
/**
* @param {string|undefined} file The index file name
* @param {string[]|undefined} files The other file name array
* @return {string[]} The generated file name array
*/
const generateFileArray = (file: string | undefined, files: string[] | undefined) => {
return generateArray(file ?? files);
};
/**
* @private
* @param {string} gadgetName The gadget name
* @param {string} fileName The file name
* @return {string} The processed file name
*/
const removeDuplicateFileName = (gadgetName: string, fileName: string) => {
const fileNameSplit: string[] = fileName.split('.');
return `${gadgetName}${fileNameSplit.shift() === gadgetName ? `.${fileNameSplit.join('.')}` : `-${fileName}`}`;
};
/**
* @param {string} gadgetName The gadget name
* @param {string[]} fileNames The file name array
* @return {string} The generated file name string
*/
const generateFileNames = (gadgetName: string, fileNames: string[]) => {
return fileNames
.map<string>((fileName) => {
return removeDuplicateFileName(gadgetName, fileName);
})
.join('|');
};
/**
* @param {string} gadgetName The gadget name
* @param {string|undefined} licenseFileName The license file name of this gadget
* @return {string|undefined} The gadget license file content
*/
const getLicense = (gadgetName: string, licenseFileName: string | undefined) => {
if (!licenseFileName) {
return;
}
const licenseFilePath = path.join(__rootDir, `src/${gadgetName}/${licenseFileName}`);
const fileContent = readFileContent(licenseFilePath);
return fileContent.trim() ? fileContent : undefined;
};
/**
* Save `dist/definition.txt`
*
* @param {string[]} definitions The gadget definitions array (in the format of MediaWiki:Gadgets-definition item)
*/
const saveDefinition = (definitions: string[]) => {
type Gadgets = typeof definitions;
const definitionObject: Record<string, Gadgets> = {};
for (const definition of definitions) {
const [, section] = definition.match(/.*?☀(\S+)$/) as [string, string];
definitionObject[section] ??= [];
definitionObject[section].push(definition.replace(/☀.*/, ''));
}
const definitionObjectSorted = sortObject(definitionObject);
let definitionText = '';
for (const [section, definitionItems] of Object.entries(definitionObjectSorted)) {
const sectionHeader = `== ${section} ==`;
for (const definition of definitionItems) {
if (definitionText.includes(sectionHeader)) {
definitionText += `${definition}\n`;
} else {
definitionText += `\n${sectionHeader}\n${definition}\n`;
}
}
}
definitionText = trim(BANNER) + definitionText;
const definitionPath = path.join(__rootDir, 'dist/definition.txt');
if (!existsSync(definitionPath)) {
console.log(
chalk.red(
`Failed to save ${chalk.italic(
'definition.txt'
)}, please confirm if any files that need to be compiled exist.`
)
);
exit(1);
}
writeFileContent(definitionPath, definitionText);
};
export {
buildFiles,
cleanUpDist,
findSourceFile,
generateDefinitionItem,
generateFileArray,
generateFileNames,
getLicense,
saveDefinition,
};