UNPKG

awesome-gadgets

Version:

Storage, management, compilation, and automatic deployment of MediaWiki gadgets.

323 lines (288 loc) 8.19 kB
import {DEFAULT_DEFINITION, GLOBAL_REQUIRES_ES6, HEADER} from 'scripts/constant'; import type {DefaultDefinition, GlobalSourceFiles, SourceFiles} from '../types'; import {closeSync, existsSync, fdatasyncSync, openSync, readFileSync, writeFileSync} from 'node:fs'; import prompts, {type PromptObject, type PromptType} from 'prompts'; import {exec as _exec} from 'node:child_process'; import alphaSort from 'alpha-sort'; import chalk from 'chalk'; import {exit} from 'node:process'; import path from 'node:path'; import {promisify} from 'node:util'; /** * @private * @return {string} */ const getRootDir = () => { const rootDir = path.resolve(); return rootDir; }; /** * The root directory of the project */ const __rootDir = getRootDir(); /** * Execute a command * * @see {@link node:child_process.exec} */ const exec = promisify(_exec); /** * Read file content * * @param {string} filePath The target file path * @return {string} The file content * @throws If the file is not found */ const readFileContent = (filePath: string) => { const fileBuffer = readFileSync(filePath); return fileBuffer.toString(); }; /** * Write file content * * @param {number|string} filePath The file descriptor or target file path * @param {string} fileContent The file content * @throws If the file is not found */ const writeFileContent = (filePath: number | string, fileContent: string) => { const fileDescriptor = typeof filePath === 'number' ? filePath : openSync(filePath, 'w'); writeFileSync(fileDescriptor, fileContent); fdatasyncSync(fileDescriptor); closeSync(fileDescriptor); }; /** * Generate an array and filter out null and undefined values * * @param {(T | U)[]} args The given arguments * @return {NonNullable<T>[]} The generated array */ const generateArray = <T, U extends T extends Array<infer S> ? S : T>(...args: (T | U)[]): NonNullable<U>[] => { return args.flatMap((arg) => { if (arg === null || arg === undefined) { return []; } if (Array.isArray(arg)) { return (arg as typeof args).filter((item) => { return item !== null && item !== undefined; }); } return [arg]; }) as NonNullable<U>[]; }; /** * Sort an object * * @param {Object} object The object to sort * @param {boolean} isSortArray Sort the array values of this object or not * @return {Object} The sorted object */ const sortObject = <T extends object>(object: T, isSortArray?: boolean) => { const objectSorted = {} as T; for (const _key of Object.keys(object).sort( alphaSort({ caseInsensitive: true, natural: true, }) )) { type Key = keyof T; type Value = T[Key]; const key = _key as Key; const value = object[key]; objectSorted[key] = isSortArray && Array.isArray(value) ? (value.toSorted( alphaSort({ caseInsensitive: true, natural: true, }) ) as Value) : value; } return objectSorted; }; /** * Trim and generate a string, with the option to keep a line break and keep/strip control characters * * @param {string|undefined} string * @param {{addNewline?:boolean; stripControlCharacters?:boolean}} [object] * @return {string} */ const trim = ( string: string | undefined, { addNewline = true, stripControlCharacters = true, }: { addNewline?: boolean; stripControlCharacters?: boolean; } = {} ) => { if (string === undefined) { return ''; } let stringTrimmed = string.trim(); if (!stringTrimmed) { return addNewline ? '\n' : ''; } if (stripControlCharacters) { // Strip control characters other than HT (\t) and LF (\n) stringTrimmed = stringTrimmed.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, ''); } if (!stringTrimmed) { return addNewline ? '\n' : ''; } if (addNewline) { stringTrimmed += '\n'; } return stringTrimmed; }; /** * Easy to use CLI prompts to enquire users for information * * @param {string|Omit<PromptObject,'name'>} message The message to be displayed to the user * @param {PromptType} [type='text'] Defines the type of prompt to display * @param {boolean|string} [initial=''] Optional default prompt value * @return {Promise<boolean|string>} * @see {@link https://www.npmjs.com/package/prompts} */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function prompt<T = any>(message: Omit<PromptObject, 'name'>): Promise<T>; async function prompt(message: string, type?: Exclude<PromptType, 'confirm'>, initial?: string): Promise<string>; async function prompt(message: string, type: 'confirm', initial?: boolean): Promise<boolean>; // eslint-disable-next-line func-style async function prompt( message: string | Omit<PromptObject, 'name'>, type: PromptType = 'text', initial: boolean | string = '' ): Promise<boolean | string> { const name = Math.random().toString(); const answers = typeof message === 'string' ? await prompts({ initial, message, name, type, }) : await prompts({ ...message, name, }); const answer = answers[name] as boolean | string | undefined; if (type === 'confirm' && !answer) { // Not confirmed console.log(chalk.red('User cancelled process, program terminated.')); exit(0); } if (answer === undefined) { // User pressed [ctrl + C] console.log(chalk.red('Input cancelled, program terminated.')); exit(1); } return answer; } /** * Generate banner and footer * * @param {Object} [object] * @param {string} object.licenseText The license text * @param {boolean} object.isDirectly The source code is from `global.json` or not * @param {boolean} object.isProcessJs Add banner and footer for JavaScript code or not * @return {Object} The banner and footer */ const generateBannerAndFooter = ({ licenseText, isDirectly = false, isProcessJs = true, }: Omit<GlobalSourceFiles[keyof GlobalSourceFiles], 'enable' | 'sourceCode'> & { isDirectly?: boolean; isProcessJs?: boolean; }) => { licenseText = licenseText ? trim(licenseText) : ''; const prefix = isDirectly ? '' : '/**\n *\n */\n'; const code: { banner: { css: string; js: string; }; footer: { css: string; js: string; }; } = { banner: { css: `${prefix}${licenseText}${trim(HEADER)}/* <nowiki> */\n`, js: '', }, footer: { css: '\n/* </nowiki> */\n', js: '', }, }; if (isProcessJs) { // MediaWiki ResourceLoader will add a comment block before the uncompressed source code, consisting of three lines // Therefore, here add three empty lines to ensure that the source map can correctly map to the corresponding lines code.banner.js = `${prefix}${licenseText}${trim(HEADER)}/* <nowiki> */\n\n${ // Always wrap the code in an IIFE to avoid variable and method leakage into the global scope GLOBAL_REQUIRES_ES6 && !isDirectly ? '(() => {' : '(function() {' }\n`; code.footer.js = '\n})();\n\n/* </nowiki> */\n'; } return code; }; /** * Parse `definition.json` of a gadget * * @param {string} gadgetName The gadget name * @param {boolean} [isShowLog=true] Show log or not * @return {Object} The definition object */ const generateDefinition = (gadgetName: string, isShowLog: boolean = true) => { const logError = (reason: string) => { if (isShowLog) { console.log( chalk.yellow( `${chalk.italic('definition.json')} of ${chalk.bold( gadgetName )} is ${reason}, the default definition will be used.` ) ); } }; let isMissing = false; const definitionFilePath = path.join(__rootDir, `src/${gadgetName}/definition.json`); if (!existsSync(definitionFilePath)) { isMissing = true; logError('missing'); } let definitionJsonText = '{}'; if (!isMissing) { definitionJsonText = readFileContent(definitionFilePath); } let definition: SourceFiles[keyof SourceFiles]['definition'] = { ...DEFAULT_DEFINITION, requiresES6: GLOBAL_REQUIRES_ES6, }; try { definition = { ...definition, ...(JSON.parse(definitionJsonText) as Partial<DefaultDefinition>), requiresES6: GLOBAL_REQUIRES_ES6, }; } catch { logError('broken'); } return definition; }; export { __rootDir, exec, readFileContent, writeFileContent, generateArray, sortObject, trim, prompt, generateBannerAndFooter, generateDefinition, };