UNPKG

npm-template-sync

Version:

Keep npm package in sync with its template

567 lines (485 loc) 13.8 kB
import { compareVersion } from "hinted-tree-merger"; import diff from "simple-diff"; import { File } from "./file.mjs"; import { sortObjectsKeys, jspath, defaultEncodingOptions } from "./util.mjs"; import { decodeScripts, encodeScripts, mergeScripts } from "./package-scripts.mjs"; function moduleNames(object) { if (object === undefined) return new Set(); const modules = new Set(); Object.keys(object).forEach(k => { const v = object[k]; if (typeof v === "string") { modules.add(v); } else if (Array.isArray(v)) { v.forEach(e => { if (typeof e === "string") { modules.add(e); } }); } }); return modules; } /** * order in which json keys are written */ const sortedKeys = [ "name", "version", "type", "private", "publishConfig", "main", "browser", "module", "svelte", "unpkg", "description", "keywords", "author", "maintainers", "contributors", "license", "sustainability", "bin", "scripts", "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", "bundledDependencies", "engines", "os", "cpu", "arch", "repository", "directories", "files", "man", "bugs", "homepage", "config", "systemd", "pacman", "release", "ava", "nyc", "xo", "template" ]; const propertyKeys = [ "description", "version", "name", "main", "module", "browser" ]; /** * Merger for package.json */ export class Package extends File { static get defaultOptions() { return { actions: [], keywords: [] }; } static matchesFileName(name) { return name.match(/^package\.json$/); } optionalDevModules(modules = new Set()) { return new Set(["cracks", "dont-crack"].filter(m => modules.has(m))); } async usedDevModules(content) { content = await content; const pkg = content.length === 0 ? {} : JSON.parse(content); return moduleNames(pkg.release); } /** * Deliver some key properties * @param {Branch} branch * @return {Object} */ async properties(branch) { const content = await branch.maybeEntry(this.name); if (content === undefined) { return {}; } const pkg = JSON.parse(await content.getString(defaultEncodingOptions)); const properties = { npm: { name: pkg.name, fullName: pkg.name } }; if (pkg.name !== undefined) { const m = pkg.name.match(/^(\@[^\/]+)\/(.*)/); if (m) { properties.npm.organization = m[1]; properties.npm.name = m[2]; } } if (pkg.template !== undefined) { if (pkg.template.repository !== undefined) { properties.templateRepo = pkg.template.repository.url; } if (pkg.template.usedBy !== undefined) { properties.usedBy = pkg.template.usedBy; } } propertyKeys.forEach(key => { if (pkg[key] !== undefined && pkg[key] !== `{{${key}}}`) { if (!(key === "version" && pkg[key] === "0.0.0-semantic-release")) { properties[key] = pkg[key]; } } }); if(pkg.config !== undefined) { Object.assign(properties,pkg.config); } return properties; } async mergeContent(context, original, templateContent) { const originalLastChar = original[original.length - 1]; const originalTemplate = JSON.parse(templateContent); const targetRepository = context.targetBranch.repository; let target = original === undefined || original === "" ? {} : JSON.parse(original); const template = Object.assign({}, originalTemplate, { repository: { type: targetRepository.type, url: targetRepository.url }, bugs: { url: context.targetBranch.issuesURL }, homepage: context.targetBranch.homePageURL, template: { repository: { url: context.templateBranch.url } } }); template.template = Object.assign({}, target.template, template.template); let messages = []; const properties = context.properties; if (target.name === undefined || target.name === "") { const m = targetRepository.name.match(/^([^\/]+)\/(.*)/); target.name = m ? m[2] : context.targetBranch.repository.name; } if (target.module !== undefined && !target.module.match(/\{\{module\}\}/)) { properties.module = target.module; } const slots = { repository: "chore(package): correct repository url", bugs: "chore(package): set bugs url from template", homepage: "chore(package): homepage from template", template: "chore(package): set template repo" }; Object.keys(slots).forEach(key => { const templateValue = template[key]; const d = diff(target[key], templateValue); if ( templateValue !== undefined && d.length > 0 && !( d[0].type === "add" && d[0].oldValue === undefined && d[0].newValue === undefined ) ) { messages.push(slots[key]); target[key] = templateValue; } }); const decodedScripts = decodeScripts(target.scripts); const usedDevModules = await context.usedDevModules(); context.debug({ usedDevModules: Array.from(usedDevModules) }); const optionalDevModules = context.optionalDevModules(usedDevModules); context.debug({ optionalDevModules: Array.from(optionalDevModules) }); const deepProperties = { devDependencies: { type: "chore", scope: "package", merge: defaultMerge }, dependencies: { type: "fix", scope: "package", merge: defaultMerge }, peerDependencies: { type: "fix", scope: "package", merge: defaultMerge }, optionalDependencies: { type: "fix", scope: "package", merge: defaultMerge }, bundeledDependencies: { type: "fix", scope: "package", merge: defaultMerge }, scripts: { type: "chore", scope: "scripts", merge: defaultMerge }, engines: { type: "chore", scope: "engines", merge: defaultMerge } }; Object.keys(deepProperties).forEach( name => (deepProperties[name].name = name) ); Object.keys(deepProperties).forEach(category => { if (template[category] !== undefined) { Object.keys(template[category]).forEach(d => { if (target[category] === undefined) { target[category] = {}; } const tp = context.expand(template[category][d]); if ( category === "devDependencies" && target.dependencies !== undefined && target.dependencies[d] === tp ) { // do not include dev dependency if regular dependency is already present } else { deepProperties[category].merge( target[category], target[category][d], tp, deepProperties[category], d, messages ); } }); } }); Object.keys(template).forEach(p => { if (target[p] === undefined && target[p] !== "--delete--") { target[p] = template[p]; messages.push(`chore(package): add ${p} from template`); } }); target.scripts = encodeScripts( mergeScripts(decodedScripts, decodeScripts(template.scripts)) ); if ( target.contributors !== undefined && target.author !== undefined && target.author.name !== undefined ) { const m = target.author.name.match(/(^[^<]+)<([^>]+)>/); if (m !== undefined) { const name = String(m[1]).replace(/^\s+|\s+$/g, ""); const email = m[2]; if ( target.contributors.find(c => c.name === name && c.email === email) ) { delete target.author; } } } const toBeDeletedModules = target.devDependencies === undefined ? [] : Array.from( context.optionalDevModules( new Set(Object.keys(target.devDependencies)) ) ).filter(m => !usedDevModules.has(m)); toBeDeletedModules.forEach(d => { messages = messages.filter( m => !m.startsWith(`chore(package): add ${d}@`) ); delete target.devDependencies[d]; }); target = deleter(target, template, messages, []); Object.keys(this.options.keywords).forEach(r => addKeyword(target, new RegExp(r), this.options.keywords[r], messages) ); removeKeyword( target, ["null", null, undefined, "npm-package-template"], messages ); this.options.actions.forEach(action => { if (action.op === "replace") { const templateValue = jspath(template, action.path); jspath(target, action.path, (targetValue, setter) => { if (templateValue !== targetValue) { setter(templateValue); messages.push( `chore(package): set ${ action.path }='${templateValue}' as in template` ); } }); } }); target = context.expand(target); propertyKeys.forEach(key => { if (target[key] === "{{" + key + "}}") { delete target[key]; messages.push( `chore(package): remove unknown value for ${key} ({{${key}}})` ); } }); const sortedTarget = normalizePackage(target); let newContent = JSON.stringify(sortedTarget, undefined, 2); const lastChar = newContent[newContent.length - 1]; // keep trailing newline if (originalLastChar === "\n" && lastChar === "}") { newContent += "\n"; } const changed = original !== newContent; if (changed && messages.length === 0) { messages.push("chore(package): update package.json from template"); } return { content: newContent, messages, changed }; } } function deleter(object, reference, messages, path) { if ( typeof object === "string" || object instanceof String || object === true || object === false || object === undefined || object === null || typeof object === "number" || object instanceof Number ) { return object; } if (Array.isArray(object)) { return object.map((e, i) => { path.push(i); const n = deleter( object[i], Array.isArray(reference) ? reference[i] : undefined, messages, path ); path.pop(); return n; }); } if (reference) { Object.keys(reference).forEach(key => { path.push(key); if (reference[key] === "--delete--" && object[key] !== undefined) { if (object[key] !== "--delete--") { messages.push(`chore(package): delete ${path.join(".")}`); } delete object[key]; } else { object[key] = deleter(object[key], reference[key], messages, path); } path.pop(); }); } return object; } function removeKeyword(pkg, keywords, messages) { if (pkg.keywords !== undefined) { keywords.forEach(keyword => { if (pkg.keywords.find(k => k === keyword)) { messages.push(`docs(package): remove keyword ${keyword}`); pkg.keywords = pkg.keywords.filter(k => k !== keyword); } }); if ( (pkg.keywords.length === 1 && pkg.keywords[0] === null) || pkg.keywords[0] === undefined ) { messages.push(`docs(package): remove keyword null`); pkg.keywords = []; } } } function addKeyword(pkg, regex, keyword, messages) { if (keyword === undefined || keyword === null || keyword === "null") { return; } if (pkg.name.match(regex)) { if (pkg.keywords === undefined) { pkg.keywords = []; } if (!pkg.keywords.find(k => k === keyword)) { messages.push(`docs(package): add keyword ${keyword}`); pkg.keywords.push(keyword); } } } function getVersion(e) { const m = e.match(/([\d\.]+)/); return m ? Number(m[1]) : undefined; } function normalizeVersion(e) { return e.replace(/^[\^\$]/, ""); } /** * */ function defaultMerge(destination, target, template, dp, name, messages) { if (template === "--delete--") { if (target !== undefined) { messages.push(`${dp.type}(${dp.scope}): remove ${name}@${target}`); delete destination[name]; } return; } if (target === undefined) { messages.push(`${dp.type}(${dp.scope}): add ${name}@${template}`); destination[name] = template; } else if (template !== target) { if (dp.name === "engines") { if (getVersion(target) > getVersion(template)) { return; } } if (dp.name === "devDependencies") { //console.log(`${target} <> ${template} -> ${compareVersion(normalizeVersion(target), normalizeVersion(template))}`); if ( compareVersion(normalizeVersion(target), normalizeVersion(template)) >= 0 ) { return; } } messages.push(`${dp.type}(${dp.scope}): ${name}@${template}`); destination[name] = template; } } /** * bring package into nomalized (sorted) form * @param {Object} source * @return {Object} normalized source */ function normalizePackage(source) { const normalized = {}; sortedKeys.forEach(key => { if (source[key] !== undefined) { switch (key) { case "bin": case "scripts": case "dependencies": case "devDependencies": case "peerDependencies": case "optionalDependencies": case "bundledDependencies": normalized[key] = sortObjectsKeys(source[key]); break; default: normalized[key] = source[key]; } } }); Object.keys(source).forEach(key => { if (normalized[key] === undefined) { normalized[key] = source[key]; } }); return normalized; }