diff --git a/README.md b/README.md index f45b58475..340a2db60 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,38 @@ -# AEgir +# AEgir [![](https://img.shields.io/badge/made%20by-Protocol%20Labs-blue.svg?style=flat-square)](http://ipn.io) [![](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](http://ipfs.io/) [![](https://img.shields.io/badge/freenode-%23ipfs-blue.svg?style=flat-square)](http://webchat.freenode.net/?channels=%23ipfs) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/ipfs/aegir/ci/master?style=flat-square) -[![Dependency Status](https://david-dm.org/ipfs/aegir.svg?style=flat-square)](https://david-dm.org/ipfs/aegir) > Automated JavaScript project management. -## Lead Maintainer +## Lead Maintainer [Hugo Dias](https://github.com/hugomrdias) +## ToC +- [Project Structure](#project-structure) +- [CI](#ci) + - [Travis Setup](#travis-setup) + - [Github Action Setup](#github-action-setup) +- [Stack Requirements](#stack-requirements) +- [Testing helpers](#testing-helpers) + - [Fixtures](#fixtures) + - [Echo Server](#echo-server) + - [Get Port](#get-port) +- [Tasks](#tasks) + - [Linting](#linting) + - [Testing](#testing) + - [Coverage](#coverage) + - [Node](#node) + - [Browser](#browser) + - [Building](#building) + - [Generating Webpack stats.json](#generating-webpack-statsjson) + - [Typescript](#typescript) + - [JSDoc Typescript support](#jsdoc-typescript-support) + - [Releasing](#releasing) + - [Scoped Github Token](#scoped-github-token) + - [Documentation](#documentation) +- [License](#license) ## Project Structure @@ -39,10 +62,12 @@ Your `package.json` should have the following entries and should pass `aegir lin "test:browser": "aegir test --target browser" } ``` -## Travis Setup + +## CI +### Travis Setup Check this tutorial https://github.com/ipfs/aegir/wiki/Travis-Setup -## Github Action Setup +### Github Action Setup Check this tutorial https://github.com/ipfs/aegir/wiki/Github-Actions-Setup ## Stack Requirements @@ -200,18 +225,15 @@ after_success: npx nyc report --reporter=text-lcov > coverage.lcov && npx codeco ``` ### Building - - -You can run it using +The build command builds a browser bundle and TS type declarations from the `src` folder. ```bash -$ aegir build +$ aegir build --help ``` This will build a browser ready version into `dist`, so after publishing the results will be available under ``` https://unpkg.com//dist/index.js -https://unpkg.com//dist/index.min.js ``` **Specifying a custom entry file for Webpack** @@ -235,6 +257,14 @@ Pass the `--analyze` option to have Webpack generate a `stats.json` file for the ```bash aegir build --analyze ``` +### Typescript + +#### JSDoc Typescript support +```bash +aegir ts --help +``` +The `ts` command provides type checking (via typescript) in javascript files with [JSDoc](https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html) annotations. + ### Releasing @@ -290,12 +320,10 @@ Be aware that by storing it in `~/.profile` or similar you will make it availabl ### Documentation -You can use `aegir docs` to generate documentation. This uses [documentation.js](http://documentation.js.org/) with the theme [clean-documentation-theme](https://github.com/dignifiedquire/clean-documentation-theme). - -To publish the documentation automatically to the `gh-pages` branch you can run +You can use `aegir docs` to generate documentation, this command uses `aegir ts --preset docs` internally. ```bash -$ aegir docs --publish +$ aegir docs --help ``` ## License diff --git a/cmds/build.js b/cmds/build.js index 55d02d7c2..24ef73419 100644 --- a/cmds/build.js +++ b/cmds/build.js @@ -1,11 +1,11 @@ 'use strict' const EPILOG = ` -This command outputs two bundles, one in development mode (index.js) and another in production mode (index.min.js) plus respective source-maps, files are written to ./dist folder. +Output files will go into a "./dist" folder. Supports options forwarding with '--' for more info check https://webpack.js.org/api/cli/ ` module.exports = { command: 'build', - desc: 'Builds browser bundles with Webpack.', + desc: 'Builds a browser bundle and TS type declarations from the `src` folder.', builder: (yargs) => { yargs .epilog(EPILOG) diff --git a/cmds/docs.js b/cmds/docs.js index 0a7a4bb74..7ec2bafcd 100644 --- a/cmds/docs.js +++ b/cmds/docs.js @@ -1,16 +1,17 @@ 'use strict' const EPILOG = ` -Supports options forwarding with '--' for more info check https://github.com/documentationjs/documentation/blob/master/docs/USAGE.md +Typescript config file is required to generated docs. Try \`aegir ts --preset config > tsconfig.json\` ` module.exports = { command: 'docs', - desc: 'Generate documentation from JSDoc.', + desc: 'Generate documentation from TS type declarations.', builder: yargs => { yargs .epilog(EPILOG) - .example('aegir docs -- --format md -o docs.md', 'Build markdown documentation.') + .example('aegir docs', 'Build HTML documentation.') + .example('aegir docs -p', 'Build HTML documentation and publish to Github Pages.') .options( { publish: { @@ -24,7 +25,6 @@ module.exports = { }, handler (argv) { const docs = require('../src/docs') - const onError = require('../src/error-handler') - docs.run(argv).catch(onError) + return docs.run(argv) } } diff --git a/cmds/ts.js b/cmds/ts.js new file mode 100644 index 000000000..ac1a03665 --- /dev/null +++ b/cmds/ts.js @@ -0,0 +1,46 @@ +'use strict' + +const EPILOG = ` +Presets: +\`check\` Runs the type checker with your local config (without writing any files). . +\`types\` Emits type declarations for \`['src/**/*', 'package.json']\` to \`dist\` folder. +\`docs\` Generates documentation based on type declarations to the \`docs\` folder. +\`config\` Prints base config to stdout. + +Note: +To provide users types declarations with 0-configuration add following to package.json: + +\`\`\`json +"typesVersions": { + "*": { "src/*": ["dist/src/*", "dist/src/*/index"] } +}, +\`\`\` + +Supports options forwarding with '--' for more info check https://www.typescriptlang.org/docs/handbook/compiler-options.html +` +module.exports = { + command: 'ts', + desc: 'Typescript command with presets for specific tasks.', + builder: (yargs) => { + yargs + .epilog(EPILOG) + .example('aegir ts --preset config > tsconfig.json', 'Add a base tsconfig.json to the current repo.') + .options({ + preset: { + type: 'string', + choices: ['config', 'check', 'types', 'docs'], + describe: 'Preset to run', + alias: 'p' + }, + include: { + type: 'array', + describe: 'Values are merged into the local TS config include property.', + default: [] + } + }) + }, + handler (argv) { + const ts = require('../src/ts') + return ts(argv) + } +} diff --git a/md/ts-jsdoc.md b/md/ts-jsdoc.md new file mode 100644 index 000000000..a77df9801 --- /dev/null +++ b/md/ts-jsdoc.md @@ -0,0 +1,105 @@ +# Documentation for JSDoc based TS types + +## Getting Started + +Add a `tsconfig.json` to your repo: +```bash +aegir ts -p config > tsconfig.json +``` + +Add types configuration to your package.json: +```json +"typesVersions": { + "*": { "src/*": ["dist/src/*", "dist/src/*/index"] } +}, +``` +`types` will tell `tsc` where to look for the entry point type declarations and `typeVersions` for every other files inside the `src` folder. + +> The `ts` command follows aegir folder conventions, source code inside `./src`, test inside `./test` and documentation inside `./docs`. + + +## CLI `ts` command + +Run `aegir ts --help` and check the help text. There's presets for common TS use cases. + +```md +Presets: +`check` Runs the type checker with your local config and doesn't not emit output. +`types` Emits type declarations for `['src/**/*', 'package.json']` to `dist` folder. +`docs` Generates documentation based on type declarations to the `docs` folder. +`config` Prints base config to stdout. +``` + + +## Adding types with JSDoc + +Typescript can infere lots of the types without any help, but you can improve your code types by using just JSDoc for that follow the official TS documentation https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html. + +### Rules for optimal type declarations and documentation + +This list is a WIP, more rules will be added as we identify them. + +#### 1. Commonjs default exports +When using `commonjs` modules, only use default exports when exporting a single `class`. + +```js +// GOOD + +class IPFS {} + +module.exports = IPFS + +// GOOD +IPFS.hash = ()=>{} + +module.exports = IPFS + +// BAD +function hash() {} + +module.exports = hash + +// REALLY BAD + +function hash() {} +function hash2() {} + +module.exports = hash +exports.hash2 = hash2 + + +``` + +#### 2. Commons js named exports +When using `commonjs` modules, always use named exports if you want to export multiple references. +```js +// GOOD +function hash() {} +function hash2() {} +class IPFS {} +module.exports = { + IPFS + hash, + hash2, + ... +} + +// BAD +exports.hash2 = hash2() {} +exports.hash = hash() {} +exports.IPFS = IPFS +``` + +#### 3. Use a `types.ts` file +When writing types sometimes JSDoc can be cumbersome, impossible, it can output weird type declarations or even broken documentation. Most of these problems can be solved by defining some complex types in typescript in a `types.ts` file. + +```ts +// types.ts +export type IntersectionType = Type1 & Type2 +``` + +```js +// index.js +/** @type { import('./types').IntersectionType } */ +const list +``` \ No newline at end of file diff --git a/package.json b/package.json index 9363ab253..8412f89d7 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,9 @@ "@commitlint/travis-cli": "^11.0.0", "@electron/get": "^1.10.0", "@polka/send-type": "^0.5.2", + "@types/mocha": "^8.0.4", + "@types/node": "^14.14.7", + "aegir-typedoc-theme": "^0.1.0", "babel-loader": "^8.0.5", "buffer": "^5.6.0", "bytes": "^3.1.0", @@ -106,13 +109,17 @@ "pascalcase": "^1.0.0", "pify": "^5.0.0", "polka": "^0.5.2", + "premove": "^3.0.1", "prompt-promise": "^1.0.3", "read-pkg-up": "^7.0.1", "rimraf": "^3.0.1", "semver": "^7.3.2", "simple-git": "^2.7.0", + "strip-bom": "^4.0.0", + "strip-json-comments": "^3.1.1", "terser-webpack-plugin": "^3.0.5", - "typescript": "^4.0.3", + "typedoc": "^0.19.2", + "typescript": "^4.0.5", "update-notifier": "^5.0.0", "webpack": "^4.43.0", "webpack-bundle-analyzer": "^3.7.0", diff --git a/src/build/index.js b/src/build/index.js index 2608295ca..28fb847e4 100644 --- a/src/build/index.js +++ b/src/build/index.js @@ -6,8 +6,9 @@ const fs = require('fs') const bytes = require('bytes') const execa = require('execa') const rimraf = require('rimraf') -const { fromAegir, gzipSize, pkg } = require('./../utils') +const { fromAegir, gzipSize, pkg, hasTsconfig } = require('./../utils') const userConfig = require('../config/user') +const tsCmd = require('../ts') const config = userConfig() @@ -41,15 +42,8 @@ module.exports = async (argv) => { stdio: 'inherit' }) - if (argv.ts) { - await execa('tsc', [ - '--outDir', './dist/src', - '--declaration' - ], { - localDir: path.join(__dirname, '../..'), - preferLocal: true, - stdio: 'inherit' - }) + if (hasTsconfig) { + await tsCmd({ preset: 'types' }) } if (argv.bundlesize) { diff --git a/src/clean.js b/src/clean.js deleted file mode 100644 index 93a7829cf..000000000 --- a/src/clean.js +++ /dev/null @@ -1,17 +0,0 @@ -'use strict' - -const rimraf = require('rimraf') -const path = require('path') - -function clean (dir) { - return new Promise((resolve, reject) => { - rimraf(path.join(process.cwd(), dir), (err) => { - if (err) { - return reject(err) - } - resolve() - }) - }) -} - -module.exports = clean diff --git a/src/config/tsconfig.aegir.json b/src/config/tsconfig.aegir.json new file mode 100644 index 000000000..371b3150d --- /dev/null +++ b/src/config/tsconfig.aegir.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": false, + "noImplicitAny": false, + "noImplicitThis": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "strictBindCallApply": true, + "strict": true, + "skipLibCheck": true, + "alwaysStrict": true, + "esModuleInterop": true, + "stripInternal": true, + "resolveJsonModule": true, + "preserveConstEnums": true, + "removeComments": false, + "target": "ES2018", + "moduleResolution": "node", + "lib": ["ES2018", "DOM"], + "noEmitOnError": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "incremental": true, + "composite": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/src/docs/build.js b/src/docs/build.js deleted file mode 100644 index eb06f0cd7..000000000 --- a/src/docs/build.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict' - -const path = require('path') -const execa = require('execa') -const merge = require('merge-options') -const { fromRoot, hasFile } = require('../utils') - -/** @typedef { import("execa").Options} ExecaOptions */ -/** @typedef { import("execa").ExecaChildProcess} ExecaChildProcess */ - -/** - * Build docs - * - * @param {object} argv - Command line arguments passed to the process. - * @param {ExecaOptions} execaOptions - execa options. - * @returns {ExecaChildProcess} - Child process that builds docs. - */ -const docs = (argv, execaOptions = {}) => { - const forwardOptions = argv['--'] ? argv['--'] : [] - const format = forwardOptions.some(v => ['--format', '-f'].includes(v)) ? [] : ['--format', 'html'] - const output = forwardOptions.some(v => ['--output', '-o'].includes(v)) ? [] : ['--output', fromRoot('docs')] - const config = hasFile('documentation.yml') ? ['--config', fromRoot('documentation.yml')] : [] - return execa('documentation', - [ - 'build', - fromRoot('src/index.js'), - ...format, - ...output, - ...config, - '--github', - '--resolve', 'node', - ...forwardOptions - ], - merge( - { - localDir: path.join(__dirname, '..'), - preferLocal: true - }, - execaOptions - ) - ) -} - -module.exports = docs diff --git a/src/docs/index.js b/src/docs/index.js index 8633605a4..17bb9ca25 100644 --- a/src/docs/index.js +++ b/src/docs/index.js @@ -1,22 +1,36 @@ 'use strict' const Listr = require('listr') +const chalk = require('chalk') +const { premove: remove } = require('premove') +const { getListrConfig, publishDocs, hasTsconfig } = require('../utils') +const tsCmd = require('../ts') -const utils = require('../utils') -const clean = require('../clean') -const publish = require('./publish') -const build = require('./build') +const TASKS = new Listr( + [ + { + title: 'Clean ./docs', + task: () => remove('docs') + }, + { + title: 'Generating documentation', + task: () => { + if (!hasTsconfig) { + // eslint-disable-next-line no-console + console.error(chalk.yellow('Documentation requires typescript config.\nTry running `aegir ts --preset config > tsconfig.json`')) + return + } -const TASKS = new Listr([{ - title: 'Clean ./docs', - task: () => clean('docs') -}, { - title: 'Generating documentation', - task: (ctx) => build(ctx) -}, { - title: 'Publish to GitHub Pages', - task: publish, - enabled: (ctx) => ctx.publish -}], utils.getListrConfig()) + return tsCmd({ preset: 'docs' }) + } + }, + { + title: 'Publish to GitHub Pages', + task: publishDocs, + enabled: (ctx) => ctx.publish && hasTsconfig + } + ], + getListrConfig() +) module.exports = TASKS diff --git a/src/docs/publish.js b/src/docs/publish.js deleted file mode 100644 index 45f587aad..000000000 --- a/src/docs/publish.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict' - -const ghPages = require('gh-pages') -const pify = require('pify') -const os = require('os') -const path = require('path') -const { pkg } = require('../utils') - -function publish (ctx) { - return pify(ghPages.publish.bind(ghPages))( - 'docs', - { - message: 'chore: update documentation', - clone: path.join(os.tmpdir(), 'aegir-gh-pages-cache', pkg.name) - } - ) -} - -module.exports = publish diff --git a/src/ts/index.js b/src/ts/index.js new file mode 100644 index 000000000..904d1a474 --- /dev/null +++ b/src/ts/index.js @@ -0,0 +1,178 @@ +'use strict' + +const path = require('path') +const execa = require('execa') +const fs = require('fs-extra') +const globby = require('globby') +const merge = require('merge-options') +const { fromRoot, fromAegir, hasFile, readJson } = require('../utils') +const hasConfig = hasFile('tsconfig.json') +let userConfig = null + +module.exports = async (argv) => { + const forwardOptions = argv['--'] ? argv['--'] : [] + const extraInclude = (argv.include && argv.include.length > 0) ? await globby(argv.include) : [] + + if (argv.preset === 'config') { + const extendsConfig = `{ + "extends": "./${path.relative( + process.cwd(), + require.resolve('aegir/src/config/tsconfig.aegir.json') + )}", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "test", // remove this line if you don't want to type-check tests + "src" + ] +}` + // eslint-disable-next-line no-console + console.log(extendsConfig) + + return + } + + if (!hasConfig) { + throw new Error( + 'TS config not found. Try running `aegir ts --preset config > tsconfig.json`' + ) + } + userConfig = readJson(fromRoot('tsconfig.json')) + + if (argv.preset === 'check') { + return check(forwardOptions, extraInclude) + } + + if (argv.preset === 'types') { + return types(forwardOptions, extraInclude) + } + + if (argv.preset === 'docs') { + return docs(forwardOptions, extraInclude) + } + + if (!argv.preset) { + return execa('tsc', ['--build', ...forwardOptions], { + localDir: path.join(__dirname, '../..'), + preferLocal: true, + stdio: 'inherit' + }) + } +} + +const check = async (forwardOptions, extraInclude) => { + const configPath = fromRoot('tsconfig-check.aegir.json') + try { + fs.writeJsonSync( + configPath, + merge.apply({ concatArrays: true }, [ + userConfig, + { + compilerOptions: { + noEmit: true, + emitDeclarationOnly: false + }, + include: extraInclude + } + ]) + ) + await execa('tsc', ['--build', configPath, ...forwardOptions], { + localDir: path.join(__dirname, '../..'), + preferLocal: true, + stdio: 'inherit' + }) + } finally { + fs.removeSync(configPath) + } +} + +const types = async (forwardOptions, extraInclude) => { + const configPath = fromRoot('tsconfig-types.aegir.json') + try { + fs.writeJsonSync( + configPath, + merge(userConfig, { + compilerOptions: { + noEmit: false, + emitDeclarationOnly: true + }, + include: ['src', 'package.json', ...extraInclude] + }) + ) + await execa('tsc', ['--build', configPath, ...forwardOptions], { + localDir: path.join(__dirname, '../..'), + preferLocal: true, + stdio: 'inherit' + }) + } finally { + fs.removeSync(configPath) + } +} + +const docs = async (forwardOptions, extraInclude) => { + const configPath = fromRoot('tsconfig-docs.aegir.json') + try { + fs.writeJsonSync( + configPath, + merge(userConfig, { + compilerOptions: { + noEmit: false, + emitDeclarationOnly: true, + outDir: 'types' + }, + include: ['src/**/*', 'package.json', ...extraInclude] + }) + ) + + // run tsc + await execa('tsc', ['-b', configPath, ...forwardOptions], { + localDir: path.join(__dirname, '../..'), + preferLocal: true, + stdio: 'inherit' + }) + + // run typedoc + await execa( + 'typedoc', + [ + '--inputfiles', + fromRoot('types', 'src'), + '--mode', + 'modules', + '--out', + 'docs', + '--excludeExternals', + // '--excludeNotDocumented', + // '--excludeNotExported', + '--excludePrivate', + '--excludeProtected', + '--includeDeclarations', + '--hideGenerator', + '--includeVersion', + '--gitRevision', + 'master', + '--disableSources', + '--tsconfig', + configPath, + '--plugin', + fromAegir('src/ts/typedoc-plugin.js'), + '--theme', + fromAegir( + './../../node_modules/aegir-typedoc-theme/bin/default' + ) + ], + { + localDir: path.join(__dirname, '..'), + preferLocal: true, + stdio: 'inherit' + } + ) + + // write .nojekyll file + fs.writeFileSync('docs/.nojekyll', '') + } finally { + fs.removeSync(configPath) + fs.removeSync(fromRoot('types')) + } +} diff --git a/src/ts/typedoc-plugin.js b/src/ts/typedoc-plugin.js new file mode 100644 index 000000000..6c073df76 --- /dev/null +++ b/src/ts/typedoc-plugin.js @@ -0,0 +1,35 @@ +'use strict' +const { Converter } = require('typedoc/dist/lib/converter') +const path = require('path') +const fs = require('fs') + +module.exports = function (PluginHost) { + const app = PluginHost.owner + const pkg = path.join(process.cwd(), 'package.json') + let pkgJson + let main + try { + pkgJson = JSON.parse(fs.readFileSync(pkg).toString()) + main = path.join(process.cwd(), pkgJson.main) + } catch (err) { + throw new Error('cant find package.json') + } + + app.converter.on(Converter.EVENT_CREATE_DECLARATION, (context, reflection, node) => { + if (reflection.kind === 1 && node) { + // entry point + if (pkgJson && reflection.name === main) { + reflection.name = '\u0000' + pkgJson.name.charAt(0).toUpperCase() + pkgJson.name.slice(1) + // reflection.kind = 2 + } + + if (pkgJson && reflection.name.includes('types/src/index.d.ts')) { + reflection.name = '\u0000' + pkgJson.name.charAt(0) + pkgJson.name.slice(1) + } + + if (pkgJson && reflection.name.includes('.d.ts')) { + reflection.name = reflection.name.replace('.d.ts', '.js') + } + } + }) +} diff --git a/src/utils.js b/src/utils.js index d9c3b2c74..b455a2481 100644 --- a/src/utils.js +++ b/src/utils.js @@ -8,6 +8,8 @@ const { constants, createBrotliCompress, createGzip } = require('zlib') const os = require('os') const ora = require('ora') const extract = require('extract-zip') +const stripComments = require('strip-json-comments') +const stripBom = require('strip-bom') const { download } = require('@electron/get') const path = require('path') const findUp = require('findup-sync') @@ -15,6 +17,10 @@ const readPkgUp = require('read-pkg-up') const fs = require('fs-extra') const execa = require('execa') const pascalcase = require('pascalcase') +const ghPages = require('gh-pages') +const { promisify } = require('util') + +const publishPages = promisify(ghPages.publish) const { packageJson: pkg, path: pkgPath } = readPkgUp.sync({ cwd: fs.realpathSync(process.cwd()) @@ -30,11 +36,36 @@ exports.paths = { exports.pkg = pkg // TODO: get this from aegir package.json exports.browserslist = '>1% or node >=10 and not ie 11 and not dead' - exports.repoDirectory = path.dirname(pkgPath) exports.fromRoot = (...p) => path.join(exports.repoDirectory, ...p) exports.hasFile = (...p) => fs.existsSync(exports.fromRoot(...p)) exports.fromAegir = (...p) => path.join(__dirname, '..', ...p) +exports.hasTsconfig = exports.hasFile('tsconfig.json') + +exports.parseJson = (contents) => { + const data = stripComments(stripBom(contents)) + + // A tsconfig.json file is permitted to be completely empty. + if (/^\s*$/.test(data)) { + return {} + } + + return JSON.parse(data) +} + +exports.readJson = (filePath) => { + return exports.parseJson(fs.readFileSync(filePath, { encoding: 'utf-8' })) +} + +exports.publishDocs = () => { + return publishPages( + 'docs', + { + dotfiles: true, + message: 'chore: update documentation' + } + ) +} /** * Get package version