UNPKG

15.9 kBJavaScriptView Raw
1/* eslint key-spacing:0 */
2// Imports
3// First we need to import the libraries we require.
4// Load in the file system libraries
5import { promises as fsPromises } from 'fs'
6const { readdir, readFile, writeFile } = fsPromises
7import { resolve, join, dirname } from 'path'
8// Errlop used for wrapping errors
9import Errlop from 'errlop'
10// CSON is used for loading in our configuration files
11import { parse as parseCSON } from 'cson-parser'
12// [TypeChecker](https://github.com/bevry/typechecker) is used for checking data types
13import { isString, isPlainObject, isEmptyPlainObject } from 'typechecker'
14// Fellow handling, and contributor fetching
15import Fellow from 'fellow'
16// Load in our other project files
17import {
18 getContributeSection,
19 getBackerFile,
20 getBackerSection,
21} from './backer.js'
22import { getBadgesSection } from './badge.js'
23import { getHistorySection } from './history.js'
24import { getInstallInstructions } from './install.js'
25import { getLicenseFile, getLicenseSection } from './license.js'
26import {
27 getGithubSlug,
28 getPeopleTextArray,
29 replaceSection,
30 trim,
31} from './util.js'
32async function parseFile(path) {
33 try {
34 const str = await readFile(path, 'utf-8')
35 const data = parseCSON(str)
36 return data
37 } catch (err) {
38 return Promise.reject(new Errlop(`failed parsing the file: ${path}`, err))
39 }
40}
41// Definition
42// Projects is defined as a class to ensure we can run multiple instances of it
43export class Projectz {
44 // Apply options
45 constructor(opts) {
46 /** our log function to use (logLevel, ...messages) */
47 this.log = function () {}
48 /**
49 * The absolute paths for all the package files.
50 * Should be arranged in the order of merging preference.
51 */
52 this.filenamesForPackageFiles = {
53 component: null,
54 bower: null,
55 jquery: null,
56 package: null,
57 projectz: null,
58 }
59 /** the data for each of our package files */
60 this.dataForPackageFiles = {}
61 /** the absolute paths for all the meta files */
62 this.filenamesForReadmeFiles = {
63 // gets filled in with relative paths
64 readme: null,
65 history: null,
66 contributing: null,
67 backers: null,
68 license: null,
69 }
70 /** the data for each of our readme files */
71 this.dataForReadmeFiles = {}
72 this.cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
73 if (opts.log) this.log = opts.log
74 }
75 /** Compile the project */
76 async compile() {
77 // Load readme and package data
78 await this.loadPaths()
79 // Enhance our package data
80 const enhancedPackagesData = await this.enhancePackagesData()
81 // Enhance our readme data
82 const enhancedReadmesData = await this.enhanceReadmesData(
83 enhancedPackagesData
84 )
85 // Save
86 await this.save(enhancedPackagesData, enhancedReadmesData)
87 }
88 /** Load in the paths we have specified */
89 async loadPaths() {
90 // Apply our determined paths for packages
91 const packages = Object.keys(this.filenamesForPackageFiles)
92 const ReadmeFiles = Object.keys(this.filenamesForReadmeFiles)
93 // Load
94 const files = await readdir(this.cwd)
95 for (const file of files) {
96 const filePath = join(this.cwd, file)
97 for (const key of packages) {
98 const basename = file.toLowerCase().split('.').slice(0, -1).join('.')
99 if (basename === key) {
100 this.log('info', `Reading package file: ${filePath}`)
101 const data = await parseFile(filePath)
102 this.filenamesForPackageFiles[key] = file
103 this.dataForPackageFiles[key] = data
104 }
105 }
106 for (const key of ReadmeFiles) {
107 if (file.toLowerCase().startsWith(key)) {
108 this.log('info', `Reading meta file: ${filePath}`)
109 const data = await readFile(filePath, 'utf-8')
110 this.filenamesForReadmeFiles[key] = file
111 this.dataForReadmeFiles[key] = data.toString()
112 }
113 }
114 }
115 }
116 /** Merge and enhance the packages data */
117 async enhancePackagesData() {
118 // ----------------------------------
119 // Combine
120 // Combine the package data
121 const mergedPackagesData = {
122 keywords: [],
123 editions: [],
124 badges: {
125 list: [],
126 config: {},
127 },
128 bugs: {},
129 readmes: {},
130 packages: {},
131 repository: {},
132 github: {},
133 dependencies: {},
134 devDependencies: {},
135 }
136 for (const key of Object.keys(this.filenamesForPackageFiles)) {
137 Object.assign(mergedPackagesData, this.dataForPackageFiles[key])
138 }
139 // ----------------------------------
140 // Validation
141 // Validate keywords field
142 if (isString(mergedPackagesData.keywords)) {
143 return Promise.reject(
144 new Error('projectz: keywords field must be array instead of CSV')
145 )
146 }
147 // Validate sponsors array
148 if (mergedPackagesData.sponsor) {
149 return Promise.reject(
150 new Error('projectz: sponsor field is deprecated, use sponsors field')
151 )
152 }
153 if (isString(mergedPackagesData.sponsors)) {
154 return Promise.reject(
155 new Error('projectz: sponsors field must be array instead of CSV')
156 )
157 }
158 // Validate maintainers array
159 if (mergedPackagesData.maintainer) {
160 return Promise.reject(
161 new Error(
162 'projectz: maintainer field is deprecated, use maintainers field'
163 )
164 )
165 }
166 if (isString(mergedPackagesData.maintainers)) {
167 return Promise.reject(
168 new Error('projectz: maintainers field must be array instead of CSV')
169 )
170 }
171 // Validate license SPDX string
172 if (isPlainObject(mergedPackagesData.license)) {
173 return Promise.reject(
174 new Error(
175 'projectz: license field must now be a valid SPDX string: https://docs.npmjs.com/files/package.json#license'
176 )
177 )
178 }
179 if (isPlainObject(mergedPackagesData.licenses)) {
180 return Promise.reject(
181 new Error(
182 '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'
183 )
184 )
185 }
186 // Validate enhanced fields
187 const objs = ['badges', 'readmes', 'packages', 'github', 'bugs']
188 for (const key of objs) {
189 if (!isPlainObject(mergedPackagesData[key])) {
190 return Promise.reject(
191 new Error(`projectz: ${key} property must be an object`)
192 )
193 }
194 }
195 // Validate package values
196 for (const [key, value] of Object.entries(mergedPackagesData.packages)) {
197 if (!isPlainObject(value)) {
198 return Promise.reject(
199 new Error(
200 `projectz: custom package data for package ${key} must be an object`
201 )
202 )
203 }
204 }
205 // Validate badges field
206 if (
207 !Array.isArray(mergedPackagesData.badges.list) ||
208 (mergedPackagesData.badges.config &&
209 !isPlainObject(mergedPackagesData.badges.config))
210 ) {
211 return Promise.reject(
212 new Error(
213 'projectz: badges field must be in the format of: {list: [], config: {}}\nSee https://github.com/bevry/badges for details.'
214 )
215 )
216 }
217 // ----------------------------------
218 // Ensure
219 // Ensure repository is an object
220 if (typeof mergedPackagesData.repository === 'string') {
221 const githubSlug = getGithubSlug(mergedPackagesData)
222 if (githubSlug) {
223 mergedPackagesData.repository = {
224 type: 'git',
225 url: `https://github.com/${githubSlug}.git`,
226 }
227 }
228 }
229 // Fallback name
230 if (!mergedPackagesData.name) {
231 mergedPackagesData.name = dirname(this.cwd)
232 }
233 // Fallback version
234 if (!mergedPackagesData.version) {
235 mergedPackagesData.version = '0.1.0'
236 }
237 // Fallback demo field, by scanning homepage
238 if (!mergedPackagesData.demo && mergedPackagesData.homepage) {
239 mergedPackagesData.demo = mergedPackagesData.homepage
240 }
241 // Fallback title from name
242 if (!mergedPackagesData.title) {
243 mergedPackagesData.title = mergedPackagesData.name
244 }
245 // Fallback description
246 if (!mergedPackagesData.description) {
247 mergedPackagesData.description = 'no description was provided'
248 }
249 // Fallback browsers field, by checking if `component` or `bower` package files exists, or if the `browser` or `jspm` fields are defined
250 if (mergedPackagesData.browsers == null) {
251 mergedPackagesData.browsers = Boolean(
252 this.filenamesForPackageFiles.bower ||
253 this.filenamesForPackageFiles.component ||
254 mergedPackagesData.browser ||
255 mergedPackagesData.jspm
256 )
257 }
258 // ----------------------------------
259 // Enhance Respository
260 // Converge and extract repository information
261 let github
262 if (mergedPackagesData.repository) {
263 const githubSlug = getGithubSlug(mergedPackagesData)
264 if (githubSlug) {
265 // Extract parts
266 const [githubUsername, githubRepository] = githubSlug.split('/')
267 const githubUrl = 'https://github.com/' + githubSlug
268 const githubRepositoryUrl = githubUrl + '.git'
269 // Github data
270 github = {
271 username: githubUsername,
272 repository: githubRepository,
273 slug: githubSlug,
274 url: githubUrl,
275 repositoryUrl: githubRepositoryUrl,
276 }
277 // Badges
278 Object.assign(mergedPackagesData.badges.config, {
279 githubUsername,
280 githubRepository,
281 githubSlug,
282 })
283 // Fallback bugs field by use of repo
284 if (!mergedPackagesData.bugs) {
285 mergedPackagesData.bugs = github && {
286 url: `https://github.com/${github.slug}/issues`,
287 }
288 }
289 // Fetch contributors
290 // await getContributorsFromRepo(githubSlug)
291 }
292 }
293 // ----------------------------------
294 // Enhance People
295 // Fellows
296 const authors = Fellow.add(
297 mergedPackagesData.authors || mergedPackagesData.author
298 )
299 const contributors = Fellow.add(mergedPackagesData.contributors).filter(
300 (fellow) => fellow.name.includes('[bot]') === false
301 )
302 const maintainers = Fellow.add(mergedPackagesData.maintainers).filter(
303 (fellow) => fellow.name.includes('[bot]') === false
304 )
305 const sponsors = Fellow.add(mergedPackagesData.sponsors)
306 // ----------------------------------
307 // Enhance Packages
308 // Create the data for the `package.json` format
309 const pkg = Object.assign(
310 // New Object
311 {},
312 // Old Data
313 this.dataForPackageFiles.package || {},
314 // Enhanced Data
315 {
316 name: mergedPackagesData.name,
317 version: mergedPackagesData.version,
318 license: mergedPackagesData.license,
319 description: mergedPackagesData.description,
320 keywords: mergedPackagesData.keywords,
321 author: getPeopleTextArray(authors, {
322 displayEmail: true,
323 displayYears: true,
324 }).join(', '),
325 maintainers: getPeopleTextArray(maintainers, {
326 displayEmail: true,
327 urlFields: ['githubUrl', 'url'],
328 }),
329 contributors: getPeopleTextArray(contributors, {
330 displayEmail: true,
331 urlFields: ['githubUrl', 'url'],
332 }),
333 repository: mergedPackagesData.repository,
334 bugs: mergedPackagesData.bugs,
335 engines: mergedPackagesData.engines,
336 dependencies: mergedPackagesData.dependencies,
337 devDependencies: mergedPackagesData.devDependencies,
338 main: mergedPackagesData.main,
339 },
340 // Explicit data
341 mergedPackagesData.packages.package || {}
342 )
343 // Trim
344 // @ts-ignore
345 if (isEmptyPlainObject(pkg.dependencies)) delete pkg.dependencies
346 // @ts-ignore
347 if (isEmptyPlainObject(pkg.devDependencies)) delete pkg.devDependencies
348 // Badges
349 if (!mergedPackagesData.badges.config.npmPackageName && pkg.name) {
350 mergedPackagesData.badges.config.npmPackageName = pkg.name
351 }
352 // Create the data for the `jquery.json` format, which is essentially the same as the `package.json` format so just extend that
353 const jquery = Object.assign(
354 // New Object
355 {},
356 // Old Data
357 this.dataForPackageFiles.jquery || {},
358 // Enhanced Data
359 pkg,
360 // Explicit data
361 mergedPackagesData.packages.jquery || {}
362 )
363 // Create the data for the `component.json` format
364 const component = Object.assign(
365 // New Object
366 {},
367 // Old Data
368 this.dataForPackageFiles.component || {},
369 // Enhanced Data
370 {
371 name: mergedPackagesData.name,
372 version: mergedPackagesData.version,
373 license: mergedPackagesData.license,
374 description: mergedPackagesData.description,
375 keywords: mergedPackagesData.keywords,
376 demo: mergedPackagesData.demo,
377 main: mergedPackagesData.main,
378 scripts: [mergedPackagesData.main],
379 },
380 // Explicit data
381 mergedPackagesData.packages.component || {}
382 )
383 // Create the data for the `bower.json` format
384 const bower = Object.assign(
385 // New Object
386 {},
387 // Old Data
388 this.dataForPackageFiles.bower || {},
389 // Enhanced Data
390 {
391 name: mergedPackagesData.name,
392 version: mergedPackagesData.version,
393 license: mergedPackagesData.license,
394 description: mergedPackagesData.description,
395 keywords: mergedPackagesData.keywords,
396 authors: getPeopleTextArray(authors, {
397 displayYears: true,
398 displayEmail: true,
399 }),
400 main: mergedPackagesData.main,
401 },
402 // Explicit data
403 mergedPackagesData.packages.bower || {}
404 )
405 // ----------------------------------
406 // Enhance Combination
407 // Merge together
408 const enhancedPackagesData = Object.assign({}, mergedPackagesData, {
409 // Add paths so that our helpers have access to them
410 filenamesForPackageFiles: this.filenamesForPackageFiles,
411 filenamesForReadmeFiles: this.filenamesForReadmeFiles,
412 // Other
413 github,
414 // Fellows
415 authors,
416 contributors,
417 maintainers,
418 sponsors,
419 // Create the data for the `package.json` format
420 package: pkg,
421 jquery,
422 component,
423 bower,
424 })
425 // Return
426 return enhancedPackagesData
427 }
428 /** Merge and enhance the readmes data */
429 async enhanceReadmesData(data) {
430 const enhancedReadmesData = {}
431 /* eslint prefer-const: 0 */
432 for (let [key, value] of Object.entries(this.dataForReadmeFiles)) {
433 if (!value) {
434 this.log('debug', `Enhancing readme value: ${key} — skipped`)
435 continue
436 }
437 value = replaceSection(['TITLE', 'NAME'], value, `<h1>${data.title}</h1>`)
438 value = replaceSection(
439 ['BADGES', 'BADGE'],
440 value,
441 getBadgesSection.bind(null, data)
442 )
443 value = replaceSection(['DESCRIPTION'], value, data.description)
444 value = replaceSection(
445 ['INSTALL'],
446 value,
447 getInstallInstructions.bind(null, data)
448 )
449 value = replaceSection(
450 ['CONTRIBUTE', 'CONTRIBUTING'],
451 value,
452 data.github
453 ? getContributeSection.bind(null, data)
454 : '<!-- github projects only -->'
455 )
456 value = replaceSection(
457 ['BACKERS', 'BACKER'],
458 value,
459 data.github
460 ? getBackerSection.bind(null, data)
461 : '<!-- github projects only -->'
462 )
463 value = replaceSection(
464 ['BACKERSFILE', 'BACKERFILE'],
465 value,
466 data.github
467 ? getBackerFile.bind(null, data)
468 : '<!-- github projects only -->'
469 )
470 value = replaceSection(
471 ['HISTORY', 'CHANGES', 'CHANGELOG'],
472 value,
473 data.github
474 ? getHistorySection.bind(null, data)
475 : '<!-- github projects only -->'
476 )
477 value = replaceSection(
478 ['LICENSE', 'LICENSES'],
479 value,
480 data.github
481 ? getLicenseSection.bind(null, data)
482 : '<!-- github projects only -->'
483 )
484 value = replaceSection(
485 ['LICENSEFILE'],
486 value,
487 getLicenseFile.bind(null, data)
488 )
489 enhancedReadmesData[key] = trim(value) + '\n'
490 this.log('info', `Enhanced readme value: ${key}`)
491 }
492 return enhancedReadmesData
493 }
494 /** Save the data we've loaded into the files */
495 async save(enhancedPackagesData, enhancedReadmesData) {
496 // Prepare
497 this.log('info', 'Writing changes...')
498 await Promise.all([
499 // save package files
500 ...Object.entries(this.filenamesForPackageFiles).map(
501 async ([key, filename]) => {
502 if (!filename || key === 'projectz') return
503 const filepath = join(this.cwd, filename)
504 this.log('info', `Saving package file: ${filepath}`)
505 const data =
506 JSON.stringify(enhancedPackagesData[key], null, ' ') + '\n'
507 return writeFile(filepath, data)
508 }
509 ),
510 // save readme files
511 ...Object.entries(this.filenamesForReadmeFiles).map(
512 async ([key, filename]) => {
513 if (!filename) return
514 const filepath = join(this.cwd, filename)
515 this.log('info', `Saving readme file: ${filepath}`)
516 const data = enhancedReadmesData[key]
517 return writeFile(filepath, data)
518 }
519 ),
520 ])
521 // log
522 this.log('info', 'Wrote changes')
523 }
524}