UNPKG

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