projectz
Version:
Stop wasting time syncing and updating your project's README and Package Files!
417 lines (416 loc) • 18 kB
JavaScript
/* eslint key-spacing:0 */
// builtin
import { resolve, join, dirname } from 'node:path';
import trimEmptyKeys from 'trim-empty-keys';
import arrangePackageData from 'arrange-package-json';
import list from '@bevry/fs-list';
import read from '@bevry/fs-read';
import write from '@bevry/fs-write';
import { readJSON, writeJSON } from '@bevry/json';
import { isString, isPlainObject, isEmptyPlainObject } from 'typechecker';
import { getGitHubSlugFromPackageData, getBackers, renderBackers, BackersRenderFormat, getRepositoryWebsiteUrlFromGitHubSlugOrUrl, getRepositoryIssuesUrlFromGitHubSlugOrUrl, getRepositoryUrlFromGitHubSlugOrUrl, } from '@bevry/github-api';
import { mh1, trim } from '@bevry/render';
export * from './types.js';
import { getContributeSection, getBackersFile, getBackersSection, } from './backer.js';
import { getBadgesSection } from './badge.js';
import { getHistorySection } from './history.js';
import { getInstallInstructions } from './install.js';
import { getLicenseFile, getLicenseSection } from './license.js';
import { replaceSection } from './util.js';
/** Projectz, use to merge data files and render meta files. */
export class Projectz {
/** The log function to use, first argument being the log level */
log = function () { };
/** The directory to process, defaults to the current working directory*/
cwd;
/** If enabled, then remote updates will be not performed (such as fetching latest backers). */
offline = false;
/**
* Resolved absolute paths for the package files.
* Should be arranged in the order of merging preference.
*/
filenamesForPackageFiles = {
component: null,
bower: null,
jquery: null,
package: null,
projectz: null,
};
/** Resolved data for the package files */
dataForPackageFiles = {};
/** Resolved absolute paths for the readme files */
filenamesForReadmeFiles = {
// gets filled in with relative paths
readme: null,
history: null,
contributing: null,
backers: null,
license: null,
};
/** Resolved data for the readme files */
dataForReadmeFiles = {};
/** Configure our instance. */
constructor(opts = {}) {
this.cwd = resolve(opts.cwd || '.');
this.offline = opts.offline || false;
if (opts.log)
this.log = opts.log;
}
/** Use the configuration to compile the project. */
async compile() {
await this.loadPaths();
const enhancedPackageData = await this.enhanceDataForPackageFiles();
const enhancedReadmeData = await this.enhanceDataForReadmeFiles(enhancedPackageData);
await this.save(enhancedPackageData, enhancedReadmeData);
}
/** Resolve the paths and metdata for the data and meta files. */
async loadPaths() {
// Apply our determined paths for packages
const packageFiles = Object.keys(this.filenamesForPackageFiles);
const readmeFiles = Object.keys(this.filenamesForReadmeFiles);
// Load
const files = await list(this.cwd);
for (const file of files) {
const filePath = join(this.cwd, file);
for (const key of packageFiles) {
const basename = file.toLowerCase().split('.').slice(0, -1).join('.');
if (basename === key) {
this.log('info', `Reading package file: ${filePath}`);
const data = await readJSON(filePath);
this.filenamesForPackageFiles[key] = file;
this.dataForPackageFiles[key] = data;
}
}
for (const key of readmeFiles) {
if (file.toLowerCase().startsWith(key)) {
this.log('info', `Reading meta file: ${filePath}`);
const data = await read(filePath);
this.filenamesForReadmeFiles[key] = file;
this.dataForReadmeFiles[key] = data.toString();
}
}
}
}
/** Merge and enhance the data for the package files. */
async enhanceDataForPackageFiles() {
// ----------------------------------
// Combine
this.log('debug', 'Enhancing packages data');
// Combine the package data
const mergedPackageData = {
keywords: [],
editions: [],
badges: {
list: [],
config: {},
},
bugs: {},
readmes: {},
packages: {},
repository: {},
github: {},
dependencies: {},
devDependencies: {},
};
for (const key of Object.keys(this.filenamesForPackageFiles)) {
Object.assign(mergedPackageData, this.dataForPackageFiles[key]);
}
// ----------------------------------
// Validation
// Validate keywords field
if (isString(mergedPackageData.keywords)) {
throw new Error('projectz: keywords field must be array instead of CSV');
}
// Validate people fields
for (const soloField of [
'maintainer',
'contributor',
'sponsor',
'funder',
'backer',
]) {
const pluralField = `${soloField}s`;
if (mergedPackageData[soloField]) {
throw new Error(`projectz: ${soloField} field is deprecated, use ${pluralField} field`);
}
if (isString(mergedPackageData[pluralField])) {
throw new Error(`projectz: ${pluralField} field must be array instead of CSV`);
}
}
// Validate license SPDX string
if (isPlainObject(mergedPackageData.license)) {
throw new Error('projectz: license field must now be a valid SPDX string: https://docs.npmjs.com/files/package.json#license');
}
if (isPlainObject(mergedPackageData.licenses)) {
throw new Error('projectz: licenses field is deprecated, you must now use the license field as a valid SPDX string: https://docs.npmjs.com/files/package.json#license');
}
// Validate enhanced fields
for (const field of ['badges', 'readmes', 'packages', 'github', 'bugs']) {
if (!isPlainObject(mergedPackageData[field])) {
throw new Error(`projectz: ${field} field must be an object`);
}
}
// Validate package values
for (const [key, value] of Object.entries(mergedPackageData.packages)) {
if (!isPlainObject(value)) {
throw new Error(`projectz: custom package data for package ${key} must be an object`);
}
}
// Validate badges field
if (!Array.isArray(mergedPackageData.badges.list) ||
(mergedPackageData.badges.config &&
!isPlainObject(mergedPackageData.badges.config))) {
throw new Error('projectz: badges field must be in the format of: {list: [], config: {}}\nSee https://github.com/bevry/badges for details.');
}
mergedPackageData.badges.config ??= {};
// ----------------------------------
// Ensure
// Ensure repository is an object
if (typeof mergedPackageData.repository === 'string') {
mergedPackageData.repository = {
type: 'git',
url: mergedPackageData.repository,
};
}
// Fallback name
if (!mergedPackageData.name) {
mergedPackageData.name = dirname(this.cwd);
}
// Fallback version
if (!mergedPackageData.version) {
mergedPackageData.version = '0.1.0';
}
// Fallback demo field, by scanning homepage
if (!mergedPackageData.demo && mergedPackageData.homepage) {
mergedPackageData.demo = mergedPackageData.homepage;
}
// Fallback title from name
if (!mergedPackageData.title) {
mergedPackageData.title = mergedPackageData.name;
}
// Fallback description
if (!mergedPackageData.description) {
mergedPackageData.description = 'no description was provided';
}
// Fallback browsers field, by checking if `component` or `bower` package files exists, or if the `browser` or `jspm` fields are defined
if (mergedPackageData.browsers == null) {
mergedPackageData.browsers = Boolean(this.filenamesForPackageFiles.bower ||
this.filenamesForPackageFiles.component ||
mergedPackageData.browser ||
mergedPackageData.jspm);
}
// ----------------------------------
// Enhance Repository
// Converge and extract repository information
let github;
if (mergedPackageData.repository) {
const githubSlug = getGitHubSlugFromPackageData(mergedPackageData);
if (githubSlug) {
// Extract parts
const [githubUsername, githubRepository] = githubSlug.split('/');
const githubRepositoryWebsiteUrl = getRepositoryWebsiteUrlFromGitHubSlugOrUrl(githubSlug) || '';
const githubRepositoryUrl = getRepositoryUrlFromGitHubSlugOrUrl(githubSlug) || '';
const githubIssuesUrl = getRepositoryIssuesUrlFromGitHubSlugOrUrl(githubSlug) || '';
// Github data
github = {
username: githubUsername,
repository: githubRepository,
slug: githubSlug,
url: githubRepositoryWebsiteUrl,
repositoryUrl: githubRepositoryUrl,
};
// Badges
Object.assign(mergedPackageData.badges.config, {
githubUsername,
githubRepository,
githubSlug,
});
// Fallback bugs field by use of slug
if (!mergedPackageData.bugs) {
mergedPackageData.bugs = githubIssuesUrl;
}
// Fallback repository field by use of slug
if (!mergedPackageData.repository?.url) {
mergedPackageData.repository = {
type: 'git',
url: githubRepositoryUrl,
};
}
}
}
// ----------------------------------
// Enhance Backers
const backers = await getBackers({
githubSlug: github?.slug,
packageData: mergedPackageData,
offline: this.offline,
});
const renderedBackersForPackage = await renderBackers(backers, {
format: BackersRenderFormat.string,
});
// ----------------------------------
// Enhance Packages
// Create the data for the `package.json` format
const pkg = Object.assign(
// New Object
{},
// Old Data
this.dataForPackageFiles.package || {},
// Enhanced Data
{
// meta
name: mergedPackageData.name,
version: mergedPackageData.version,
license: mergedPackageData.license,
description: mergedPackageData.description,
repository: mergedPackageData.repository,
bugs: mergedPackageData.bugs,
keywords: mergedPackageData.keywords,
// code
engines: mergedPackageData.engines,
dependencies: mergedPackageData.dependencies,
devDependencies: mergedPackageData.devDependencies,
main: mergedPackageData.main,
},
// Enhanced Backers
renderedBackersForPackage,
// Explicit data
mergedPackageData.packages.package || {});
// Trim
// @ts-ignore
if (isEmptyPlainObject(pkg.dependencies))
delete pkg.dependencies;
// @ts-ignore
if (isEmptyPlainObject(pkg.devDependencies))
delete pkg.devDependencies;
// Badges
if (!mergedPackageData.badges.config.npmPackageName && pkg.name) {
mergedPackageData.badges.config.npmPackageName = pkg.name;
}
// Create the data for the `jquery.json` format, which is essentially the same as the `package.json` format so just extend that
const jquery = Object.assign(
// New Object
{},
// Old Data
this.dataForPackageFiles.jquery || {},
// Enhanced Data
pkg,
// Explicit data
mergedPackageData.packages.jquery || {});
// Create the data for the `component.json` format
const component = Object.assign(
// New Object
{},
// Old Data
this.dataForPackageFiles.component || {},
// Enhanced Data
{
name: mergedPackageData.name,
version: mergedPackageData.version,
license: mergedPackageData.license,
description: mergedPackageData.description,
keywords: mergedPackageData.keywords,
demo: mergedPackageData.demo,
main: mergedPackageData.main,
scripts: [mergedPackageData.main],
},
// Explicit data
mergedPackageData.packages.component || {});
// Create the data for the `bower.json` format
const bower = Object.assign(
// New Object
{},
// Old Data
this.dataForPackageFiles.bower || {},
// Enhanced Data
{
name: mergedPackageData.name,
version: mergedPackageData.version,
license: mergedPackageData.license,
description: mergedPackageData.description,
keywords: mergedPackageData.keywords,
authors: mergedPackageData.authors,
main: mergedPackageData.main,
},
// Explicit data
mergedPackageData.packages.bower || {});
// ----------------------------------
// Enhance Combination
// only stored in memory
const enhancedPackageData = Object.assign({}, mergedPackageData, backers, {
// Add paths so that our helpers have access to them
filenamesForPackageFiles: this.filenamesForPackageFiles,
filenamesForReadmeFiles: this.filenamesForReadmeFiles,
// Other
github,
// Create the data for the `package.json` format
package: pkg,
jquery,
component,
bower,
});
// Return
return enhancedPackageData;
}
/** Merge and enhance the metadata from the meta files. */
async enhanceDataForReadmeFiles(data) {
const enhancedReadmeData = {};
/* eslint prefer-const: 0 */
for (let [key, value] of Object.entries(this.dataForReadmeFiles)) {
if (!value) {
this.log('debug', `Enhancing readme value: ${key} — skipped`);
continue;
}
value = replaceSection(['TITLE', 'NAME'], value, mh1(data.title));
value = replaceSection(['BADGES', 'BADGE'], value, getBadgesSection.bind(null, data));
value = replaceSection(['DESCRIPTION'], value, data.description);
value = replaceSection(['INSTALL'], value, getInstallInstructions.bind(null, data));
value = replaceSection(['CONTRIBUTE', 'CONTRIBUTING'], value, data.github
? getContributeSection.bind(null, data)
: '<!-- github projects only -->', Boolean(value.includes('BACKERS --')));
value = replaceSection(['BACKERS', 'BACKER', 'PEOPLE'], value, data.github
? getBackersSection.bind(null, data)
: '<!-- github projects only -->');
value = replaceSection(['BACKERSFILE', 'BACKERFILE', 'PEOPLEFILE'], value, data.github
? getBackersFile.bind(null, data)
: '<!-- github projects only -->');
value = replaceSection(['HISTORY', 'CHANGES', 'CHANGELOG'], value, data.github
? getHistorySection.bind(null, data)
: '<!-- github projects only -->');
value = replaceSection(['LICENSE', 'LICENSES'], value, data.github
? getLicenseSection.bind(null, data)
: '<!-- github projects only -->');
value = replaceSection(['LICENSEFILE'], value, getLicenseFile.bind(null, data));
enhancedReadmeData[key] = value.replaceAll('-->\n\n\n<!--', '-->\n\n<!--');
this.log('info', `Enhanced readme value: ${key}`);
}
return enhancedReadmeData;
}
/** Save the data and meta files with our enhancements. */
async save(enhancedPackageData, enhancedReadmeData) {
// Prepare
this.log('info', 'Writing changes...');
await Promise.all([
// save package files
...Object.entries(this.filenamesForPackageFiles).map(async ([key, filename]) => {
if (!filename || key === 'projectz')
return;
const filepath = join(this.cwd, filename);
this.log('info', `Saving package file: ${filepath}`);
const data = trimEmptyKeys(arrangePackageData(enhancedPackageData[key]));
return writeJSON(filepath, data);
}),
// save readme files
...Object.entries(this.filenamesForReadmeFiles).map(async ([key, filename]) => {
if (!filename)
return;
const filepath = join(this.cwd, filename);
this.log('info', `Saving readme file: ${filepath}`);
const content = trim(enhancedReadmeData[key]) + '\n';
return write(filepath, content);
}),
]);
// log
this.log('info', 'Wrote changes');
}
}