UNPKG

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