├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── codecov.yml ├── package-lock.json ├── package.json ├── src └── index.ts ├── test ├── fixtures.ts └── index.test.ts └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Begin CI... 13 | uses: actions/checkout@v2 14 | 15 | - name: Use Node 14 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 14.x 19 | 20 | - name: Use cached node_modules 21 | uses: actions/cache@v1 22 | with: 23 | path: node_modules 24 | key: nodeModules-${{ hashFiles('**/package-lock.json') }} 25 | restore-keys: | 26 | nodeModules- 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | env: 31 | CI: true 32 | 33 | - name: Lint 34 | run: npm run lint 35 | env: 36 | CI: true 37 | 38 | - name: Test 39 | run: npm test -- --coverage 40 | env: 41 | CI: true 42 | 43 | - name: Build 44 | run: npm run build 45 | env: 46 | CI: true 47 | 48 | - name: Codecov 49 | run: npx codecov 50 | env: 51 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jason Etcovitch 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

README Box

2 |

Lil' helper for replacing a section of the contents of a README.

3 |

NPM CI Codecov

4 | 5 | ## Usage 6 | 7 | ### Installation 8 | 9 | ```sh 10 | $ npm install readme-box 11 | ``` 12 | 13 | ```js 14 | const { ReadmeBox } = require('readme-box') 15 | import { ReadmeBox } from 'readme-box' 16 | ``` 17 | 18 | You can quickly update a section of a README: 19 | 20 | ```js 21 | const result = await ReadmeBox.updateSection('New contents!', { 22 | owner: 'JasonEtco', 23 | repo: 'example', 24 | token: process.env.GITHUB_TOKEN, 25 | // branch is assumed to be 'master' by default, you can also specify `branch: 'main'` 26 | branch: 'main', 27 | section: 'example-section', 28 | // set to `true` to allow empty commits when there are no changes 29 | emptyCommits: false 30 | }) 31 | 32 | // `result` is the response object of the README update request or 33 | // `undefined` if no changes were made. 34 | ``` 35 | 36 | Or, if you need to access parts of it more granularly, you can use the `ReadmeBox` class methods: 37 | 38 | ```js 39 | const box = new ReadmeBox({ owner, repo, token }) 40 | 41 | // Get the contents of the README from the API 42 | const { content, sha } = await box.getReadme() 43 | 44 | // Get the contents of a section of the provided string 45 | const sectionContents = box.getSection('example-section', content) 46 | 47 | // Return a string with the replaced contents 48 | const replacedContents = box.replaceSection({ 49 | section: 'example-section', 50 | oldContent, 51 | newContent 52 | }) 53 | 54 | // Update the README via the API, with an optional commit message 55 | await box.updateReadme({ content, sha, message: 'Updating the README!' }) 56 | ``` 57 | 58 | ## How it works 59 | 60 | `ReadmeBox.updateSection` combines a couple of the methods exposed on the `ReadmeBox` class, to do the following: 61 | 62 | - Get the README file's contents from the API 63 | - Replace a section of it using Regular Expressions 64 | - Update the file via the API 65 | 66 | It expects your README to have a "section", using HTML comments: 67 | 68 | ```html 69 | Check out this README! 70 | 71 | 72 | Old contents... 73 | 74 | ``` 75 | 76 | When the above example code is run, everything between the start and end comments will be replaced. 77 | 78 | ## Local Development 79 | 80 | This project was bootstrapped with [TSDX](https://github.com/jaredpalmer/tsdx). Below is a list of commands you will probably find useful. 81 | 82 | ### `npm start` 83 | 84 | Runs the project in development/watch mode. The project will be rebuilt upon changes. TSDX has a special logger for you convenience. Error messages are pretty printed and formatted for compatibility VS Code's Problems tab. The library will be rebuilt if you make edits. 85 | 86 | ### `npm run build` 87 | 88 | Bundles the package to the `dist` folder. 89 | The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module). 90 | s 91 | 92 | ### `npm test` 93 | 94 | Runs the test watcher (Jest) in an interactive mode. 95 | By default, runs tests related to files changed since the last commit. 96 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | # Fail the status if coverage drops by >= 3% 6 | threshold: 3 7 | patch: 8 | default: 9 | threshold: 3 10 | 11 | comment: false 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "readme-box", 3 | "author": "Jason Etcovitch", 4 | "description": "Lil' helper for replacing a section of the contents of a README.", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/JasonEtco/readme-box" 10 | }, 11 | "scripts": { 12 | "start": "tsdx watch", 13 | "build": "tsdx build", 14 | "test": "tsdx test", 15 | "lint": "tsdx lint", 16 | "prepare": "tsdx build" 17 | }, 18 | "main": "dist/index.js", 19 | "typings": "dist/index.d.ts", 20 | "module": "dist/readme-box.esm.js", 21 | "files": [ 22 | "dist", 23 | "src" 24 | ], 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "pretty-quick --staged" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 80, 32 | "semi": false, 33 | "singleQuote": true 34 | }, 35 | "dependencies": { 36 | "@octokit/request": "^5.6.0" 37 | }, 38 | "devDependencies": { 39 | "husky": "^6.0.0", 40 | "nock": "^13.1.0", 41 | "pretty-quick": "^3.1.1", 42 | "tsdx": "^0.14.1", 43 | "tslib": "^2.3.0", 44 | "typescript": "^4.3.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { request } from '@octokit/request' 2 | 3 | export interface ReadmeBoxOpts { 4 | owner: string 5 | repo: string 6 | token: string 7 | branch?: string 8 | } 9 | 10 | export interface UpdateSectionOpts extends ReadmeBoxOpts { 11 | section: string 12 | message?: string 13 | emptyCommits?: boolean 14 | } 15 | 16 | export interface ReplaceSectionOpts { 17 | section: string 18 | newContents: string 19 | oldContents: string 20 | } 21 | 22 | export class ReadmeBox { 23 | public owner: string 24 | public repo: string 25 | public token: string 26 | public branch: string 27 | private request: typeof request 28 | 29 | constructor(opts: ReadmeBoxOpts) { 30 | this.owner = opts.owner 31 | this.repo = opts.repo 32 | this.token = opts.token 33 | this.branch = opts.branch || 'master' 34 | 35 | this.request = request.defaults({ 36 | headers: { 37 | authorization: `token ${this.token}` 38 | } 39 | }) 40 | } 41 | 42 | static async updateSection(newContents: string, opts: UpdateSectionOpts) { 43 | const box = new ReadmeBox(opts) 44 | 45 | // Get the README 46 | const { content, sha, path } = await box.getReadme() 47 | 48 | // Replace the old contents with the new 49 | const replaced = box.replaceSection({ 50 | section: opts.section, 51 | oldContents: content, 52 | newContents 53 | }) 54 | 55 | if (opts.emptyCommits !== true && content === replaced) { 56 | return 57 | } 58 | 59 | // Actually update the README 60 | return box.updateReadme({ 61 | content: replaced, 62 | message: opts.message, 63 | branch: opts.branch, 64 | sha, 65 | path 66 | }) 67 | } 68 | 69 | async getReadme() { 70 | const { data } = await this.request('GET /repos/:owner/:repo/readme', { 71 | owner: this.owner, 72 | repo: this.repo, 73 | ref: this.branch 74 | }) 75 | 76 | // The API returns the blob as base64 encoded, we need to decode it 77 | const encoded = data.content 78 | const decoded = Buffer.from(encoded, 'base64').toString('utf8') 79 | 80 | return { 81 | content: decoded, 82 | sha: data.sha, 83 | path: data.path 84 | } 85 | } 86 | 87 | async updateReadme(opts: { 88 | content: string 89 | sha: string 90 | path?: string 91 | message?: string 92 | branch?: string 93 | }) { 94 | return this.request('PUT /repos/:owner/:repo/contents/:path', { 95 | owner: this.owner, 96 | repo: this.repo, 97 | content: Buffer.from(opts.content).toString('base64'), 98 | path: opts.path || 'README.md', 99 | message: opts.message || 'Updating the README!', 100 | sha: opts.sha, 101 | branch: opts.branch || 'master' 102 | }) 103 | } 104 | 105 | getSection(section: string, content: string) { 106 | const { regex } = this.createRegExp(section) 107 | const match = content.match(regex) 108 | return match?.groups?.content 109 | } 110 | 111 | replaceSection(opts: ReplaceSectionOpts) { 112 | const { regex, start, end } = this.createRegExp(opts.section) 113 | 114 | if (!regex.test(opts.oldContents)) { 115 | throw new Error( 116 | `Contents do not contain start/end comments for section "${opts.section}"` 117 | ) 118 | } 119 | 120 | const newContentsWithComments = `${start}\n${opts.newContents}\n${end}` 121 | return opts.oldContents.replace(regex, newContentsWithComments) 122 | } 123 | 124 | private createRegExp(section: string) { 125 | const start = `` 126 | const end = `` 127 | const regex = new RegExp(`${start}\n(?:(?[\\s\\S]+)\n)?${end}`) 128 | return { regex, start, end } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | export const ReadmeContent = `# Header 2 | 3 | 4 | Old stuff... 5 | 6 | 7 | More stuff...` 8 | 9 | export const getReadme = { 10 | path: 'README.md', 11 | sha: '123abc', 12 | content: Buffer.from(ReadmeContent).toString('base64') 13 | } 14 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { ReadmeBox, UpdateSectionOpts } from '../src' 3 | import * as fixtures from './fixtures' 4 | 5 | function decode(string: string) { 6 | return Buffer.from(string, 'base64').toString('utf8') 7 | } 8 | 9 | describe('ReadmeBox', () => { 10 | let opts: UpdateSectionOpts 11 | let updateFileContentsUri: string 12 | let updateFileContentsParams: any 13 | let box: ReadmeBox 14 | 15 | beforeEach(() => { 16 | opts = { 17 | owner: 'JasonEtco', 18 | repo: 'readme-box', 19 | token: '123abc', 20 | section: 'example', 21 | branch: 'master' 22 | } 23 | 24 | box = new ReadmeBox(opts) 25 | 26 | nock('https://api.github.com') 27 | .get(`/repos/${opts.owner}/${opts.repo}/readme?ref=${opts.branch}`) 28 | .reply(200, fixtures.getReadme) 29 | .put(new RegExp(`/repos/${opts.owner}/${opts.repo}/contents/.*`)) 30 | .reply(200, (uri, body) => { 31 | updateFileContentsUri = uri 32 | updateFileContentsParams = body 33 | }) 34 | }) 35 | 36 | afterEach(() => { 37 | nock.cleanAll() 38 | updateFileContentsParams = null 39 | }) 40 | 41 | it('runs a test', () => { 42 | expect(box).toBeInstanceOf(ReadmeBox) 43 | }) 44 | 45 | describe('.updateSection', () => { 46 | it('calls the API requests and updates the section of the README', async () => { 47 | await ReadmeBox.updateSection('New content!', opts) 48 | expect(nock.isDone()).toBe(true) 49 | expect(decode(updateFileContentsParams.content)).toBe( 50 | fixtures.ReadmeContent.replace('Old stuff...', 'New content!') 51 | ) 52 | }) 53 | 54 | it('uses a custom commit message', async () => { 55 | opts.message = 'Custom commit message!' 56 | await ReadmeBox.updateSection('New content!', opts) 57 | expect(updateFileContentsParams.message).toBe(opts.message) 58 | }) 59 | 60 | it('does commit if `emptyCommits` is set to `true` and there are no changes', async () => { 61 | const result = await ReadmeBox.updateSection('Old stuff...', { 62 | ...opts, 63 | emptyCommits: true 64 | }) 65 | 66 | expect(result).toBeDefined() 67 | expect(nock.isDone()).toBe(true) 68 | }) 69 | 70 | it('does not create commit if `emptyCommits` is not set and there are no changes', async () => { 71 | const result = await ReadmeBox.updateSection('Old stuff...', opts) 72 | expect(result).toBeUndefined() 73 | expect(nock.isDone()).toBe(false) 74 | }) 75 | }) 76 | 77 | describe('#updateReadme', () => { 78 | it('updates the README', async () => { 79 | await box.updateReadme({ content: 'yep', sha: '123abc' }) 80 | expect(decode(updateFileContentsParams.content)).toBe('yep') 81 | }) 82 | 83 | it('uses the provided path', async () => { 84 | await box.updateReadme({ 85 | path: 'readme.markdown', 86 | content: 'yep', 87 | sha: '123abc' 88 | }) 89 | expect(updateFileContentsUri.endsWith('readme.markdown')).toBe(true) 90 | }) 91 | }) 92 | 93 | describe('#getSection', () => { 94 | it('returns the expected content', () => { 95 | const result = box.getSection('example', fixtures.ReadmeContent) 96 | expect(result).toBe('Old stuff...') 97 | }) 98 | 99 | it('returns undefined if nothing was found', () => { 100 | const result = box.getSection('nope', fixtures.ReadmeContent) 101 | expect(result).toBeUndefined() 102 | }) 103 | 104 | it('returns undefined if the section is empty', () => { 105 | const result = box.getSection( 106 | 'example', 107 | '\n' 108 | ) 109 | expect(result).toBeUndefined() 110 | }) 111 | }) 112 | 113 | describe('#replaceSection', () => { 114 | it("replaces the section's contents", () => { 115 | const result = box.replaceSection({ 116 | newContents: 'New content!', 117 | oldContents: fixtures.ReadmeContent, 118 | section: 'example' 119 | }) 120 | expect(result).toBe( 121 | fixtures.ReadmeContent.replace('Old stuff...', 'New content!') 122 | ) 123 | }) 124 | 125 | it('throws an error if the section was not found', () => { 126 | expect(() => 127 | box.replaceSection({ 128 | newContents: 'New content!', 129 | oldContents: 'Pizza', 130 | section: 'example' 131 | }) 132 | ).toThrowError( 133 | 'Contents do not contain start/end comments for section "example"' 134 | ) 135 | }) 136 | 137 | it('works when the comments are not separated by any content', () => { 138 | const result = box.replaceSection({ 139 | newContents: 'New content!', 140 | oldContents: '\n', 141 | section: 'example' 142 | }) 143 | expect(result).toBe( 144 | '\nNew content!\n' 145 | ) 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "*": ["src/*", "node_modules/*"] 19 | }, 20 | "jsx": "react", 21 | "esModuleInterop": true 22 | } 23 | } 24 | --------------------------------------------------------------------------------