├── .remarkignore ├── .prettierignore ├── test ├── fixture │ ├── 02-input.md │ ├── 02-output.md │ ├── 03-input.md │ ├── 00-input.md │ ├── 01-input.md │ ├── 04-input.md │ ├── 05-input.md │ ├── 06-input.md │ ├── 07-input.md │ ├── 08-input.md │ ├── 09-input.md │ ├── 10-input.md │ ├── contributors-throwing.js │ ├── 00-output.md │ ├── 07-output.md │ ├── contributors-invalid-exports.js │ ├── 03-output.md │ ├── contributors-string.js │ ├── 10-output.md │ ├── contributors-named.js │ ├── 06-output.md │ ├── contributors-duplicates.js │ ├── contributors-main.js │ ├── 09-output.md │ ├── 04-output.md │ ├── 05-output.md │ ├── 08-output.md │ └── 01-output.md └── index.js ├── .npmrc ├── .gitignore ├── .editorconfig ├── index.js ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── tsconfig.json ├── lib ├── formatters.js └── index.js ├── license ├── package.json └── readme.md /.remarkignore: -------------------------------------------------------------------------------- 1 | /test/fixture/ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /test/fixture/02-input.md: -------------------------------------------------------------------------------- 1 | # Readme 2 | -------------------------------------------------------------------------------- /test/fixture/02-output.md: -------------------------------------------------------------------------------- 1 | # Readme 2 | -------------------------------------------------------------------------------- /test/fixture/03-input.md: -------------------------------------------------------------------------------- 1 | # Readme 2 | -------------------------------------------------------------------------------- /test/fixture/00-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/01-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/04-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/05-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/06-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/07-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/08-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/09-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /test/fixture/10-input.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /test/fixture/contributors-throwing.js: -------------------------------------------------------------------------------- 1 | throw new Error('Some error!') 2 | -------------------------------------------------------------------------------- /test/fixture/00-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | 4 | | :------- | 5 | | **test** | 6 | -------------------------------------------------------------------------------- /test/fixture/07-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | 4 | | :-------- | 5 | | **Alpha** | 6 | -------------------------------------------------------------------------------- /test/fixture/contributors-invalid-exports.js: -------------------------------------------------------------------------------- 1 | const contributors = false 2 | 3 | export default contributors 4 | -------------------------------------------------------------------------------- /test/fixture/03-output.md: -------------------------------------------------------------------------------- 1 | # Readme 2 | 3 | ## Contributors 4 | 5 | | Name | 6 | | :------- | 7 | | **test** | 8 | -------------------------------------------------------------------------------- /test/fixture/contributors-string.js: -------------------------------------------------------------------------------- 1 | const contributors = ['test '] 2 | 3 | export default contributors 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | .DS_Store 4 | *.d.ts.map 5 | *.d.ts 6 | *.log 7 | *.tsbuildinfo 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /test/fixture/10-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | 4 | | :---- | 5 | | **z** | 6 | | **a** | 7 | | **B** | 8 | | **y** | 9 | -------------------------------------------------------------------------------- /test/fixture/contributors-named.js: -------------------------------------------------------------------------------- 1 | export const contributors = [ 2 | {email: 'test@localhost', github: 'test', twitter: 'test'} 3 | ] 4 | -------------------------------------------------------------------------------- /test/fixture/06-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | 4 | | :---------- | 5 | | **Alpha** | 6 | | **Bravo** | 7 | | **Charlie** | 8 | -------------------------------------------------------------------------------- /test/fixture/contributors-duplicates.js: -------------------------------------------------------------------------------- 1 | const contributors = ['test ', 'test two '] 2 | 3 | export default contributors 4 | -------------------------------------------------------------------------------- /test/fixture/contributors-main.js: -------------------------------------------------------------------------------- 1 | const contributors = [ 2 | {email: 'test@localhost', github: 'test', twitter: 'test'} 3 | ] 4 | 5 | export default contributors 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /test/fixture/09-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | GitHub | 4 | | :------- | :----------------------------------- | 5 | | **test** | [**@test**](https://github.com/test) | 6 | -------------------------------------------------------------------------------- /test/fixture/04-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | GitHub | 4 | | :------- | :--------------------------------------- | 5 | | **test** | [**@wooorm**](https://github.com/wooorm) | 6 | -------------------------------------------------------------------------------- /test/fixture/05-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | Social | 4 | | :------- | :--------------------------------------- | 5 | | **test** | [**@foo@bar.com**](https://bar.com/@foo) | 6 | -------------------------------------------------------------------------------- /test/fixture/08-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | Social | 4 | | :---------------- | :-------------------------------------- | 5 | | **One more name** | [**@a@twitter**](https://twitter.com/a) | 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('remark-contributors').Contributor} Contributor 3 | * @typedef {import('./lib/index.js').Filter} Filter 4 | * @typedef {import('./lib/index.js').Options} Options 5 | */ 6 | 7 | export {defaultFilter, default} from './lib/index.js' 8 | -------------------------------------------------------------------------------- /test/fixture/01-output.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | | Name | GitHub | Social | 4 | | :------- | :----------------------------------- | :-------------------------------------------- | 5 | | **test** | [**@test**](https://github.com/test) | [**@test@twitter**](https://twitter.com/test) | 6 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | name: bb 2 | on: 3 | issues: 4 | types: [opened, reopened, edited, closed, labeled, unlabeled] 5 | pull_request_target: 6 | types: [opened, reopened, edited, closed, labeled, unlabeled] 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: unifiedjs/beep-boop-beta@main 12 | with: 13 | repo-token: ${{secrets.GITHUB_TOKEN}} 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declarationMap": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "lib": ["es2022"], 10 | "module": "node16", 11 | "strict": true, 12 | "target": "es2022" 13 | }, 14 | "exclude": ["coverage/", "node_modules/"], 15 | "include": ["**/*.js"] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | name: '${{matrix.node}} on ${{matrix.os}}' 4 | runs-on: ${{matrix.os}} 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-node@v4 8 | with: 9 | node-version: ${{matrix.node}} 10 | - run: npm install 11 | - run: npm test 12 | - uses: codecov/codecov-action@v4 13 | strategy: 14 | matrix: 15 | node: 16 | - lts/hydrogen 17 | - node 18 | os: 19 | - ubuntu-latest 20 | - windows-latest 21 | name: main 22 | on: 23 | - pull_request 24 | - push 25 | -------------------------------------------------------------------------------- /lib/formatters.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {FormatterObject} from 'remark-contributors' 3 | * @import {Social} from './index.js' 4 | */ 5 | 6 | /** 7 | * Default formatters. 8 | * 9 | * @type {Readonly>>} 10 | */ 11 | export const defaultFormatters = { 12 | email: {exclude: true}, 13 | commits: {exclude: true}, 14 | social: { 15 | format(value) { 16 | const object = /** @type {Social | undefined} */ (value) 17 | 18 | /* c8 ignore next 3 -- shouldn’t happen, but let’s keep it here just to be sure. */ 19 | if (!object) { 20 | return '' 21 | } 22 | 23 | return { 24 | type: 'link', 25 | url: object.url, 26 | children: [ 27 | {type: 'strong', children: [{type: 'text', value: object.text}]} 28 | ] 29 | } 30 | }, 31 | label: 'Social' 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Vincent Weevers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remark-git-contributors", 3 | "version": "5.1.0", 4 | "description": "remark plugin to generate a list of Git contributors", 5 | "license": "MIT", 6 | "keywords": [ 7 | "contributors", 8 | "github", 9 | "inject", 10 | "markdown", 11 | "mdast", 12 | "plugin", 13 | "remark", 14 | "remark-plugin", 15 | "unified" 16 | ], 17 | "repository": "remarkjs/remark-git-contributors", 18 | "bugs": "https://github.com/remarkjs/remark-git-contributors/issues", 19 | "funding": { 20 | "type": "opencollective", 21 | "url": "https://opencollective.com/unified" 22 | }, 23 | "author": { 24 | "name": "Vincent Weevers", 25 | "email": "mail@vincentweevers.nl", 26 | "github": "vweevers", 27 | "twitter": "vweevers" 28 | }, 29 | "contributors": [ 30 | { 31 | "name": "Vincent Weevers", 32 | "email": "mail@vincentweevers.nl", 33 | "github": "vweevers", 34 | "twitter": "vweevers" 35 | }, 36 | { 37 | "name": "Titus Wormer", 38 | "email": "tituswormer@gmail.com", 39 | "github": "wooorm", 40 | "twitter": "wooorm" 41 | } 42 | ], 43 | "sideEffects": false, 44 | "type": "module", 45 | "exports": "./index.js", 46 | "files": [ 47 | "lib/", 48 | "index.d.ts.map", 49 | "index.d.ts", 50 | "index.js" 51 | ], 52 | "dependencies": { 53 | "@types/mdast": "^4.0.0", 54 | "contributors-from-git": "^1.0.0", 55 | "dlv": "^1.0.0", 56 | "load-plugin": "^6.0.0", 57 | "mdast-util-heading-range": "^4.0.0", 58 | "parse-author": "^2.0.0", 59 | "remark-contributors": "^7.0.0", 60 | "vfile": "^6.0.0", 61 | "vfile-find-up": "^7.0.0", 62 | "vfile-message": "^4.0.0" 63 | }, 64 | "devDependencies": { 65 | "@types/dlv": "^1.0.0", 66 | "@types/node": "^22.0.0", 67 | "@types/parse-author": "^2.0.0", 68 | "@types/semver": "^7.0.0", 69 | "c8": "^10.0.0", 70 | "prettier": "^3.0.0", 71 | "remark": "^15.0.0", 72 | "remark-cli": "^12.0.0", 73 | "remark-gfm": "^4.0.0", 74 | "remark-preset-wooorm": "^10.0.0", 75 | "semver": "^7.0.0", 76 | "tmpgen": "^1.0.0", 77 | "type-coverage": "^2.0.0", 78 | "type-fest": "^4.0.0", 79 | "typescript": "^5.0.0", 80 | "xo": "^0.59.0" 81 | }, 82 | "scripts": { 83 | "build": "tsc --build --clean && tsc --build && type-coverage", 84 | "format": "remark . --frail --output --quiet && prettier . --log-level warn --write && xo --fix", 85 | "prepack": "npm run build && npm run format", 86 | "test": "npm run build && npm run format && npm run test-coverage", 87 | "test-api": "node --conditions development test/index.js", 88 | "test-coverage": "c8 --100 --reporter lcov npm run test-api" 89 | }, 90 | "prettier": { 91 | "bracketSpacing": false, 92 | "singleQuote": true, 93 | "semi": false, 94 | "tabWidth": 2, 95 | "trailingComma": "none", 96 | "useTabs": false 97 | }, 98 | "remarkConfig": { 99 | "plugins": [ 100 | "remark-preset-wooorm", 101 | "./index.js" 102 | ] 103 | }, 104 | "typeCoverage": { 105 | "atLeast": 100, 106 | "detail": true, 107 | "ignoreCatch": true, 108 | "strict": true 109 | }, 110 | "xo": { 111 | "overrides": [ 112 | { 113 | "files": [ 114 | "test/**/*.js" 115 | ], 116 | "rules": { 117 | "no-await-in-loop": "off" 118 | } 119 | } 120 | ], 121 | "prettier": true, 122 | "rules": { 123 | "unicorn/prefer-string-replace-all": "off" 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Root} from 'mdast' 3 | * @import {ContributorObject, Contributor} from 'remark-contributors' 4 | * @import {PackageJson} from 'type-fest' 5 | * @import {VFile} from 'vfile' 6 | */ 7 | 8 | /** 9 | * @typedef CleanContributor 10 | * Contributor with cleaned up data. 11 | * @property {number} commits 12 | * Number of commits. 13 | * @property {string} email 14 | * Email. 15 | * @property {string | undefined} github 16 | * GitHub username. 17 | * @property {string} name 18 | * Name. 19 | * @property {Social | undefined} social 20 | * Social profile. 21 | * 22 | * @typedef ContributorsModule 23 | * Contributors module. 24 | * @property {Array | null | undefined} [contributors] 25 | * Named export. 26 | * @property {Array | null | undefined} [default] 27 | * Default export. 28 | * 29 | * @callback Filter 30 | * Filter contributors. 31 | * @param {RawContributor} contributor 32 | * Contributor found by `contributorsFromGit`. 33 | * @param {Record} metadata 34 | * Associated metadata found in `package.json` or `options.contributors`. 35 | * @returns {boolean} 36 | * Whether to include the contributor. 37 | * 38 | * @typedef Options 39 | * Configuration. 40 | * @property {boolean | null | undefined} [appendIfMissing=false] 41 | * Inject the section if there is none (default: `false`). 42 | * @property {Array | string | null | undefined} [contributors] 43 | * List of contributors to inject (optional); 44 | * defaults to the `contributors` field in the closest `package.json` upwards 45 | * from the processed file, if there is one; 46 | * supports the string form (`name (url)`) as well; 47 | * throws if no contributors are found or given. 48 | * @property {string | null | undefined} [cwd] 49 | * Working directory from which to resolve a `contributors` module, if any 50 | * (default: `file.cwd`). 51 | * @property {Filter | null | undefined} [filter=defaultFilter] 52 | * Filter contributors (default: `defaultFilter`). 53 | * @property {number | null | undefined} [limit=0] 54 | * Limit the rendered contributors (default: `0`); 55 | * `0` (or lower) includes all contributors; 56 | * if `limit` is given, only the top `` contributors, sorted by commit 57 | * count, are rendered. 58 | * 59 | * @typedef RawContributor 60 | * Contributor found by `contributorsFromGit`. 61 | * @property {number} commits 62 | * Number of commits. 63 | * @property {string} email 64 | * Email. 65 | * @property {string} name 66 | * Name. 67 | * 68 | * @typedef Social 69 | * Social profile. 70 | * @property {string} text 71 | * Text. 72 | * @property {string} url 73 | * URL. 74 | */ 75 | 76 | import fs from 'node:fs/promises' 77 | import path from 'node:path' 78 | import process from 'node:process' 79 | import {pathToFileURL} from 'node:url' 80 | // @ts-expect-error: untyped. 81 | import contributorsFromGit from 'contributors-from-git' 82 | import dlv from 'dlv' 83 | import {loadPlugin} from 'load-plugin' 84 | import {headingRange} from 'mdast-util-heading-range' 85 | import parseAuthor from 'parse-author' 86 | import remarkContributors from 'remark-contributors' 87 | import {findUp} from 'vfile-find-up' 88 | import {VFileMessage} from 'vfile-message' 89 | import {defaultFormatters} from './formatters.js' 90 | 91 | const noreply = '@users.noreply.github.com' 92 | const headingExpression = /^contributors$/i 93 | const idFields = ['email', 'name', 'github', 'social.url'] 94 | 95 | /** 96 | * Default filter for contributors; 97 | * currently filters out Greenkeeper. 98 | * 99 | * @param {RawContributor} contributor 100 | * Contributor found by `contributorsFromGit`. 101 | * @param {Record} metadata 102 | * Associated metadata found in `package.json` or `options.contributors`. 103 | * @returns {boolean} 104 | * Whether to include the contributor. 105 | * @satisfies {Filter} 106 | */ 107 | export function defaultFilter(contributor, metadata) { 108 | return ( 109 | !contributor.email.endsWith('@greenkeeper.io') && 110 | contributor.name.toLowerCase() !== 'greenkeeper' && 111 | metadata.github !== 'greenkeeper[bot]' && 112 | metadata.github !== 'greenkeeperio-bot' 113 | ) 114 | } 115 | 116 | /** 117 | * Generate a list of Git contributors. 118 | * 119 | * In short, this plugin: 120 | * 121 | * * looks for the first heading matching `/^contributors$/i` 122 | * * if no heading is found and `appendIfMissing` is set, injects such a heading 123 | * * if there is a heading, replaces everything in that section with a new table 124 | * with Git contributors 125 | * 126 | * @param {Readonly | string | null | undefined} [options] 127 | * Configuration (optional); 128 | * passing `string` is as if passing `options.contributors`. 129 | * @returns 130 | * Transform. 131 | */ 132 | export default function remarkGitContributors(options) { 133 | const settings = 134 | typeof options === 'string' ? {contributors: options} : options || {} 135 | const filter = settings.filter || defaultFilter 136 | 137 | /** 138 | * Transform. 139 | * 140 | * @param {Root} tree 141 | * Tree. 142 | * @param {VFile} file 143 | * File. 144 | * @returns {Promise} 145 | * Nothing. 146 | */ 147 | return async function (tree, file) { 148 | // Skip work if there’s no Contributors heading. 149 | // remark-contributors also does this so this is an optimization. 150 | if (!settings.appendIfMissing) { 151 | let found = false 152 | 153 | headingRange(tree, headingExpression, function () { 154 | found = true 155 | }) 156 | 157 | if (!found) return 158 | } 159 | 160 | const cwd = path.resolve(settings.cwd || file.cwd) 161 | /* c8 ignore next -- verbose to test. */ 162 | const base = file.dirname ? path.resolve(cwd, file.dirname) : cwd 163 | const indices = await indexContributors(cwd, settings.contributors) 164 | const packageFile = await findUp('package.json', base) 165 | /** @type {PackageJson} */ 166 | let packageData = {} 167 | 168 | if (packageFile) { 169 | packageData = JSON.parse(String(await fs.readFile(packageFile.path))) 170 | } 171 | 172 | // Cast because objects are indexable. 173 | indexContributor(indices, /** @type {Contributor} */ (packageData.author)) 174 | 175 | if (Array.isArray(packageData.contributors)) { 176 | for (const contributor of packageData.contributors) { 177 | indexContributor(indices, contributor) 178 | } 179 | } 180 | 181 | await new Promise(function (resolve, reject) { 182 | contributorsFromGit(cwd, ongitcontributors) 183 | 184 | /** 185 | * @param {Error | null} error 186 | * Error. 187 | * @param {Array} gitContributors 188 | * Contributors. 189 | */ 190 | // eslint-disable-next-line complexity 191 | function ongitcontributors(error, gitContributors) { 192 | if (error) { 193 | if (/does not have any commits yet/.test(String(error))) { 194 | file.message('Unexpected empty Git history, expected commits', { 195 | cause: error, 196 | ruleId: 'no-commits', 197 | source: 'remark-git-contributors' 198 | }) 199 | resolve(undefined) 200 | return 201 | } 202 | 203 | reject( 204 | new VFileMessage('Cannot get Git contributors', { 205 | cause: error, 206 | ruleId: 'git-exception', 207 | source: 'remark-git-contributors' 208 | }) 209 | ) 210 | return 211 | } 212 | 213 | /** @type {Map>} */ 214 | const idFieldToMap = new Map() 215 | 216 | /** @type {Array} */ 217 | let contributors = [] 218 | 219 | for (const gitContributor of gitContributors) { 220 | const {name, email, commits} = gitContributor 221 | 222 | if (!email) { 223 | file.message( 224 | 'Unexpected missing email for contributor `' + name + '`', 225 | { 226 | ruleId: 'contributor-email-missing', 227 | source: 'remark-git-contributors' 228 | } 229 | ) 230 | continue 231 | } 232 | 233 | const nameLower = name.toLowerCase() 234 | 235 | const metadata = { 236 | ...(indices.email[email] || indices.name[nameLower]) 237 | } 238 | 239 | if (email.endsWith(noreply)) { 240 | metadata.github = email 241 | .slice(0, -noreply.length) 242 | .replace(/^\d+\+/, '') 243 | indexValue(indices.github, metadata.github, metadata) 244 | } 245 | 246 | if (!filter(gitContributor, metadata)) { 247 | continue 248 | } 249 | 250 | /** @type {Social | undefined} */ 251 | let social 252 | 253 | if (metadata.twitter) { 254 | const handle = ( 255 | String(metadata.twitter).split(/@|\//).pop() || '' 256 | ).trim() 257 | 258 | if (handle) { 259 | social = { 260 | text: '@' + handle + '@twitter', 261 | url: 'https://twitter.com/' + handle 262 | } 263 | } else { 264 | file.message( 265 | 'Unexpected invalid Twitter handle `' + 266 | metadata.twitter + 267 | '` for contributor `' + 268 | email + 269 | '`', 270 | { 271 | ruleId: 'contributor-twitter-invalid', 272 | source: 'remark-git-contributors' 273 | } 274 | ) 275 | } 276 | } else if (metadata.mastodon) { 277 | const array = String(metadata.mastodon).split('@').filter(Boolean) 278 | const handle = array[0] 279 | const domain = array[1] 280 | 281 | if (handle && domain) { 282 | social = { 283 | url: 'https://' + domain + '/@' + handle, 284 | text: '@' + handle + '@' + domain 285 | } 286 | } else { 287 | file.message( 288 | 'Unexpected invalid Mastodon handle `' + 289 | metadata.mastodon + 290 | '` for contributor `' + 291 | email + 292 | '`', 293 | { 294 | ruleId: 'contributor-mastodon-invalid', 295 | source: 'remark-git-contributors' 296 | } 297 | ) 298 | } 299 | } else { 300 | file.info( 301 | 'Unexpected missing social handle for contributor `' + 302 | email + 303 | '`', 304 | { 305 | ruleId: 'contributor-social-missing', 306 | source: 'remark-git-contributors' 307 | } 308 | ) 309 | } 310 | 311 | /** @type {CleanContributor} */ 312 | const contributor = { 313 | email, 314 | commits, 315 | name: String(metadata.name || name), 316 | github: String(metadata.github || '') || undefined, 317 | social 318 | } 319 | // Whether an existing contributor was found that matched an id field. 320 | let found = false 321 | 322 | for (const idField of idFields) { 323 | /** @type {unknown} */ 324 | const id = dlv(contributor, idField) 325 | 326 | if (typeof id === 'string') { 327 | let idToContributor = idFieldToMap.get(idField) 328 | 329 | if (!idToContributor) { 330 | idToContributor = new Map() 331 | idFieldToMap.set(idField, idToContributor) 332 | } 333 | 334 | const existingContributor = idToContributor.get(id) 335 | 336 | if (existingContributor) { 337 | existingContributor.commits += contributor.commits 338 | found = true 339 | break 340 | } 341 | 342 | idToContributor.set(id, contributor) 343 | } 344 | } 345 | 346 | if (!found) { 347 | contributors.push(contributor) 348 | } 349 | } 350 | 351 | contributors.sort(function (a, b) { 352 | return b.commits - a.commits || a.name.localeCompare(b.name) 353 | }) 354 | 355 | if (settings.limit && settings.limit > 0) { 356 | contributors = contributors.slice(0, settings.limit) 357 | } 358 | 359 | const formatters = {...defaultFormatters} 360 | 361 | // Exclude GitHub column if all cells would be empty 362 | if ( 363 | contributors.every(function (c) { 364 | return !c.github 365 | }) 366 | ) { 367 | formatters.github = {exclude: true} 368 | } 369 | 370 | // Exclude Social column if all cells would be empty 371 | if ( 372 | contributors.every(function (c) { 373 | return !c.social 374 | }) 375 | ) { 376 | formatters.social = {exclude: true} 377 | } 378 | 379 | const transform = remarkContributors({ 380 | contributors, 381 | formatters, 382 | appendIfMissing: settings.appendIfMissing, 383 | align: 'left' 384 | }) 385 | 386 | transform(tree, file).then(resolve, reject) 387 | } 388 | }) 389 | } 390 | } 391 | 392 | /** 393 | * @param {string} cwd 394 | * Working directory. 395 | * @param {Array> | string | null | undefined} contributors 396 | * List of contributors to index. 397 | * @returns {Promise>>} 398 | * Indices. 399 | */ 400 | async function indexContributors(cwd, contributors) { 401 | /** @type {Record>} */ 402 | const indices = {email: {}, github: {}, name: {}} 403 | 404 | if (contributors === null || contributors === undefined) { 405 | return indices 406 | } 407 | 408 | if (typeof contributors === 'string') { 409 | const exported = /** @type {ContributorsModule} */ ( 410 | await loadPlugin(contributors, { 411 | from: [pathToFileURL(cwd) + '/', pathToFileURL(process.cwd()) + '/'], 412 | key: false 413 | }) 414 | ) 415 | 416 | if (Array.isArray(exported.contributors)) { 417 | contributors = exported.contributors 418 | } else if (Array.isArray(exported.default)) { 419 | contributors = exported.default 420 | } 421 | } 422 | 423 | if (!Array.isArray(contributors)) { 424 | throw new VFileMessage('Unexpected missing contributors') 425 | } 426 | 427 | for (const contributor of contributors) { 428 | indexContributor(indices, contributor) 429 | } 430 | 431 | return indices 432 | } 433 | 434 | /** 435 | * @param {Record>} indices 436 | * Indices. 437 | * @param {Contributor} contributor 438 | * Contributor. 439 | * @returns {undefined} 440 | * Nothing. 441 | */ 442 | function indexContributor(indices, contributor) { 443 | const contributorObject = 444 | typeof contributor === 'string' 445 | ? // Cast because objects are indexable. 446 | /** @type {ContributorObject} */ (parseAuthor(contributor)) 447 | : {...contributor} 448 | 449 | /** @type {Array} */ 450 | /* c8 ignore next 3 -- verbose to test. */ 451 | const emails = Array.isArray(contributorObject.emails) 452 | ? // type-coverage:ignore-next-line 453 | [...contributorObject.emails] 454 | : [] 455 | 456 | if (contributorObject.email) { 457 | emails.push(contributorObject.email) 458 | } 459 | 460 | for (const email of emails) { 461 | indexValue(indices.email, email, contributorObject) 462 | } 463 | 464 | indexValue(indices.github, contributorObject.github, contributorObject) 465 | indexValue(indices.name, contributorObject.name, contributorObject) 466 | } 467 | 468 | /** 469 | * @param {Record} index 470 | * Index. 471 | * @param {unknown} raw 472 | * Raw value. 473 | * @param {ContributorObject} contributor 474 | * Contributor. 475 | * @returns {undefined} 476 | * Nothing. 477 | */ 478 | function indexValue(index, raw, contributor) { 479 | if (raw) { 480 | const key = String(raw).toLowerCase() 481 | const value = index[key] 482 | index[key] = value ? {...contributor, ...value} : contributor 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # remark-git-contributors 2 | 3 | [![Version][version-badge]][version] 4 | [![Build][build-badge]][build] 5 | [![Coverage][coverage-badge]][coverage] 6 | [![Downloads][downloads-badge]][downloads] 7 | [![Sponsors][sponsors-badge]][collective] 8 | [![Backers][backers-badge]][collective] 9 | [![Chat][chat-badge]][chat] 10 | 11 | **[remark][]** plugin to generate a list of Git contributors. 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When should I use this?](#when-should-i-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`defaultFilter(contributor, metadata)`](#defaultfiltercontributor-metadata) 21 | * [`unified().use(remarkGitContributors[, options])`](#unifieduseremarkgitcontributors-options) 22 | * [`Contributor`](#contributor) 23 | * [`Filter`](#filter) 24 | * [`Options`](#options) 25 | * [Examples](#examples) 26 | * [Example: CLI](#example-cli) 27 | * [Example: CLI in npm scripts](#example-cli-in-npm-scripts) 28 | * [Example: `appendIfMissing`](#example-appendifmissing) 29 | * [Example: metadata](#example-metadata) 30 | * [Types](#types) 31 | * [Compatibility](#compatibility) 32 | * [Security](#security) 33 | * [Contribute](#contribute) 34 | * [Contributors](#contributors) 35 | * [License](#license) 36 | 37 | ## What is this? 38 | 39 | This package is a [unified][] ([remark][]) plugin that collects contributors 40 | from Git history, deduplicates them, augments it with metadata found in options, 41 | a module, or `package.json`, and passes that to 42 | [`remark-contributors`][remark-contributors] to add them in a table in 43 | `## Contributors`. 44 | 45 | ## When should I use this? 46 | 47 | This project is particularly useful when you have (open source) projects that 48 | are maintained with Git and want to show who helped build them by adding their 49 | names, websites, and perhaps some more info, based on their commits, to readmes. 50 | This package is useful because it’s automated based on Git: those who commit 51 | will get included. 52 | The downside is that commits aren’t the only way to contribute (something 53 | [All Contributors][all-contributors] focusses on). 54 | 55 | This plugin is a Git layer on top of 56 | [`remark-contributors`][remark-contributors], so it shares its benefits. 57 | You can also use that plugin when you don’t want Git commits to be the source of 58 | truth. 59 | 60 | ## Install 61 | 62 | This package is [ESM only][esm]. 63 | In Node.js (version 16+), install with [npm][]: 64 | 65 | ```sh 66 | npm install remark-git-contributors 67 | ``` 68 | 69 | Contributions are welcome to add support for Deno. 70 | 71 | ## Use 72 | 73 | Say we have the following file `example.md` in this project: 74 | 75 | ```markdown 76 | # Example 77 | 78 | Some text. 79 | 80 | ## Contributors 81 | 82 | ## License 83 | 84 | MIT 85 | ``` 86 | 87 | …and a module `example.js`: 88 | 89 | ```js 90 | import {remark} from 'remark' 91 | import remarkGfm from 'remark-gfm' 92 | import remarkGitContributors from 'remark-git-contributors' 93 | import {read} from 'to-vfile' 94 | 95 | const file = await remark() 96 | .use(remarkGfm) // Required: add support for tables (a GFM feature). 97 | .use(remarkGitContributors) 98 | .process(await read('example.md')) 99 | 100 | console.log(String(file)) 101 | ``` 102 | 103 | …then running `node example.js` yields: 104 | 105 | ```markdown 106 | # Example 107 | 108 | Some text. 109 | 110 | ## Contributors 111 | 112 | | Name | GitHub | Social | 113 | | :------------------ | :------------------------------------------- | :---------------------------------------------------- | 114 | | **Titus Wormer** | [**@wooorm**](https://github.com/wooorm) | [**@wooorm@twitter**](https://twitter.com/wooorm) | 115 | | **Vincent Weevers** | [**@vweevers**](https://github.com/vweevers) | [**@vweevers@twitter**](https://twitter.com/vweevers) | 116 | 117 | ## License 118 | 119 | MIT 120 | ``` 121 | 122 | > 👉 **Note**: These contributors are inferred from the Git history 123 | > and [`package.json`][file-package-json] in this repo. 124 | > Running this example in a different package will yield different results. 125 | 126 | ## API 127 | 128 | This package exports no identifiers. 129 | The default export is [`remarkGitContributors`][api-remark-git-contributors]. 130 | 131 | ### `defaultFilter(contributor, metadata)` 132 | 133 | Default filter for contributors ([`Filter`][api-filter]); 134 | currently filters out Greenkeeper. 135 | 136 | ### `unified().use(remarkGitContributors[, options])` 137 | 138 | Generate a list of Git contributors. 139 | 140 | In short, this plugin: 141 | 142 | * looks for the first heading matching `/^contributors$/i` 143 | * if no heading is found and `appendIfMissing` is set, injects such a heading 144 | * if there is a heading, replaces everything in that section with a new table 145 | with Git contributors 146 | 147 | ###### Parameters 148 | 149 | * `options` ([`Options`][api-options] or `string`, optional) 150 | — configuration; 151 | passing `string` is as if passing `options.contributors` 152 | 153 | ###### Returns 154 | 155 | Transform ([`Transformer`][unified-transformer]). 156 | 157 | ### `Contributor` 158 | 159 | Contributor in string form (`name (url)`) or as object (TypeScript 160 | type). 161 | 162 | ###### Type 163 | 164 | ```ts 165 | type Contributor = Record | string 166 | ``` 167 | 168 | ### `Filter` 169 | 170 | Filter contributors (TypeScript type). 171 | 172 | ###### Parameters 173 | 174 | * `contributor` (`Contributor`) 175 | — contributor found by `contributorsFromGit` 176 | * `metadata` (`Record`) 177 | — associated metadata found in `package.json` or `options.contributors` 178 | 179 | ###### Returns 180 | 181 | Whether to include the contributor (`boolean`). 182 | 183 | ### `Options` 184 | 185 | Configuration (TypeScript type). 186 | 187 | ###### Fields 188 | 189 | * `appendIfMissing` (`boolean`, default: `false`) 190 | — inject the section if there is none 191 | * `contributors` ([`Array`][api-contributor] or `string`, 192 | optional) 193 | — list of contributors to inject; 194 | defaults to the `contributors` field in the closest `package.json` upwards 195 | from the processed file, if there is one; 196 | supports the string form (`name (url)`) as well; 197 | throws if no contributors are found or given 198 | * `cwd` (`string`, default: `file.cwd`) 199 | — working directory from which to resolve a `contributors` module, if any 200 | * `filter` ([`Filter`][api-filter], default: 201 | [`defaultFilter`][api-default-filter]) 202 | — filter contributors 203 | * `limit` (`number`, default: `0`) 204 | — limit the rendered contributors; 205 | `0` (or lower) includes all contributors; 206 | if `limit` is given, only the top `` contributors, sorted by commit 207 | count, are rendered 208 | 209 | ## Examples 210 | 211 | ### Example: CLI 212 | 213 | It’s recommended to use `remark-git-contributors` on the CLI with 214 | [`remark-cli`][remark-cli]. 215 | Install both (and [`remark-gfm`][remark-gfm]) with [npm][]: 216 | 217 | ```sh 218 | npm install remark-cli remark-gfm remark-git-contributors --save-dev 219 | ``` 220 | 221 | Let’s say we have an `example.md` with the following text: 222 | 223 | ```markdown 224 | # Hello 225 | 226 | Some text. 227 | 228 | ## Contributors 229 | ``` 230 | 231 | You can now use the CLI to format `example.md`: 232 | 233 | ```sh 234 | npx remark --output --use remark-gfm --use remark-git-contributors example.md 235 | ``` 236 | 237 | This adds the table of contributors to `example.md`, which now contains (when 238 | running in this project): 239 | 240 | ```markdown 241 | # Hello 242 | 243 | Some text. 244 | 245 | ## Contributors 246 | 247 | | Name | GitHub | Social | 248 | | :------------------ | :------------------------------------------- | :---------------------------------------------------- | 249 | | **Titus Wormer** | [**@wooorm**](https://github.com/wooorm) | [**@wooorm@twitter**](https://twitter.com/wooorm) | 250 | | **Vincent Weevers** | [**@vweevers**](https://github.com/vweevers) | [**@vweevers@twitter**](https://twitter.com/vweevers) | 251 | ``` 252 | 253 | ### Example: CLI in npm scripts 254 | 255 | You can use `remark-git-contributors` and [`remark-cli`][remark-cli] in an npm 256 | script to format markdown in your project. 257 | Install both (and [`remark-gfm`][remark-gfm]) with [npm][]: 258 | 259 | ```sh 260 | npm install remark-cli remark-gfm remark-git-contributors --save-dev 261 | ``` 262 | 263 | Then, add a format script and configuration to `package.json`: 264 | 265 | ```js 266 | { 267 | // … 268 | "scripts": { 269 | // … 270 | "format": "remark . --output --quiet", 271 | // … 272 | }, 273 | "remarkConfig": { 274 | "plugins": [ 275 | "remark-gfm", 276 | "remark-git-contributors" 277 | ] 278 | }, 279 | // … 280 | } 281 | ``` 282 | 283 | > 💡 **Tip**: Add other tools such as prettier or ESLint to check and format 284 | > other files. 285 | > 286 | > 💡 **Tip**: Run `./node_modules/.bin/remark --help` for help with 287 | > `remark-cli`. 288 | 289 | Now you format markdown in your project with: 290 | 291 | ```sh 292 | npm run format 293 | ``` 294 | 295 | ### Example: `appendIfMissing` 296 | 297 | The default behavior of this plugin is to not generate lists of Git 298 | contributors if there is no `## Contributors` (case- and level-insensitive). 299 | You can change that by configuring the plugin with 300 | `options.appendIfMissing: true`. 301 | 302 | The reason for not generating contributors by default is that as we saw in the 303 | previous example (CLI in npm scripts) remark and this plugin often run on 304 | several files. 305 | You can choose where to add the list by explicitly adding `## Contributors` 306 | in the main file (`readme.md`) and other docs won’t be touched. 307 | Or, when you have many contributors, add a specific `contributors.md` file, 308 | with a primary `# Contributors` heading, and the list will be generated there. 309 | 310 | To turn `appendIfMissing` mode on, pass it like so on the API: 311 | 312 | ```js 313 | // … 314 | .use(remarkGitContributors, {appendIfMissing: true}) 315 | // … 316 | ``` 317 | 318 | Or on the CLI (in `package.json`): 319 | 320 | ```js 321 | // … 322 | "remarkConfig": { 323 | "plugins": [ 324 | // … 325 | [ 326 | "remark-git-contributors", 327 | {"appendIfMissing": true} 328 | ] 329 | ] 330 | }, 331 | // … 332 | ``` 333 | 334 | ### Example: metadata 335 | 336 | The data gathered from Git is only includes names and emails. 337 | To add more metadata, either add it to `package.json` (used in this project’s 338 | [`package.json`][file-package-json]) or configure `options.contributors`. 339 | On the API, that’s done like so: 340 | 341 | ```js 342 | // … 343 | .use(remarkGitContributors, {contributors: /* value */}) 344 | // … 345 | ``` 346 | 347 | Or on the CLI (in `package.json`): 348 | 349 | ```js 350 | // … 351 | "remarkConfig": { 352 | "plugins": [ 353 | // … 354 | [ 355 | "remark-git-contributors", 356 | {"contributors": /* value */} 357 | ] 358 | ] 359 | }, 360 | // … 361 | ``` 362 | 363 | The value for `contributors` is either: 364 | 365 | * an array in the form of `[{ email, name, … }, … ]`; 366 | * a module id, or path to a file, that exports `contributors` as the default 367 | export or as a `contributors` named export 368 | 369 | > 👉 **Note**: contributors that are not in Git history are excluded. 370 | > This way the `contributors` metadata can be reused in multiple projects. 371 | 372 | Each contributor should at least have an `email` property to match against Git 373 | email addresses. 374 | If you’re experiencing people showing up multiple times from Git history, for 375 | example because they switched email addresses while contributing to the project, 376 | or if their name or email are wrong, you can “merge” and fix contributors in Git 377 | by using a [`.mailmap` file][git-mailmap]. 378 | 379 | The supported properties on contributors are: 380 | 381 | * `email` — person’s email (example: `sara@example.com`) 382 | * `github` — GitHub username (example: `sara123`) 383 | * `mastodon` — Mastodon (`@user@domain`) 384 | * `name` — person’s name (example: `Sara`) 385 | * `twitter` — Twitter username (example: `the_sara`) 386 | 387 | An example of a module is: 388 | 389 | ```js 390 | // … 391 | .use(remarkGitContributors, {contributors: './data/contributors.js'}) 392 | // … 393 | ``` 394 | 395 | Where `data/contributors.js` would contain either: 396 | 397 | ```js 398 | export const contributors = [{ email, name, /* … */ }, /* … */ ] 399 | ``` 400 | 401 | Or: 402 | 403 | ```js 404 | const contributors = [{ email, name, /* … */ }, /* … */ ] 405 | 406 | export default contributors 407 | ``` 408 | 409 | ## Types 410 | 411 | This package is fully typed with [TypeScript][]. 412 | It exports the additional types 413 | [`Contributor`][api-contributor], 414 | [`Filter`][api-filter], and 415 | [`Options`][api-options]. 416 | 417 | ## Compatibility 418 | 419 | Projects maintained by the unified collective are compatible with maintained 420 | versions of Node.js. 421 | 422 | When we cut a new major release, we drop support for unmaintained versions of 423 | Node. 424 | This means we try to keep the current release line, 425 | `remark-git-contributors@^5`, compatible with Node.js 16. 426 | 427 | This plugin works with `unified` version 6+ and `remark` version 7+. 428 | 429 | ## Security 430 | 431 | `remark-git-contributors` is typically used in a trusted environment. 432 | This section explains potential attack vectors and how to mitigate them if the 433 | environment is not (fully) trusted. 434 | 435 | `options.contributors` (or `contributors` in `package.json`) and `author` from 436 | `package.json` are used and injected into the tree. 437 | `git log` also runs in the current working directory. 438 | This could open you up to a [cross-site scripting (XSS)][wiki-xss] attack if 439 | you pass user provided content in or store user provided content in 440 | `package.json` or Git. 441 | 442 | This may become a problem if the markdown later transformed to **[rehype][]** 443 | (**[hast][]**) or opened in an unsafe markdown viewer. 444 | 445 | If `contributors` is a string, it is handled as a module identifier and 446 | imported. 447 | This could also be very dangerous if an attacker was able to inject code in that 448 | package. 449 | 450 | ## Contribute 451 | 452 | See [`contributing.md`][contributing] in [`remarkjs/.github`][health] for ways 453 | to get started. 454 | See [`support.md`][support] for ways to get help. 455 | 456 | This project has a [code of conduct][coc]. 457 | By interacting with this repository, organization, or community you agree to 458 | abide by its terms. 459 | 460 | ## Contributors 461 | 462 | | Name | GitHub | Social | 463 | | :------------------ | :------------------------------------------- | :---------------------------------------------------- | 464 | | **Titus Wormer** | [**@wooorm**](https://github.com/wooorm) | [**@wooorm@twitter**](https://twitter.com/wooorm) | 465 | | **Vincent Weevers** | [**@vweevers**](https://github.com/vweevers) | [**@vweevers@twitter**](https://twitter.com/vweevers) | 466 | 467 | ## License 468 | 469 | [MIT][license] © Vincent Weevers 470 | 471 | 472 | 473 | [version-badge]: http://img.shields.io/npm/v/remark-git-contributors.svg 474 | 475 | [version]: https://www.npmjs.org/package/remark-git-contributors 476 | 477 | [build-badge]: https://github.com/remarkjs/remark-git-contributors/workflows/main/badge.svg 478 | 479 | [build]: https://github.com/remarkjs/remark-git-contributors/actions 480 | 481 | [coverage-badge]: https://img.shields.io/codecov/c/github/remarkjs/remark-git-contributors.svg 482 | 483 | [coverage]: https://codecov.io/github/remarkjs/remark-git-contributors 484 | 485 | [downloads-badge]: https://img.shields.io/npm/dm/remark-git-contributors.svg 486 | 487 | [downloads]: https://www.npmjs.com/package/remark-git-contributors 488 | 489 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 490 | 491 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 492 | 493 | [collective]: https://opencollective.com/unified 494 | 495 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 496 | 497 | [chat]: https://github.com/remarkjs/remark/discussions 498 | 499 | [npm]: https://docs.npmjs.com/cli/install 500 | 501 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 502 | 503 | [health]: https://github.com/remarkjs/.github 504 | 505 | [contributing]: https://github.com/remarkjs/.github/blob/main/contributing.md 506 | 507 | [support]: https://github.com/remarkjs/.github/blob/main/support.md 508 | 509 | [coc]: https://github.com/remarkjs/.github/blob/main/code-of-conduct.md 510 | 511 | [license]: license 512 | 513 | [all-contributors]: https://github.com/all-contributors/all-contributors 514 | 515 | [git-mailmap]: https://git-scm.com/docs/git-shortlog#_mapping_authors 516 | 517 | [hast]: https://github.com/syntax-tree/hast 518 | 519 | [remark]: https://github.com/remarkjs/remark 520 | 521 | [rehype]: https://github.com/rehypejs/rehype 522 | 523 | [remark-cli]: https://github.com/remarkjs/remark/tree/main/packages/remark-cli 524 | 525 | [remark-contributors]: https://github.com/remarkjs/remark-contributors 526 | 527 | [remark-gfm]: https://github.com/remarkjs/remark-gfm 528 | 529 | [typescript]: https://www.typescriptlang.org 530 | 531 | [unified]: https://github.com/unifiedjs/unified 532 | 533 | [unified-transformer]: https://github.com/unifiedjs/unified#transformer 534 | 535 | [wiki-xss]: https://en.wikipedia.org/wiki/Cross-site_scripting 536 | 537 | [file-package-json]: package.json 538 | 539 | [api-contributor]: #contributor 540 | 541 | [api-default-filter]: #defaultfiltercontributor-metadata 542 | 543 | [api-filter]: #filter 544 | 545 | [api-options]: #options 546 | 547 | [api-remark-git-contributors]: #unifieduseremarkgitcontributors-options 548 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {PackageJson} from 'type-fest' 3 | */ 4 | 5 | /** 6 | * @typedef {PackageJson.Person} Person 7 | */ 8 | 9 | /** 10 | * @typedef CommitOptions 11 | * Configuration to set up Git. 12 | * @property {ReadonlyArray> | null | undefined} [users] 13 | * Users (optional). 14 | * @property {string | null | undefined} [main] 15 | * Contents of file initially (optional). 16 | * @property {boolean | null | undefined} [skipInit] 17 | * Skip `git init` (default: `false`). 18 | * 19 | * @typedef PackageOptions 20 | * Configuration to set up a `package.json`. 21 | * @property {Readonly | null | undefined} [author] 22 | * Author (optional). 23 | * @property {ReadonlyArray> | null | undefined} [contributors] 24 | * Contributors (optional). 25 | * @property {boolean | null | undefined} [broken] 26 | * Break the `package.json` (default: `false`). 27 | * 28 | * @typedef {[name: string, email: string]} User 29 | * User name and email. 30 | */ 31 | 32 | import assert from 'node:assert/strict' 33 | import {execFile as execFileCallback} from 'node:child_process' 34 | import fs from 'node:fs/promises' 35 | import process from 'node:process' 36 | import test from 'node:test' 37 | import {pathToFileURL} from 'node:url' 38 | import {promisify} from 'node:util' 39 | import {remark} from 'remark' 40 | import remarkGfm from 'remark-gfm' 41 | import remarkGitContributors from 'remark-git-contributors' 42 | import semver from 'semver' 43 | // @ts-expect-error: untyped. 44 | import tmpgen from 'tmpgen' 45 | import {VFile} from 'vfile' 46 | 47 | const execFile = promisify(execFileCallback) 48 | 49 | /** @type {() => string} */ 50 | const temporary = tmpgen('remark-git-contributors/*') 51 | 52 | const testName = 'test' 53 | const testEmail = 'test@localhost' 54 | const testUrl = 'https://localhost' 55 | 56 | const fixtures = new URL('fixture/', import.meta.url) 57 | 58 | test('remark-git-contributors', async function (t) { 59 | await t.test('should expose the public api', async function () { 60 | assert.deepEqual( 61 | Object.keys(await import('remark-git-contributors')).sort(), 62 | ['default', 'defaultFilter'] 63 | ) 64 | }) 65 | 66 | await t.test('should work', async function () { 67 | const cwd = temporary() 68 | const [input, expected] = await getFixtures('00') 69 | 70 | await createPackage(cwd) 71 | await createCommits(cwd) 72 | const file = await remark() 73 | .use(remarkGfm) 74 | .use(remarkGitContributors) 75 | .process(new VFile({cwd, path: 'input.md', value: input})) 76 | 77 | assert.equal(String(file), expected) 78 | assert.deepEqual( 79 | file.messages.map(function (d) { 80 | return d.reason 81 | }), 82 | ['Unexpected missing social handle for contributor `test@localhost`'] 83 | ) 84 | }) 85 | 86 | await t.test('should support metadata as strings', async function () { 87 | const cwd = temporary() 88 | const [input, expected] = await getFixtures('00') 89 | 90 | await createPackage(cwd) 91 | await createCommits(cwd, { 92 | main: String( 93 | await fs.readFile(new URL('contributors-string.js', fixtures)) 94 | ) 95 | }) 96 | const file = await remark() 97 | .use(remarkGfm) 98 | .use(remarkGitContributors, './index.js') 99 | .process(new VFile({cwd, path: 'input.md', value: input})) 100 | 101 | assert.equal(String(file), expected) 102 | assert.deepEqual( 103 | file.messages.map(function (d) { 104 | return d.reason 105 | }), 106 | ['Unexpected missing social handle for contributor `test@localhost`'] 107 | ) 108 | }) 109 | 110 | await t.test('should support duplicate metadata', async function () { 111 | const cwd = temporary() 112 | const [input, expected] = await getFixtures('00') 113 | 114 | await createPackage(cwd) 115 | await createCommits(cwd, { 116 | main: String( 117 | await fs.readFile(new URL('contributors-duplicates.js', fixtures)) 118 | ) 119 | }) 120 | const file = await remark() 121 | .use(remarkGfm) 122 | .use(remarkGitContributors, './index.js') 123 | .process(new VFile({cwd, path: 'input.md', value: input})) 124 | 125 | assert.equal(String(file), expected) 126 | assert.deepEqual( 127 | file.messages.map(function (d) { 128 | return d.reason 129 | }), 130 | ['Unexpected missing social handle for contributor `test@localhost`'] 131 | ) 132 | }) 133 | 134 | await t.test('should work w/ metadata', async function () { 135 | const cwd = temporary() 136 | const [input, expected] = await getFixtures('01') 137 | 138 | await createPackage(cwd) 139 | await createCommits(cwd) 140 | const file = await remark() 141 | .use(remarkGfm) 142 | .use(remarkGitContributors, { 143 | contributors: [{email: testEmail, github: 'test', twitter: 'test'}] 144 | }) 145 | .process(new VFile({cwd, path: 'input.md', value: input})) 146 | 147 | assert.equal(String(file), expected) 148 | assert.deepEqual(file.messages, []) 149 | }) 150 | 151 | await t.test( 152 | 'should work w/ metadata from default export', 153 | async function () { 154 | const cwd = temporary() 155 | const [input, expected] = await getFixtures('01') 156 | 157 | await createPackage(cwd) 158 | await createCommits(cwd, { 159 | main: String( 160 | await fs.readFile(new URL('contributors-main.js', fixtures)) 161 | ) 162 | }) 163 | const file = await remark() 164 | .use(remarkGfm) 165 | .use(remarkGitContributors, './index.js') 166 | .process(new VFile({cwd, path: 'input.md', value: input})) 167 | 168 | assert.equal(String(file), expected) 169 | assert.deepEqual(file.messages, []) 170 | } 171 | ) 172 | 173 | await t.test('should work w/ metadata from named export', async function () { 174 | const cwd = temporary() 175 | const [input, expected] = await getFixtures('01') 176 | 177 | await createPackage(cwd) 178 | await createCommits(cwd, { 179 | main: String( 180 | await fs.readFile(new URL('contributors-named.js', fixtures)) 181 | ) 182 | }) 183 | const file = await remark() 184 | .use(remarkGfm) 185 | .use(remarkGitContributors, './index.js') 186 | .process(new VFile({cwd, path: 'input.md', value: input})) 187 | 188 | assert.equal(String(file), expected) 189 | assert.deepEqual(file.messages, []) 190 | }) 191 | 192 | await t.test('should fail w/ an unfound module id', async function () { 193 | const cwd = temporary() 194 | const [input] = await getFixtures('00') 195 | 196 | await createPackage(cwd) 197 | await createCommits(cwd) 198 | 199 | try { 200 | await remark() 201 | .use(remarkGfm) 202 | .use(remarkGitContributors, './missing.js') 203 | .process(new VFile({cwd, path: 'input.md', value: input})) 204 | assert.fail() 205 | } catch (error) { 206 | assert.match(String(error), /Cannot find module/) 207 | } 208 | }) 209 | 210 | await t.test('should fail w/ a throwing module', async function () { 211 | const cwd = temporary() 212 | const [input] = await getFixtures('00') 213 | 214 | await createPackage(cwd) 215 | await createCommits(cwd, { 216 | main: String( 217 | await fs.readFile(new URL('contributors-throwing.js', fixtures)) 218 | ) 219 | }) 220 | 221 | try { 222 | await remark() 223 | .use(remarkGfm) 224 | .use(remarkGitContributors, './index.js') 225 | .process(new VFile({cwd, path: 'input.md', value: input})) 226 | assert.fail() 227 | } catch (error) { 228 | assert.match(String(error), /Error: Some error!/) 229 | } 230 | }) 231 | 232 | await t.test('should fail w/ invalid exports', async function () { 233 | const cwd = temporary() 234 | const [input] = await getFixtures('00') 235 | 236 | await createPackage(cwd) 237 | await createCommits(cwd, { 238 | main: String( 239 | await fs.readFile(new URL('contributors-invalid-exports.js', fixtures)) 240 | ) 241 | }) 242 | 243 | try { 244 | await remark() 245 | .use(remarkGfm) 246 | .use(remarkGitContributors, './index.js') 247 | .process(new VFile({cwd, path: 'input.md', value: input})) 248 | assert.fail() 249 | } catch (error) { 250 | assert.match(String(error), /Unexpected missing contributors/) 251 | } 252 | }) 253 | 254 | await t.test( 255 | 'should fail w/ invalid `options.contributors`', 256 | async function () { 257 | const cwd = temporary() 258 | const [input] = await getFixtures('00') 259 | 260 | await createPackage(cwd) 261 | await createCommits(cwd) 262 | 263 | try { 264 | await remark() 265 | .use(remarkGfm) 266 | // @ts-expect-error: check how runtime handles invalid `contributors`. 267 | .use(remarkGitContributors, {contributors: true}) 268 | .process(new VFile({cwd, path: 'input.md', value: input})) 269 | assert.fail() 270 | } catch (error) { 271 | assert.match(String(error), /Unexpected missing contributors/) 272 | } 273 | } 274 | ) 275 | 276 | await t.test('should work w/o heading', async function () { 277 | const cwd = temporary() 278 | const [input, expected] = await getFixtures('02') 279 | 280 | await createPackage(cwd) 281 | await createCommits(cwd) 282 | const file = await remark() 283 | .use(remarkGfm) 284 | .use(remarkGitContributors) 285 | .process(new VFile({cwd, path: 'input.md', value: input})) 286 | 287 | assert.equal(String(file), expected) 288 | assert.deepEqual(file.messages, []) 289 | }) 290 | 291 | await t.test( 292 | 'should work w/o heading, with `options.appendIfMissing`', 293 | async function () { 294 | const cwd = temporary() 295 | const [input, expected] = await getFixtures('03') 296 | 297 | await createPackage(cwd) 298 | await createCommits(cwd) 299 | const file = await remark() 300 | .use(remarkGfm) 301 | .use(remarkGitContributors, {appendIfMissing: true}) 302 | .process(new VFile({cwd, path: 'input.md', value: input})) 303 | 304 | assert.equal(String(file), expected) 305 | assert.deepEqual( 306 | file.messages.map(function (d) { 307 | return d.reason 308 | }), 309 | ['Unexpected missing social handle for contributor `test@localhost`'] 310 | ) 311 | } 312 | ) 313 | 314 | await t.test('should work w/ a noreply email', async function () { 315 | const cwd = temporary() 316 | const email = '944406+wooorm@users.noreply.github.com' 317 | const [input, expected] = await getFixtures('04') 318 | 319 | await createPackage(cwd) 320 | await createCommits(cwd, {users: [[testName, email]]}) 321 | const file = await remark() 322 | .use(remarkGfm) 323 | .use(remarkGitContributors) 324 | .process(new VFile({cwd, path: 'input.md', value: input})) 325 | 326 | assert.equal(String(file), expected) 327 | assert.deepEqual( 328 | file.messages.map(function (d) { 329 | return d.reason 330 | }), 331 | [ 332 | 'Unexpected missing social handle for contributor `944406+wooorm@users.noreply.github.com`' 333 | ] 334 | ) 335 | }) 336 | 337 | await t.test('should ignore greenkeeper email', async function () { 338 | const cwd = temporary() 339 | const email = 'example@greenkeeper.io' 340 | const [input] = await getFixtures('00') 341 | 342 | await createPackage(cwd) 343 | await createCommits(cwd, {users: [[testName, email]]}) 344 | 345 | try { 346 | await remark() 347 | .use(remarkGfm) 348 | .use(remarkGitContributors) 349 | .process(new VFile({cwd, path: 'input.md', value: input})) 350 | assert.fail() 351 | } catch (error) { 352 | assert.match( 353 | String(error), 354 | /Error: Missing required `contributors` in settings/ 355 | ) 356 | } 357 | }) 358 | 359 | await t.test('should support a custom `filter`', async function () { 360 | const cwd = temporary() 361 | const [input] = await getFixtures('00') 362 | 363 | await createPackage(cwd) 364 | await createCommits(cwd, { 365 | users: [ 366 | ['beep', 'beep@boop.io'], 367 | ['bea', 'äkta@människor.io'] 368 | ] 369 | }) 370 | 371 | const file = await remark() 372 | .use(remarkGfm) 373 | .use(remarkGitContributors, { 374 | filter(d) { 375 | return d.name !== 'beep' 376 | } 377 | }) 378 | .process(new VFile({cwd, path: 'input.md', value: input})) 379 | 380 | assert.equal( 381 | String(file), 382 | '# Contributors\n\n| Name |\n| :------ |\n| **bea** |\n' 383 | ) 384 | assert.deepEqual( 385 | file.messages.map(function (d) { 386 | return d.reason 387 | }), 388 | ['Unexpected missing social handle for contributor `äkta@människor.io`'] 389 | ) 390 | }) 391 | 392 | await t.test('should work w/ invalid twitter', async function () { 393 | const cwd = temporary() 394 | const [input, expected] = await getFixtures('00') 395 | 396 | await createPackage(cwd) 397 | await createCommits(cwd) 398 | const file = await remark() 399 | .use(remarkGfm) 400 | .use(remarkGitContributors, { 401 | contributors: [{email: testEmail, twitter: '@'}] 402 | }) 403 | .process(new VFile({cwd, path: 'input.md', value: input})) 404 | 405 | assert.equal(String(file), expected) 406 | assert.deepEqual( 407 | file.messages.map(function (d) { 408 | return d.reason 409 | }), 410 | ['Unexpected invalid Twitter handle `@` for contributor `test@localhost`'] 411 | ) 412 | }) 413 | 414 | await t.test('should work w/ valid mastodon', async function () { 415 | const cwd = temporary() 416 | const [input, expected] = await getFixtures('05') 417 | 418 | await createPackage(cwd) 419 | await createCommits(cwd) 420 | const file = await remark() 421 | .use(remarkGfm) 422 | .use(remarkGitContributors, { 423 | contributors: [{email: testEmail, mastodon: '@foo@bar.com'}] 424 | }) 425 | .process(new VFile({cwd, path: 'input.md', value: input})) 426 | 427 | assert.equal(String(file), expected) 428 | assert.deepEqual(file.messages, []) 429 | }) 430 | 431 | await t.test('should work w/ invalid mastodon', async function () { 432 | const cwd = temporary() 433 | const [input, expected] = await getFixtures('00') 434 | 435 | await createPackage(cwd) 436 | await createCommits(cwd) 437 | const file = await remark() 438 | .use(remarkGfm) 439 | .use(remarkGitContributors, { 440 | contributors: [{email: testEmail, mastodon: '@foo'}] 441 | }) 442 | .process(new VFile({cwd, path: 'input.md', value: input})) 443 | 444 | assert.equal(String(file), expected) 445 | assert.deepEqual( 446 | file.messages.map(function (d) { 447 | return d.reason 448 | }), 449 | [ 450 | 'Unexpected invalid Mastodon handle `@foo` for contributor `test@localhost`' 451 | ] 452 | ) 453 | }) 454 | 455 | await t.test('should work w/ empty email', async function () { 456 | const cwd = temporary() 457 | const [input] = await getFixtures('00') 458 | 459 | await createPackage(cwd) 460 | await createCommits(cwd, {users: [[testName, '<>']]}) 461 | 462 | try { 463 | await remark() 464 | .use(remarkGfm) 465 | .use(remarkGitContributors) 466 | .process(new VFile({cwd, path: 'input.md', value: input})) 467 | assert.fail() 468 | } catch (error) { 469 | assert.match( 470 | String(error), 471 | /Error: Missing required `contributors` in settings/ 472 | ) 473 | } 474 | }) 475 | 476 | await t.test('should work w/ multiple authors', async function () { 477 | /** @type {User} */ 478 | const topContributor = ['Alpha', 'alpha@localhost'] 479 | /** @type {User} */ 480 | const otherContributor = ['Bravo', 'bravo@localhost'] 481 | /** @type {User} */ 482 | const anotherContributor = ['Charlie', 'charlie@localhost'] 483 | const cwd = temporary() 484 | const [input, expected] = await getFixtures('06') 485 | 486 | await createPackage(cwd) 487 | await createCommits(cwd, { 488 | users: [ 489 | topContributor, 490 | topContributor, 491 | otherContributor, 492 | anotherContributor, 493 | topContributor, 494 | anotherContributor, 495 | otherContributor 496 | ] 497 | }) 498 | const file = await remark() 499 | .use(remarkGfm) 500 | .use(remarkGitContributors) 501 | .process(new VFile({cwd, path: 'input.md', value: input})) 502 | 503 | assert.equal(String(file), expected) 504 | }) 505 | 506 | await t.test( 507 | 'should work w/ multiple authors and `options.limit`', 508 | async function () { 509 | /** @type {User} */ 510 | const topContributor = ['Alpha', 'alpha@localhost'] 511 | /** @type {User} */ 512 | const otherContributor = ['Bravo', 'bravo@localhost'] 513 | const cwd = temporary() 514 | const [input, expected] = await getFixtures('07') 515 | 516 | await createPackage(cwd) 517 | await createCommits(cwd, { 518 | users: [topContributor, topContributor, otherContributor] 519 | }) 520 | const file = await remark() 521 | .use(remarkGfm) 522 | .use(remarkGitContributors, {limit: 1}) 523 | .process(new VFile({cwd, path: 'input.md', value: input})) 524 | 525 | assert.equal(String(file), expected) 526 | } 527 | ) 528 | 529 | await t.test( 530 | 'should work w/ duplicate Git users and contributors', 531 | async function () { 532 | const cwd = temporary() 533 | const email = 'alpha@localhost' 534 | const [input, expected] = await getFixtures('08') 535 | 536 | await createPackage(cwd) 537 | await createCommits(cwd, { 538 | users: [ 539 | ['A name', email], 540 | ['Another name', email] 541 | ] 542 | }) 543 | 544 | const file = await remark() 545 | .use(remarkGfm) 546 | .use(remarkGitContributors, { 547 | contributors: [ 548 | {name: 'One more name', email, twitter: '@a'}, 549 | {name: 'The last name', email, twitter: '@b'} 550 | ] 551 | }) 552 | .process(new VFile({cwd, path: 'input.md', value: input})) 553 | 554 | assert.equal(String(file), expected) 555 | } 556 | ) 557 | 558 | await t.test('should work w/o git', async function () { 559 | const cwd = temporary() 560 | const [input] = await getFixtures('00') 561 | 562 | await createPackage(cwd) 563 | await createCommits(cwd, {users: [], skipInit: true}) 564 | 565 | try { 566 | await remark() 567 | .use(remarkGfm) 568 | .use(remarkGitContributors) 569 | .process(new VFile({cwd, path: 'input.md', value: input})) 570 | assert.fail() 571 | } catch (error) { 572 | assert.match(String(error), /Cannot get Git contributors/) 573 | } 574 | }) 575 | 576 | await t.test( 577 | 'should work w/ no git users or `contributors`', 578 | async function () { 579 | const cwd = temporary() 580 | const [input] = await getFixtures('00') 581 | 582 | await createPackage(cwd) 583 | await createCommits(cwd, {users: []}) 584 | 585 | const file = await remark() 586 | .use(remarkGfm) 587 | .use(remarkGitContributors, {contributors: []}) 588 | .process(new VFile({cwd, path: 'input.md', value: input})) 589 | 590 | assert.deepEqual( 591 | file.messages.map(function (d) { 592 | return d.reason 593 | }), 594 | ['Unexpected empty Git history, expected commits'] 595 | ) 596 | } 597 | ) 598 | 599 | await t.test('should work w/ `author` in `package.json`', async function () { 600 | const cwd = temporary() 601 | const [input, expected] = await getFixtures('09') 602 | 603 | await createPackage(cwd, { 604 | author: { 605 | name: testName, 606 | email: testEmail, 607 | url: testUrl, 608 | // @ts-expect-error: fine to add more info in persons. 609 | github: 'test' 610 | } 611 | }) 612 | await createCommits(cwd) 613 | const file = await remark() 614 | .use(remarkGfm) 615 | .use(remarkGitContributors) 616 | .process(new VFile({cwd, path: 'input.md', value: input})) 617 | 618 | assert.equal(String(file), expected) 619 | }) 620 | 621 | await t.test( 622 | 'should work w/ `contributors` in `package.json`', 623 | async function () { 624 | const cwd = temporary() 625 | const [input, expected] = await getFixtures('09') 626 | 627 | await createPackage(cwd, { 628 | contributors: [ 629 | // @ts-expect-error: fine to add more info in persons. 630 | {name: testName, email: testEmail, url: testUrl, github: 'test'} 631 | ] 632 | }) 633 | await createCommits(cwd) 634 | const file = await remark() 635 | .use(remarkGfm) 636 | .use(remarkGitContributors) 637 | .process(new VFile({cwd, path: 'input.md', value: input})) 638 | 639 | assert.equal(String(file), expected) 640 | } 641 | ) 642 | 643 | await t.test('should fail w/ broken `package.json`s', async function () { 644 | const cwd = temporary() 645 | const [input] = await getFixtures('00') 646 | 647 | await createPackage(cwd, {broken: true}) 648 | await createCommits(cwd) 649 | 650 | try { 651 | await remark() 652 | .use(remarkGfm) 653 | .use(remarkGitContributors) 654 | .process(new VFile({cwd, path: 'input.md', value: input})) 655 | assert.fail() 656 | } catch (error) { 657 | assert.match( 658 | String(error), 659 | semver.satisfies(process.version, '>=20') 660 | ? /SyntaxError: Unexpected non-whitespace character/ 661 | : /SyntaxError: Unexpected token/ 662 | ) 663 | } 664 | }) 665 | 666 | await t.test( 667 | 'should sort authors with equal commit count by name', 668 | async function () { 669 | const cwd = temporary() 670 | const [input, expected] = await getFixtures('10') 671 | 672 | await createPackage(cwd) 673 | await createCommits(cwd, { 674 | users: [ 675 | ['y', 'y@test'], 676 | ['a', 'a@test'], 677 | ['B', 'b@test'], 678 | ['z', 'z@test'], 679 | ['z', 'z@test'] 680 | ] 681 | }) 682 | 683 | const file = await remark() 684 | .use(remarkGfm) 685 | .use(remarkGitContributors) 686 | .process(new VFile({cwd, path: 'input.md', value: input})) 687 | 688 | assert.equal(String(file), expected) 689 | } 690 | ) 691 | }) 692 | 693 | /** 694 | * @param {string} cwd 695 | * Path to folder. 696 | * @param {Readonly | null | undefined} [options] 697 | * Configuration (optional). 698 | * @returns {Promise} 699 | * Nothing. 700 | */ 701 | async function createPackage(cwd, options) { 702 | const settings = options || {} 703 | const packageData = /** @type {PackageJson} */ ({ 704 | author: settings.author || undefined, 705 | contributors: settings.contributors || undefined, 706 | name: 'example', 707 | private: true, 708 | type: 'module' 709 | }) 710 | let value = JSON.stringify(packageData) 711 | 712 | if (settings.broken) { 713 | value = value.slice(1) 714 | } 715 | 716 | await fs.writeFile(new URL('package.json', pathToFileURL(cwd) + '/'), value) 717 | } 718 | 719 | /** 720 | * @param {string} cwd 721 | * Path to folder. 722 | * @param {Readonly | null | undefined} [options] 723 | * Configuration (optional). 724 | * @returns {Promise} 725 | * Nothing. 726 | */ 727 | async function createCommits(cwd, options) { 728 | const settings = options || {} 729 | const users = settings.users || [['test', 'test@localhost']] 730 | let index = -1 731 | 732 | if (!settings.skipInit) { 733 | await execFile('git', ['init', '.'], {cwd}) 734 | } 735 | 736 | while (++index < users.length) { 737 | const [name, email] = users[index] 738 | await fs.writeFile( 739 | new URL('index.js', pathToFileURL(cwd) + '/'), 740 | settings.main && index === 0 ? settings.main : '// ' + index + '\n' 741 | ) 742 | await execFile('git', ['config', 'user.name', name], {cwd}) 743 | await execFile('git', ['config', 'user.email', email], {cwd}) 744 | await execFile('git', ['config', 'commit.gpgsign', 'false'], {cwd}) 745 | await execFile('git', ['add', 'index.js'], {cwd}) 746 | await execFile('git', ['commit', '-m', 'commit ' + index], {cwd}) 747 | } 748 | } 749 | 750 | /** 751 | * Read the input and output of a fixture. 752 | * 753 | * @param {string} name 754 | * Name of the fixture. 755 | * @returns {Promise<[string, string]>} 756 | * Input and output. 757 | */ 758 | async function getFixtures(name) { 759 | const input = String( 760 | await fs.readFile(new URL(name + '-input.md', fixtures)) 761 | ).replace(/\r\n/g, '\n') 762 | const output = String( 763 | await fs.readFile(new URL(name + '-output.md', fixtures)) 764 | ).replace(/\r\n/g, '\n') 765 | return [input, output] 766 | } 767 | --------------------------------------------------------------------------------