@ckeditor/ckeditor5-dev-utils
Version:
Utils for CKEditor 5 development tools packages.
756 lines (724 loc) • 25 kB
JavaScript
import { styleText } from 'node:util';
import path from 'node:path';
import { createRequire } from 'node:module';
import { Features } from 'lightningcss';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { PassThrough } from 'node:stream';
import through from 'through2';
import readline from 'node:readline';
import isInteractive from 'is-interactive';
import cliSpinners from 'cli-spinners';
import cliCursor from 'cli-cursor';
import fs, { readFileSync } from 'node:fs';
import sh from 'shelljs';
import { simpleGit } from 'simple-git';
import upath from 'upath';
import fs$1, { readFile } from 'node:fs/promises';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import pacote from 'pacote';
import { glob } from 'glob';
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
const levels = new Map();
levels.set('silent', new Set([]));
levels.set('info', new Set(['info']));
levels.set('warning', new Set(['info', 'warning']));
levels.set('error', new Set(['info', 'warning', 'error']));
/**
* Logger module which allows configuring the verbosity level.
*
* There are three levels of verbosity:
* 1. `info` - all messages will be logged,
* 2. `warning` - warning and errors will be logged,
* 3. `error` - only errors will be logged.
*
* Usage:
*
* import { logger } from '@ckeditor/ckeditor5-dev-utils';
*
* const infoLog = logger( 'info' );
* infoLog.info( 'Message.' ); // This message will be always displayed.
* infoLog.warning( 'Message.' ); // This message will be always displayed.
* infoLog.error( 'Message.' ); // This message will be always displayed.
*
* const warningLog = logger( 'warning' );
* warningLog.info( 'Message.' ); // This message won't be displayed.
* warningLog.warning( 'Message.' ); // This message will be always displayed.
* warningLog.error( 'Message.' ); // This message will be always displayed.
*
* const errorLog = logger( 'error' );
* errorLog.info( 'Message.' ); // This message won't be displayed.
* errorLog.warning( 'Message.' ); // This message won't be displayed.
* errorLog.error( 'Message.' ); // This message will be always displayed.
*
* Additionally, the `logger#error()` method prints the error instance if provided as the second argument.
*/
function logger(moduleVerbosity = 'info') {
return {
info(message) {
this._log('info', message);
},
warning(message) {
this._log('warning', styleText('yellow', message));
},
error(message, error) {
this._log('error', styleText('red', message), error);
},
_log(messageVerbosity, message, error) {
if (!levels.get(messageVerbosity).has(moduleVerbosity)) {
return;
}
console.log(message);
if (error) {
console.dir(error, { depth: null });
}
}
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
const require$1 = createRequire(import.meta.url);
/**
* This can be replaced with `fileURLToPath( import.meta.resolve( '<NAME>' ) )`
* once Vitest 4 releases and we update to it.
*
* In Vitest 3 and earlier, `import.meta.resolve` results in the following error:
*
* ```
* __vite_ssr_import_meta__.resolve is not a function
* ```
*/
function resolveLoader(loaderName) {
return require$1.resolve(loaderName);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
const escapedPathSep = path.sep == '/' ? '/' : '\\\\';
function getCoverageLoader({ files }) {
return {
test: /\.[jt]s$/,
use: [
{
loader: resolveLoader('babel-loader'),
options: {
plugins: [
'babel-plugin-istanbul'
]
}
}
],
include: getPathsToIncludeForCoverage(files),
exclude: [
new RegExp(`${escapedPathSep}(lib)${escapedPathSep}`)
]
};
}
/**
* Returns an array of `/ckeditor5-name\/src\//` regexps based on passed globs.
* E.g., `ckeditor5-utils/**\/*.js` will be converted to `/ckeditor5-utils\/src/`.
*
* This loose way of matching packages for CC works with packages under various paths.
* E.g., `workspace/ckeditor5-utils` and `ckeditor5/node_modules/ckeditor5-utils` and every other path.
*/
function getPathsToIncludeForCoverage(globs) {
const values = globs
.reduce((returnedPatterns, globPatterns) => {
returnedPatterns.push(...globPatterns);
return returnedPatterns;
}, [])
.map(glob => {
const matchCKEditor5 = glob.match(/\/(ckeditor5-[^/]+)\/(?!.*ckeditor5-)/);
if (matchCKEditor5) {
const packageName = matchCKEditor5[1]
// A special case when --files='!engine' or --files='!engine|ui' was passed.
// Convert it to /ckeditor5-(?!engine)[^/]\/src\//.
.replace(/ckeditor5-!\(([^)]+)\)\*/, 'ckeditor5-(?!$1)[^' + escapedPathSep + ']+')
.replace('ckeditor5-*', 'ckeditor5-[a-z]+');
return new RegExp(packageName + escapedPathSep + 'src' + escapedPathSep);
}
})
// Filter undefined ones.
.filter(path => path);
return [...new Set(values)];
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* @param {Array.<string>} debugFlags
* @returns {object}
*/
function getDebugLoader(debugFlags) {
return {
loader: path.join(import.meta.dirname, 'ck-debug-loader.js'),
options: { debugFlags }
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
function getTypeScriptLoader(options = {}) {
const { configFile = 'tsconfig.json', debugFlags = [], includeDebugLoader = false } = options;
return {
test: /\.ts$/,
use: [
{
loader: resolveLoader('esbuild-loader'),
options: {
target: 'es2022',
tsconfig: configFile
}
},
includeDebugLoader ? getDebugLoader(debugFlags) : null
].filter(Boolean)
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
function getIconsLoader({ matchExtensionOnly = false } = {}) {
return {
test: matchExtensionOnly ? /\.svg$/ : /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/,
use: [resolveLoader('raw-loader')]
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
function getFormattedTextLoader() {
return {
test: /\.(txt|html|rtf)$/,
use: [resolveLoader('raw-loader')]
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
function getJavaScriptLoader({ debugFlags }) {
return {
test: /\.js$/,
...getDebugLoader(debugFlags)
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
function getStylesLoader(options) {
const { minify = false, sourceMap = false, extractToSeparateFile = false } = options;
const getBundledLoader = () => ({
loader: resolveLoader('style-loader'),
options: {
injectType: 'singletonStyleTag',
attributes: {
'data-cke': true
}
}
});
const getExtractedLoader = () => {
return MiniCssExtractPlugin.loader;
};
const getCssLoader = () => ({
loader: resolveLoader('css-loader'),
options: {
importLoaders: 1,
sourceMap
}
});
const getLightningCssLoader = () => ({
loader: path.join(import.meta.dirname, 'ck-lightningcss-loader.js'),
options: {
lightningCssOptions: {
minify,
sourceMap,
include: Features.Nesting
}
}
});
return {
test: /\.css$/,
use: [
extractToSeparateFile ? getExtractedLoader() : getBundledLoader(),
getCssLoader(),
getLightningCssLoader()
].filter(Boolean)
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
var index$4 = /*#__PURE__*/Object.freeze({
__proto__: null,
getCoverageLoader: getCoverageLoader,
getDebugLoader: getDebugLoader,
getFormattedTextLoader: getFormattedTextLoader,
getIconsLoader: getIconsLoader,
getJavaScriptLoader: getJavaScriptLoader,
getStylesLoader: getStylesLoader,
getTypeScriptLoader: getTypeScriptLoader
});
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
function noop(callback) {
if (!callback) {
return new PassThrough({ objectMode: true });
}
return through({ objectMode: true }, (chunk, encoding, throughCallback) => {
const callbackResult = callback(chunk);
if (callbackResult instanceof Promise) {
callbackResult
.then(() => {
throughCallback(null, chunk);
})
.catch(err => {
throughCallback(err);
});
}
else {
throughCallback(null, chunk);
}
});
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
var index$3 = /*#__PURE__*/Object.freeze({
__proto__: null,
noop: noop
});
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
// A size of default indent for a log.
const INDENT_SIZE = 3;
/**
* A factory function that creates an instance of a CLI spinner. It supports both a spinner CLI and a spinner with a counter.
*
* The spinner improves UX when processing a time-consuming task. A developer does not have to consider whether the process hanged on.
*
* @param title Description of the current processed task.
* @param [options={}]
*/
function createSpinner(title, options = {}) {
const isEnabled = !options.isDisabled && isInteractive();
const indentLevel = options.indentLevel || 0;
const indent = ' '.repeat(indentLevel * INDENT_SIZE);
const emoji = options.emoji || '📍';
const status = options.status || '[title] Status: [current]/[total].';
const spinnerType = typeof options.total === 'number' ? 'counter' : 'spinner';
let timerId;
let counter = 0;
return {
start() {
if (!isEnabled) {
console.log(`${emoji} ${title}`);
return;
}
const { frames } = cliSpinners.dots12;
const getMessage = () => {
if (spinnerType === 'spinner') {
return title;
}
if (typeof options.status === 'function') {
return options.status(title, counter, options.total);
}
return `${status}`
.replace('[title]', title)
.replace('[current]', String(counter))
.replace('[total]', options.total.toString());
};
let index = 0;
let shouldClearLastLine = false;
cliCursor.hide();
timerId = setInterval(() => {
if (index === frames.length) {
index = 0;
}
if (shouldClearLastLine) {
clearLastLine();
}
process.stdout.write(`${indent}${frames[index++]} ${getMessage()}`);
shouldClearLastLine = true;
}, cliSpinners.dots12.interval);
},
increase() {
if (spinnerType === 'spinner') {
throw new Error('The \'#increase()\' method is available only when using the counter spinner.');
}
counter += 1;
},
finish(options = {}) {
const finishEmoji = options.emoji || emoji;
if (!isEnabled) {
return;
}
clearInterval(timerId);
clearLastLine();
if (spinnerType === 'counter') {
clearLastLine();
}
cliCursor.show();
console.log(`${indent}${finishEmoji} ${title}`);
}
};
function clearLastLine() {
readline.clearLine(process.stdout, 1);
readline.cursorTo(process.stdout, 0);
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* Returns array with all directories under the specified path.
*/
function getDirectories(directoryPath) {
const isDirectory = (directoryPath) => {
try {
return fs.statSync(directoryPath).isDirectory();
}
catch {
return false;
}
};
return fs.readdirSync(directoryPath)
.filter(item => {
return isDirectory(path.join(directoryPath, item));
});
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
function shExec(command, options = {}) {
const { verbosity = 'info', cwd = process.cwd(), async = false } = options;
sh.config.silent = true;
const execOptions = { cwd };
if (async) {
return new Promise((resolve, reject) => {
sh.exec(command, execOptions, (code, stdout, stderr) => {
try {
const result = execHandler({ code, stdout, stderr, verbosity, command });
resolve(result);
}
catch (err) {
reject(err);
}
});
});
}
const { code, stdout, stderr } = sh.exec(command, execOptions);
return execHandler({ code, stdout, stderr, verbosity, command });
}
function execHandler({ code, stdout, stderr, verbosity, command }) {
const log = logger(verbosity);
const grey = (text) => styleText('grey', text);
if (code) {
if (stdout) {
log.error(grey(stdout));
}
if (stderr) {
log.error(grey(stderr));
}
throw new Error(`Error while executing ${command}: ${stderr}`);
}
if (stdout) {
log.info(grey(stdout));
}
if (stderr) {
log.info(grey(stderr));
}
return stdout;
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* Updates JSON file under a specified path.
*
* @param filePath Path to a file on disk.
* @param updateFunction Function that will be called with a parsed JSON object. It should return the modified JSON object to save.
*/
function updateJSONFile(filePath, updateFunction) {
const contents = fs.readFileSync(filePath, 'utf-8');
let json = JSON.parse(contents);
json = updateFunction(json);
fs.writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n', 'utf-8');
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
const CHUNK_LENGTH_LIMIT = 4000;
async function commit({ cwd, message, files, dryRun = false }) {
cwd = upath.normalize(cwd);
const git = simpleGit({ baseDir: cwd });
const filteredFiles = await getFilesToCommit(cwd, files, git);
// To avoid an error when trying to commit a non-existing path.
if (!filteredFiles.length) {
return;
}
if (dryRun) {
const lastCommit = await git.log(['-1']);
await makeCommit(git, message, filteredFiles);
await git.reset([lastCommit.latest.hash]);
}
else {
await makeCommit(git, message, filteredFiles);
}
}
async function makeCommit(git, message, filteredFiles) {
for (const chunk of splitPathsIntoChunks(filteredFiles)) {
await git.add(chunk);
}
const status = await git.status();
if (!status.isClean()) {
await git.commit(message);
}
}
function splitPathsIntoChunks(filePaths) {
return filePaths.reduce((chunks, singlePath) => {
const lastChunk = chunks.at(-1);
const newLength = [...lastChunk, singlePath].join(' ').length;
if (newLength < CHUNK_LENGTH_LIMIT) {
lastChunk.push(singlePath);
}
else {
chunks.push([singlePath]);
}
return chunks;
}, [[]]);
}
/**
* Returns a set of Git-tracked file paths by parsing `git ls-files --stage`.
* Supports file names with spaces using tab-splitting.
*/
async function getFilesToCommit(cwd, files, git) {
const gitTracked = await getTrackedFiles(git);
const filePromises = files
.map(filePath => {
const normalized = upath.normalize(filePath);
// `upath` and Unix environment may fail on detecting a Windows-like path.
// Hence, let's use `isAbsolute` from both systems.
const isAbsolute = upath.win32.isAbsolute(normalized) || upath.posix.isAbsolute(normalized);
return isAbsolute ? upath.relative(cwd, normalized) : normalized;
})
.map(async (itemPath) => {
if (gitTracked.has(itemPath)) {
return itemPath;
}
const fullPath = upath.join(cwd, itemPath);
try {
await fs$1.access(fullPath);
return itemPath;
}
catch {
return null;
}
});
return (await Promise.all(filePromises))
.filter((pathOrNull) => pathOrNull !== null);
}
/**
* Returns a set of Git-tracked files in a current repository.
*/
async function getTrackedFiles(git) {
const gitTrackedOutput = await git.raw(['ls-files', '--stage']);
const gitTracked = gitTrackedOutput
.split('\n')
// <mode> <object> <stage>\t<file>
// Split by tab and take the last part, which is the file path that could contain spaces.
.map(line => line.trim().split('\t').pop())
.filter(Boolean)
.map(p => upath.normalize(p));
return new Set(gitTracked);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
var index$2 = /*#__PURE__*/Object.freeze({
__proto__: null,
commit: commit,
createSpinner: createSpinner,
getDirectories: getDirectories,
shExec: shExec,
updateJSONFile: updateJSONFile
});
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
const manifest = cacheLessPacoteFactory(pacote.manifest);
const packument = cacheLessPacoteFactory(pacote.packument);
/**
* Creates a version of a `pacote` function that doesn't use caching.
*/
function cacheLessPacoteFactory(callback) {
return async (...args) => {
const [description, options = {}] = args;
const uuid = randomUUID();
const cacheDir = upath.join(os.tmpdir(), `pacote--${uuid}`);
await fs$1.mkdir(cacheDir, { recursive: true });
try {
return await callback(description, {
...options,
cache: cacheDir,
memoize: false,
preferOnline: true
});
}
finally {
await fs$1.rm(cacheDir, { recursive: true, force: true });
}
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* Checks if a specific version of a package is available in the npm registry.
*/
async function checkVersionAvailability(version, packageName) {
return manifest(`${packageName}@${version}`)
.then(() => {
// If `manifest` resolves, a package with the given version exists.
return false;
})
.catch(() => {
// When throws, the package does not exist.
return true;
});
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
var index$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
checkVersionAvailability: checkVersionAvailability,
manifest: manifest,
packument: packument
});
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* This function locates package.json files for all packages located in `packagesDirectory` in the repository structure.
*/
async function findPathsToPackages(cwd, packagesDirectory, options = {}) {
const { includePackageJson = false, includeCwd = false, packagesDirectoryFilter = null } = options;
const packagePaths = await getPackages(cwd, packagesDirectory, includePackageJson);
if (includeCwd) {
if (includePackageJson) {
packagePaths.push(upath.join(cwd, 'package.json'));
}
else {
packagePaths.push(cwd);
}
}
const normalizedPaths = packagePaths.map(item => upath.normalize(item));
if (packagesDirectoryFilter) {
return normalizedPaths.filter(item => packagesDirectoryFilter(item));
}
return normalizedPaths;
}
async function getPackages(cwd, packagesDirectory, includePackageJson) {
if (!packagesDirectory) {
return Promise.resolve([]);
}
const globOptions = {
cwd: upath.join(cwd, packagesDirectory),
absolute: true
};
let pattern = '*/';
if (includePackageJson) {
pattern += 'package.json';
globOptions.nodir = true;
}
const paths = await glob(pattern, globOptions);
return paths.map(path => upath.normalize(path));
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* Reads and returns the contents of the package.json file.
*/
function getPackageJson(cwd = process.cwd(), { async = false } = {}) {
const path = upath.join(cwd, 'package.json');
if (async) {
return readFile(path, 'utf-8').then(data => JSON.parse(data));
}
const data = readFileSync(path, 'utf-8');
return JSON.parse(data);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* This function extracts the repository URL for generating links in the changelog.
*/
function getRepositoryUrl(cwd, { async = false } = {}) {
if (!async) {
const packageJson = getPackageJson(cwd);
return findRepositoryUrl(packageJson);
}
return getPackageJson(cwd, { async: true }).then(findRepositoryUrl);
}
function findRepositoryUrl(packageJson) {
// Due to merging our issue trackers, `packageJson.bugs` will point to the same place for every package.
// We cannot rely on this value anymore. See: https://github.com/ckeditor/ckeditor5/issues/1988.
// Instead of we can take a value from `packageJson.repository` and adjust it to match to our requirements.
let repositoryUrl = (typeof packageJson.repository === 'object') ? packageJson.repository.url : packageJson.repository;
if (!repositoryUrl) {
throw new Error(`The package.json for "${packageJson.name}" must contain the "repository" property.`);
}
if (repositoryUrl.startsWith('git+')) {
repositoryUrl = repositoryUrl.slice(4);
}
const match = repositoryUrl.match(/^(?:https?:\/\/|git@)github\.com[:/](?<owner>[^/\s]+)\/(?<repo>[^/\s]+?)(?:\.git)?(?:[/?#].*)?$/);
if (match) {
const { owner, repo } = match.groups;
return `https://github.com/${owner}/${repo}`;
}
// Short notation: `owner/repo`.
if (/^[^/\s]+\/[^/\s]+$/.test(repositoryUrl)) {
return `https://github.com/${repositoryUrl}`;
}
throw new Error(`The repository URL "${repositoryUrl}" is not supported.`);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
var index = /*#__PURE__*/Object.freeze({
__proto__: null,
findPathsToPackages: findPathsToPackages,
getPackageJson: getPackageJson,
getRepositoryUrl: getRepositoryUrl
});
export { index$4 as loaders, logger, index$1 as npm, index$3 as stream, index$2 as tools, index as workspaces };