├── .editorconfig ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── cicd.yml ├── .gitignore ├── .husky └── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc.js ├── CHANGELOG.md ├── CONTRIBUTING.mdx ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── demo.gif ├── jest.config.js ├── package-lock.json ├── package.json ├── release └── .gitkeep ├── scripts └── release.mjs ├── src ├── Logger.ts ├── __tests__ │ ├── cli.test.ts │ ├── md-inject.test.ts │ └── meta.test.ts ├── index.ts └── md-inject.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const prettierrc = require('./.prettierrc.js') 2 | 3 | module.exports = { 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint', 'prettier'], 6 | extends: [ 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:prettier/recommended', 9 | ], 10 | ignorePatterns: [ 11 | 'dist', 12 | // Who watches the watchers? 13 | '.eslintrc.js', 14 | ], 15 | rules: { 16 | // VS Code linting will not respect the `.prettierrc` options unless injected here: 17 | 'prettier/prettier': ['error', prettierrc], 18 | '@typescript-eslint/no-var-requires': 'off', 19 | '@typescript-eslint/no-explicit-any': 'off', 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # configuration options available at https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | 3 | # This configuration implements every package-ecosystem entry with the following logic: 4 | # - run Sunday's at midnight at a timeframe that allows TII team members to benefit from dependabot runs 5 | # - only manage direct dependencies, as opposed to transient dependencies which are harder to maintain at scale 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "bundler" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | day: "sunday" 14 | time: "00:00" 15 | timezone: "Asia/Kolkata" 16 | allow: 17 | - dependency-type: "direct" 18 | 19 | - package-ecosystem: "cargo" 20 | directory: "/" 21 | schedule: 22 | interval: "weekly" 23 | day: "sunday" 24 | time: "00:00" 25 | timezone: "Asia/Kolkata" 26 | allow: 27 | - dependency-type: "direct" 28 | 29 | - package-ecosystem: "composer" 30 | directory: "/" 31 | schedule: 32 | interval: "weekly" 33 | day: "sunday" 34 | time: "00:00" 35 | timezone: "Asia/Kolkata" 36 | allow: 37 | - dependency-type: "direct" 38 | 39 | - package-ecosystem: "docker" 40 | directory: "/" 41 | schedule: 42 | interval: "weekly" 43 | day: "sunday" 44 | time: "00:00" 45 | timezone: "Asia/Kolkata" 46 | allow: 47 | - dependency-type: "direct" 48 | 49 | - package-ecosystem: "mix" 50 | directory: "/" 51 | schedule: 52 | interval: "weekly" 53 | day: "sunday" 54 | time: "00:00" 55 | timezone: "Asia/Kolkata" 56 | allow: 57 | - dependency-type: "direct" 58 | 59 | - package-ecosystem: "elm" 60 | directory: "/" 61 | schedule: 62 | interval: "weekly" 63 | day: "sunday" 64 | time: "00:00" 65 | timezone: "Asia/Kolkata" 66 | allow: 67 | - dependency-type: "direct" 68 | 69 | - package-ecosystem: "gitsubmodule" 70 | directory: "/" 71 | schedule: 72 | interval: "weekly" 73 | day: "sunday" 74 | time: "00:00" 75 | timezone: "Asia/Kolkata" 76 | allow: 77 | - dependency-type: "direct" 78 | 79 | - package-ecosystem: "github-actions" 80 | directory: "/" 81 | schedule: 82 | interval: "weekly" 83 | day: "sunday" 84 | time: "00:00" 85 | timezone: "Asia/Kolkata" 86 | allow: 87 | - dependency-type: "direct" 88 | 89 | - package-ecosystem: "gomod" 90 | directory: "/" 91 | schedule: 92 | interval: "weekly" 93 | day: "sunday" 94 | time: "00:00" 95 | timezone: "Asia/Kolkata" 96 | allow: 97 | - dependency-type: "direct" 98 | 99 | - package-ecosystem: "gradle" 100 | directory: "/" 101 | schedule: 102 | interval: "weekly" 103 | day: "sunday" 104 | time: "00:00" 105 | timezone: "Asia/Kolkata" 106 | allow: 107 | - dependency-type: "direct" 108 | 109 | - package-ecosystem: "maven" 110 | directory: "/" 111 | schedule: 112 | interval: "weekly" 113 | day: "sunday" 114 | time: "00:00" 115 | timezone: "Asia/Kolkata" 116 | allow: 117 | - dependency-type: "direct" 118 | 119 | - package-ecosystem: "npm" 120 | directory: "/" 121 | schedule: 122 | interval: "weekly" 123 | day: "sunday" 124 | time: "00:00" 125 | timezone: "Asia/Kolkata" 126 | allow: 127 | - dependency-type: "direct" 128 | groups: 129 | eslint-and-prettier: 130 | patterns: 131 | - "*eslint*" 132 | - "*prettier*" 133 | 134 | - package-ecosystem: "nuget" 135 | directory: "/" 136 | schedule: 137 | interval: "weekly" 138 | day: "sunday" 139 | time: "00:00" 140 | timezone: "Asia/Kolkata" 141 | allow: 142 | - dependency-type: "direct" 143 | 144 | - package-ecosystem: "pip" 145 | directory: "/" 146 | schedule: 147 | interval: "weekly" 148 | day: "sunday" 149 | time: "00:00" 150 | timezone: "Asia/Kolkata" 151 | allow: 152 | - dependency-type: "direct" 153 | 154 | - package-ecosystem: "terraform" 155 | directory: "/" 156 | schedule: 157 | interval: "weekly" 158 | day: "sunday" 159 | time: "00:00" 160 | timezone: "Asia/Kolkata" 161 | allow: 162 | - dependency-type: "direct" 163 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CICD 2 | on: [push] 3 | jobs: 4 | Validate: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Clone Repo 8 | uses: actions/checkout@v4.1.7 9 | with: 10 | persist-credentials: false 11 | fetch-depth: 0 12 | 13 | - name: Set Up NodeJs 14 | uses: actions/setup-node@v4.0.2 15 | with: 16 | node-version-file: '.nvmrc' 17 | cache: npm 18 | 19 | - name: Install Dependencies 20 | run: npm ci 21 | 22 | - name: Lint 23 | run: npm run lint 24 | 25 | - name: Test 26 | run: npm test 27 | 28 | - name: Build 29 | run: npm run build 30 | 31 | - name: Cache Deployment 32 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 33 | uses: actions/cache@v4.0.2 34 | env: 35 | cache-name: deployment-cache 36 | with: 37 | path: './**/*' 38 | key: ${{ github.event.after }} 39 | 40 | CheckRelease: 41 | needs: Validate 42 | runs-on: ubuntu-latest 43 | if: ${{ github.ref == 'refs/heads/main' }} 44 | steps: 45 | - name: Load Deployment 46 | uses: actions/cache@v4.0.2 47 | env: 48 | cache-name: deployment-cache 49 | with: 50 | path: './**/*' 51 | key: ${{ github.event.after }} 52 | 53 | - name: Check PR Labels 54 | env: 55 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 56 | run: | 57 | npm run release:check 58 | 59 | Deploy: 60 | needs: CheckRelease 61 | runs-on: ubuntu-latest 62 | if: ${{ github.ref == 'refs/heads/main' }} 63 | steps: 64 | - name: Load Deployment 65 | uses: actions/cache@v4.0.2 66 | env: 67 | cache-name: deployment-cache 68 | with: 69 | path: './**/*' 70 | key: ${{ github.event.after }} 71 | 72 | - name: Configure Git 73 | env: 74 | GH_EMAIL: ${{ secrets.GH_EMAIL }} 75 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 76 | GH_USER: ${{ secrets.GH_USER }} 77 | run: | 78 | git config --local user.name "$GH_USER" 79 | git config --local user.email "$GH_EMAIL" 80 | git remote set-url origin "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY" 81 | 82 | - name: Validate Git Connection 83 | run: | 84 | git ls-remote origin > /dev/null 85 | 86 | - name: Create Git Release 87 | run: | 88 | npm run release 89 | 90 | - name: Push Git Release 91 | run: | 92 | git push --follow-tags origin $GITHUB_REF 93 | 94 | - name: Configure NPM 95 | env: 96 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 97 | run: | 98 | npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN" 99 | 100 | - name: Publish NPM Package 101 | id: publish 102 | run: | 103 | npm publish 104 | 105 | - name: Publish Release Notes 106 | env: 107 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 108 | run: | 109 | npm run release:post 110 | 111 | - name: Revert Git Release 112 | if: failure() && steps.publish.outcome == 'failure' 113 | run: | 114 | NEW_TAG=$(git tag --points-at HEAD) 115 | 116 | git tag -d $NEW_TAG 117 | git push --delete origin $NEW_TAG 118 | 119 | git revert --no-commit HEAD 120 | git commit -m "chore(release): Reverts failed publish $NEW_TAG [skip ci]" 121 | 122 | git push origin $GITHUB_REF 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary release files 2 | release/* 3 | !release/.gitkeep 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # TypeScript v1 declaration files 49 | typings/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.test 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | 82 | # Next.js build output 83 | .next 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | registry = https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.10.0 -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [4.1.7](https://github.com/target/markdown-inject/compare/v4.1.6...v4.1.7) (2024-07-01) 6 | 7 | ### [4.1.6](https://github.com/target/markdown-inject/compare/v4.1.5...v4.1.6) (2024-07-01) 8 | 9 | ### [4.1.5](https://github.com/target/markdown-inject/compare/v4.1.4...v4.1.5) (2024-07-01) 10 | 11 | ### [4.1.4](https://github.com/target/markdown-inject/compare/v4.1.3...v4.1.4) (2024-07-01) 12 | 13 | ### [4.1.3](https://github.com/target/markdown-inject/compare/v4.1.2...v4.1.3) (2024-07-01) 14 | 15 | ### [4.1.2](https://github.com/target/markdown-inject/compare/v4.1.1...v4.1.2) (2024-07-01) 16 | 17 | ### [4.1.1](https://github.com/target/markdown-inject/compare/v4.1.0...v4.1.1) (2024-05-31) 18 | 19 | ## [4.1.0](https://github.com/target/markdown-inject/compare/v4.0.3...v4.1.0) (2023-12-21) 20 | 21 | 22 | ### Features 23 | 24 | * add mdx support ([d09edae](https://github.com/target/markdown-inject/commit/d09edae7cd5d2a8e9abec1de5a3847c833aee8d3)) 25 | 26 | ### [4.0.3](https://github.com/target/markdown-inject/compare/v4.0.2...v4.0.3) (2023-12-06) 27 | 28 | ### [4.0.2](https://github.com/target/markdown-inject/compare/v4.0.1...v4.0.2) (2023-12-01) 29 | 30 | ### [4.0.1](https://github.com/target/markdown-inject/compare/v4.0.0...v4.0.1) (2023-07-07) 31 | 32 | ## [4.0.0](https://github.com/target/markdown-inject/compare/v3.1.4...v4.0.0) (2023-05-11) 33 | 34 | 35 | ### ⚠ BREAKING CHANGES 36 | 37 | * Exits with no action when called during a pull request build in CI 38 | 39 | ### Features 40 | 41 | * Exits with no action when called during a pull request build in CI ([3f07562](https://github.com/target/markdown-inject/commit/3f07562f5b1f09203944c304a7339d5caae6af3e)) 42 | 43 | ### [3.1.4](https://github.com/target/markdown-inject/compare/v3.1.3...v3.1.4) (2023-04-12) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * **devDependencies:** add overrides for deep jest@26 transient dependencies ([a40c75a](https://github.com/target/markdown-inject/commit/a40c75a3b18851fe90f58014b5317c1a404f86c7)) 49 | 50 | ### [3.1.3](https://github.com/target/markdown-inject/compare/v3.1.2...v3.1.3) (2023-04-11) 51 | 52 | ### [3.1.2](https://github.com/target/markdown-inject/compare/v3.1.1...v3.1.2) (2023-03-06) 53 | 54 | ### [3.1.1](https://github.com/target/markdown-inject/compare/v3.1.0...v3.1.1) (2023-01-13) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * **ci:** upgrade later workflow steps to same cache version ([659ede9](https://github.com/target/markdown-inject/commit/659ede94cb4a16f1a03d6c429df9625e17fd605a)) 60 | 61 | ## [3.1.0](https://github.com/target/markdown-inject/compare/v3.0.4...v3.1.0) (2022-12-20) 62 | 63 | 64 | ### Features 65 | 66 | * **ci:** upgrade actions, use nvmrc ([1caaa72](https://github.com/target/markdown-inject/commit/1caaa72e70b29c13b95b2a8186fb2bbc45cb5741)) 67 | * **node:** upgrade node ([dd9e43a](https://github.com/target/markdown-inject/commit/dd9e43a4869e8b6623300826f9a77524b4e5ba95)) 68 | 69 | ### [3.0.4](https://github.com/target/markdown-inject/compare/v3.0.3...v3.0.4) (2022-11-28) 70 | 71 | ### [3.0.3](https://github.com/target/markdown-inject/compare/v3.0.2...v3.0.3) (2022-11-15) 72 | 73 | ### [3.0.2](https://github.com/target/markdown-inject/compare/v3.0.1...v3.0.2) (2022-04-22) 74 | 75 | ### [3.0.1](https://github.com/target/markdown-inject/compare/v3.0.0...v3.0.1) (2022-04-22) 76 | 77 | ## [3.0.0](https://github.com/target/markdown-inject/compare/v2.0.0...v3.0.0) (2022-01-04) 78 | 79 | 80 | ### ⚠ BREAKING CHANGES 81 | 82 | * Throws an error when no output is emitted 83 | 84 | ### Features 85 | 86 | * Throws an error when no output is emitted ([a9602ed](https://github.com/target/markdown-inject/commit/a9602ed8983e678a2751a22de3fdd3d7e17073e9)) 87 | 88 | ## [2.0.0](https://github.com/target/markdown-inject/compare/v1.1.2...v2.0.0) (2021-12-03) 89 | 90 | 91 | ### ⚠ BREAKING CHANGES 92 | 93 | * **runtime:** Sets minimum node version of 12 94 | 95 | ### Features 96 | 97 | * **CLI:** Adds -all flag to alias './**/*.md' ([be0db10](https://github.com/target/markdown-inject/commit/be0db10ce479983b3051cf9ca66423f868d9d489)) 98 | 99 | 100 | * **runtime:** Sets minimum node version of 12 ([3c5902b](https://github.com/target/markdown-inject/commit/3c5902b7d952236cdec9f2c1a383de03b0ce1bac)) 101 | 102 | ### 1.1.2 (2021-11-26) 103 | 104 | ### 1.1.1 (2021-11-19) 105 | 106 | ## [1.1.0](https://github.com/target/markdown-inject/compare/v1.0.1...v1.1.0) (2021-10-08) 107 | 108 | 109 | ### Features 110 | 111 | * Adds environment variable controls ([497feb6](https://github.com/target/markdown-inject/commit/497feb603061aa790a3270987a260abed926219f)) 112 | 113 | ### [1.0.1](https://github.com/target/markdown-inject/compare/v1.0.0...v1.0.1) (2021-08-13) 114 | 115 | ## 1.0.0 (2021-08-06) 116 | 117 | 118 | ### ⚠ BREAKING CHANGES 119 | 120 | * **CLI:** Changes all CLI short flags to lowercase characters 121 | 122 | ### Features 123 | 124 | * **CLI:** Changes all CLI short flags to lowercase characters ([fbae5a7](https://github.com/target/markdown-inject/commit/fbae5a765590db898debf1403946d58a6688477f)) 125 | * **markdown-inject:** initial commit ([0781660](https://github.com/target/markdown-inject/commit/07816601bf99bfb2b363f1f0e342cca1edb4d5ae)) 126 | -------------------------------------------------------------------------------- /CONTRIBUTING.mdx: -------------------------------------------------------------------------------- 1 | ### Local Development 2 | 3 | This project builds with node version: 4 | 5 | {/* CODEBLOCK_START {"value": ".nvmrc", "hideValue": true} */} 6 | {/* prettier-ignore */} 7 | ~~~~~~~~~~bash 8 | v20.10.0 9 | ~~~~~~~~~~ 10 | 11 | {/* CODEBLOCK_END */} 12 | 13 | After cloning the repository, install dependencies and build the project: 14 | 15 | ``` 16 | npm ci 17 | ``` 18 | 19 | Build the library and watch for changes: 20 | 21 | ``` 22 | npm start 23 | ``` 24 | 25 | Link your local copy: 26 | 27 | ``` 28 | npm link 29 | ``` 30 | 31 | `markdown-inject` commands in any terminal will now run using your local copy. 32 | 33 | ### Validation 34 | 35 | This app ships with a local suite of [jest](https://jestjs.io/) tests, [eslint](https://eslint.org/) + [prettier](https://prettier.io/) configurations for code consistency and formatting, and [TypeScript](https://www.typescriptlang.org/) type validation. Each of these features can be validated using... 36 | 37 | ```bash 38 | npm test 39 | npm run lint 40 | npm run build 41 | ``` 42 | 43 | A `validate` utility script chains these calls together, and is called on every commit. 44 | 45 | ```bash 46 | npm run validate 47 | ``` 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2021 Target Brands, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | closes # 9 | 10 | # Changes 11 | 12 | 13 | 14 | 15 | 16 | # Validation 17 | 18 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markdown-inject 2 | 3 | Add file or command output to markdown documents. 4 | 5 | Functionality Demo 6 | 7 | ## Installation 8 | 9 | `markdown-inject` is written in TypeScript and distributed as a node module on the npm ecosystem. It exposes a bin executable, making it a command line offering. 10 | 11 | Download and invoke in one command: 12 | 13 | ``` 14 | npx markdown-inject 15 | ``` 16 | 17 | Local npm installation: 18 | 19 | ``` 20 | npm install markdown-inject --save-dev 21 | ``` 22 | 23 | or with Yarn: 24 | 25 | ``` 26 | yarn add markdown-inject --dev 27 | ``` 28 | 29 | Optionally, wire up `markdown-inject` to a git pre-commit hook tool like [husky](https://github.com/typicode/husky) to automatically update markdown injection as part of your workflow. 30 | 31 | ## Usage 32 | 33 | > Note: `markdown-inject` takes no action during pull request builds in CI. 34 | 35 | 43 | 44 | ~~~~~~~~~~bash 45 | Usage: markdown-inject [options] 46 | 47 | Examples: 48 | $ npx markdown-inject -a 49 | $ npx markdown-inject 'README.md' 50 | $ npx markdown-inject './**/*.{md,mdx}' 51 | 52 | Add file or command output to markdown documents. 53 | 54 | Options: 55 | -v, --version output the version number 56 | -a, --all applies a globPattern of './**/*.md' 57 | (default: false) 58 | -b, --block-prefix specifies the prefix for START and END HTML 59 | comment blocks (default: "CODEBLOCK") 60 | -n, --no-follow-symbolic-links prevents globs from following symlinks 61 | -q, --quiet emits no console log statements (default: 62 | false) 63 | -e, --no-system-environment prevents "command"s from receiving system 64 | environment 65 | -h, --help display help for command 66 | ~~~~~~~~~~ 67 | 68 | 69 | 70 | `markdown-inject` expands a given glob for markdown files. Then it discovers the below `CODEBLOCK` HTML or MDX (as shown in [CONTRIBUTING.mdx](./CONTRIBUTING.mdx)) comments within each markdown file, performs the appropriate action (in this case, reading another local file), and writes content back into the markdown file: 71 | 72 | 73 | 74 | ``` 75 | 76 | 77 | ``` 78 | 79 | 80 | 81 | ``` 82 | 83 | 84 | ~~~~~~~~~~bash 85 | File: .nvmrc 86 | 87 | v20.10.0 88 | ~~~~~~~~~~ 89 | 90 | 91 | ``` 92 | 93 | Output is written between the CODEBLOCK_START and CODEBLOCK_END comments. Output includes: 94 | 95 | - A prettier ignore comment introducing the output so that prettier does not further alter existing code. 96 | - A markdown codeblock is opened with the language specified via configuration. 97 | - The `: ` line is included by default, labeling the output. 98 | - The command or file output. 99 | 100 | Executing commands follows a similar syntax: 101 | 102 | ``` 103 | 104 | 105 | ~~~~~~~~~~bash 106 | $ echo hello world 107 | 108 | hello world 109 | ~~~~~~~~~~ 110 | 111 | 112 | ``` 113 | 114 | You can hide the `: ` comment from the generated output too: 115 | 116 | ``` 117 | 118 | 119 | ~~~~~~~~~~bash 120 | hello world 121 | ~~~~~~~~~~ 122 | 123 | 124 | ``` 125 | 126 | ### Environment 127 | 128 | System environment is automatically passed to `command`s: 129 | 130 | 141 | 142 | ~~~~~~~~~~bash 143 | $ echo "My home directory is: $HOME" 144 | 145 | My home directory is: /Users/me 146 | ~~~~~~~~~~ 147 | 148 | 149 | 150 | In some scenarios, passing system environment to `command`s may be undesirable. This functionality can be disabled using the [`--no-system-environment` CLI flag](#usage). This creates output such as: 151 | 152 | 162 | 163 | ~~~~~~~~~~bash 164 | $ echo "My password is: $MY_PASSWORD" 165 | 166 | My password is: 167 | ~~~~~~~~~~ 168 | 169 | 170 | 171 | Sometimes commands need a little extra nudging via environment to receive a usable output. Environment variables can be added using the `environment` key: 172 | 173 | 174 | 175 | ``` 176 | 186 | 187 | ``` 188 | 189 | 190 | 191 | The `environment` key can also be used to overwrite system environment variables with example values: 192 | 193 | ``` 194 | 204 | 205 | ~~~~~~~~~~bash 206 | $ echo "My password is: $MY_PASSWORD" 207 | 208 | My password is: 209 | ~~~~~~~~~~ 210 | 211 | 212 | ``` 213 | 214 | Environment variables with values which follow bash variable naming rules will be substituted into the `command` environment whether or not `--no-system-environment` is enabled. This can be useful for re-introducing necessary environment variables that would be omitted by `--no-system-environment`: 215 | 216 | 217 | 218 | ``` 219 | 229 | 230 | ~~~~~~~~~~bash 231 | $ echo "My home directory is: $HOME 232 | My password is: $MY_PASSWORD" 233 | 234 | My home directory is: /Users/me 235 | My password is: 236 | ~~~~~~~~~~ 237 | 238 | 239 | ``` 240 | 241 | 242 | 243 | ## Codeblock Configuration 244 | 245 | The `CODEBLOCK_START` HTML comment config block has the following properties: 246 | 247 | | Name | Type | Required | Default | Description | 248 | | ------------- | --------------------- | -------- | --------------------------------------------------- | -------------------------------------------------------------- | 249 | | `value` | `string` | `true` | | Command to execute or file to retrieve | 250 | | `environment` | `object` | `false` | `{}` | Run `command` executions with the given environment values | 251 | | `hideValue` | `boolean` | `false` | `false` | Do not display `File: foo.js` or `$ npx foo` on the first line | 252 | | `language` | `string` | `false` | `command`: `bash`, `file`: File extension | Syntax highlighting language | 253 | | `trim` | `boolean` | `false` | `true` | Trim whitespace from the ends of file or command output | 254 | | `type` | `'command' \| 'file'` | `false` | `'file'` | Type of execution | 255 | 256 | ## Contributing 257 | 258 | See [CONTRIBUTING.md](/CONTRIBUTING.md) for more information. 259 | 260 | ## Similar Projects 261 | 262 | - [embedme](https://github.com/zakhenry/embedme) - embed source files into markdown code blocks 263 | - [mdsh](https://github.com/zimbatm/mdsh) - a similar tool but for the Rust ecosystem 264 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/markdown-inject/f2919aa0d99763b579e65d9be31051ed99800d41/demo.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | testMatch: ['**/__tests__/**/(*.)+(spec|test).+(ts|js)'], 4 | transform: { 5 | '.+\\.(ts|tsx)$': 'ts-jest', 6 | }, 7 | resetMocks: true, 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-inject", 3 | "version": "4.1.7", 4 | "description": "Add file or command output to markdown documents.", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "markdown-inject": "dist/index.js" 8 | }, 9 | "files": [ 10 | "dist" 11 | ], 12 | "engines": { 13 | "node": ">=12" 14 | }, 15 | "scripts": { 16 | "start": "npm run build -- -w", 17 | "prebuild": "npm run clean", 18 | "build": "tsc --build", 19 | "clean": "rm -rf dist", 20 | "lint": "eslint .", 21 | "test": "jest", 22 | "prepare": "husky install", 23 | "validate": "npm run lint && npm test && npm run build", 24 | "markdown-inject": "node dist -a", 25 | "release": "npx standard-version --releaseCommitMessageFormat \"chore(release): {{currentTag}} [skip ci]\" --scripts.postchangelog \"npm run release:save\"", 26 | "release:check": "node scripts/release.mjs check", 27 | "release:save": "node scripts/release.mjs save release/new-release.md", 28 | "release:post": "node scripts/release.mjs post release/new-release.md" 29 | }, 30 | "dependencies": { 31 | "chalk": "5.3.0", 32 | "commander": "7.2.0", 33 | "env-ci": "7.3.0", 34 | "fs-extra": "9.1.0", 35 | "globby": "11.0.4" 36 | }, 37 | "devDependencies": { 38 | "@octokit/rest": "18.12.0", 39 | "@types/commander": "2.12.2", 40 | "@types/env-ci": "3.1.1", 41 | "@types/fs-extra": "9.0.11", 42 | "@types/jest": "29.5.1", 43 | "@types/node": "15.12.2", 44 | "@types/shell-quote": "1.7.1", 45 | "@typescript-eslint/eslint-plugin": "4.22.0", 46 | "@typescript-eslint/parser": "4.22.0", 47 | "eslint": "7.24.0", 48 | "eslint-config-prettier": "8.2.0", 49 | "eslint-plugin-prettier": "3.4.0", 50 | "husky": "6.0.0", 51 | "jest": "29.5.0", 52 | "prettier": "2.2.1", 53 | "shell-quote": "1.7.3", 54 | "standard-version": "9.3.2", 55 | "ts-jest": "29.1.0", 56 | "ts-node": "9.1.1", 57 | "typescript": "5.5.2" 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "git@github.com:target/markdown-inject" 62 | }, 63 | "keywords": [ 64 | "markdown", 65 | "inject", 66 | "markdown inject" 67 | ], 68 | "author": "", 69 | "overrides": { 70 | "json5": "2.2.3", 71 | "decode-uri-component": "0.2.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /release/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/markdown-inject/f2919aa0d99763b579e65d9be31051ed99800d41/release/.gitkeep -------------------------------------------------------------------------------- /scripts/release.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { execSync } from 'child_process' 3 | import { Octokit } from '@octokit/rest' 4 | 5 | const [, , action, fileName] = process.argv 6 | 7 | const actions = { 8 | check: async () => { 9 | const { 10 | GITHUB_REPOSITORY, 11 | GH_TOKEN, 12 | GITHUB_SHA, 13 | GITHUB_RUN_ID, 14 | } = process.env 15 | 16 | const { owner, repo } = 17 | /^(?[^/]+)\/(?.*)$/.exec(GITHUB_REPOSITORY)?.groups || {} 18 | 19 | const gh = new Octokit({ auth: GH_TOKEN }) 20 | 21 | const searchParams = { 22 | is: 'pr', 23 | repo: GITHUB_REPOSITORY, 24 | sha: GITHUB_SHA, 25 | } 26 | 27 | const searchQuery = Object.entries(searchParams) 28 | .map(([key, value]) => `${key}:${value}`) 29 | .join(' ') 30 | 31 | const { 32 | data: { items: prs }, 33 | } = await gh.search.issuesAndPullRequests({ 34 | q: searchQuery, 35 | }) 36 | 37 | const skipReleaseLabel = 'skip release' 38 | if ( 39 | // if any PRs attributed to this SHA have a `skip release` label 40 | prs.some(({ labels }) => 41 | labels.find(({ name }) => name === skipReleaseLabel) 42 | ) 43 | ) { 44 | console.log( 45 | `Identified matching PR with "${skipReleaseLabel}" label. Cancelling deployment.` 46 | ) 47 | try { 48 | await gh.actions.cancelWorkflowRun({ 49 | owner, 50 | repo, 51 | run_id: GITHUB_RUN_ID, 52 | }) 53 | 54 | // Block the workflow permanently until it is shut down by GitHub 55 | await new Promise(() => null) 56 | } catch (err) { 57 | if (err) { 58 | console.log('Error while cancelling workflow run') 59 | return process.exit(1) 60 | } 61 | } 62 | } 63 | }, 64 | save: async () => { 65 | const changelogDiff = execSync('git diff CHANGELOG.md', { 66 | encoding: 'utf-8', 67 | }) 68 | 69 | const additionRegex = /^\+/ 70 | const changelogAdditions = changelogDiff 71 | .split('\n') // evaluate each line 72 | .filter((line) => additionRegex.test(line)) // take only lines that begin with + 73 | .slice(1) // toss the first one (`+++ b/CHANGELOG.md`) 74 | .map((line) => line.replace(additionRegex, '')) // remove the leading `+`s 75 | .join('\n') // pull it all together 76 | 77 | fs.writeFileSync(fileName, changelogAdditions) 78 | }, 79 | post: async () => { 80 | const { GITHUB_REPOSITORY, GH_TOKEN } = process.env 81 | 82 | const { owner, repo } = 83 | /^(?[^/]+)\/(?.*)$/.exec(GITHUB_REPOSITORY)?.groups || {} 84 | 85 | const releaseText = fs.readFileSync(fileName, { 86 | encoding: 'utf-8', 87 | }) 88 | 89 | const currentTag = execSync('git tag --points-at HEAD', { 90 | encoding: 'utf-8', 91 | }).trim() 92 | 93 | const gh = new Octokit({ auth: GH_TOKEN }) 94 | 95 | try { 96 | await gh.repos.createRelease({ 97 | owner, 98 | repo, 99 | tag_name: currentTag, 100 | name: currentTag.replace(/^v/i, ''), 101 | body: releaseText, 102 | }) 103 | } catch { 104 | console.log('Error while creating release') 105 | return process.exit(1) 106 | } 107 | }, 108 | } 109 | 110 | if (actions[action]) { 111 | await actions[action]() 112 | } else { 113 | throw new Error( 114 | `Unrecognized action "${action}". Valid actions:\n${Object.keys(actions) 115 | .map((t) => `- ${t}`) 116 | .join('\n')}` 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/Logger.ts: -------------------------------------------------------------------------------- 1 | import { Console } from 'console' 2 | import { Writable } from 'stream' 3 | 4 | class Logger extends Console { 5 | constructor(quiet = false) { 6 | super(quiet ? new Writable() : process.stdout) 7 | } 8 | } 9 | 10 | export default Logger 11 | -------------------------------------------------------------------------------- /src/__tests__/cli.test.ts: -------------------------------------------------------------------------------- 1 | import shellQuote from 'shell-quote' 2 | import injectMarkdown from '../md-inject' 3 | 4 | const baseProcess = process 5 | 6 | jest.mock('../md-inject') 7 | jest.spyOn(console, 'error') 8 | jest.spyOn(process.stdout, 'write') 9 | 10 | describe('CLI', () => { 11 | beforeEach(async () => { 12 | /* eslint-disable-next-line */ 13 | /* @ts-ignore */ 14 | process.exit = jest.fn() 15 | }) 16 | 17 | afterEach(() => { 18 | process = baseProcess 19 | }) 20 | 21 | it('returns help text when no glob pattern is passed', async () => { 22 | await invokeCli('markdown-inject') 23 | 24 | expect(process.stdout.write).toHaveBeenCalledWith( 25 | expect.stringMatching('Usage: markdown-inject') 26 | ) 27 | expect(injectMarkdown).not.toHaveBeenCalled() 28 | expect(process.exit).toHaveBeenCalledWith(0) 29 | }) 30 | 31 | it('passes ./**/*.md when -a parameter is supplied', async () => { 32 | await invokeCli('markdown-inject -a') 33 | 34 | expect(injectMarkdown).toHaveBeenCalledWith( 35 | expect.objectContaining({ 36 | globPattern: './**/*.md', 37 | }) 38 | ) 39 | }) 40 | 41 | it.each([[`'./**/CHANGELOG.md' -a`], [`-a './**/CHANGELOG.md'`]])( 42 | 'throws when -a and a globPattern are provided', 43 | async (mdiArgs) => { 44 | console.error = jest.fn() 45 | 46 | await invokeCli(`markdown-inject ${mdiArgs}`) 47 | 48 | expect(injectMarkdown).not.toHaveBeenCalled() 49 | expect(console.error).toHaveBeenCalledWith( 50 | "Options -a / --all and a globPattern ('./**/CHANGELOG.md') can not be provided together. Please select one or the other." 51 | ) 52 | expect(process.exit).toHaveBeenCalledWith(1) 53 | } 54 | ) 55 | 56 | it('-a does not write the help text', async () => { 57 | await invokeCli('markdown-inject -a') 58 | 59 | expect(process.stdout.write).not.toHaveBeenCalledWith( 60 | expect.stringMatching('Usage: markdown-inject') 61 | ) 62 | }) 63 | }) 64 | 65 | const invokeCli = async (args: string) => { 66 | const [node] = process.argv 67 | process.argv = [node, ...(shellQuote.parse(args) as string[])] 68 | 69 | jest.isolateModules(() => { 70 | require('../index') 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /src/__tests__/md-inject.test.ts: -------------------------------------------------------------------------------- 1 | import injectMarkdown from '../md-inject' 2 | 3 | import { exec as baseExec } from 'child_process' 4 | import baseEnvCi from 'env-ci' 5 | import baseFs from 'fs-extra' 6 | import baseGlob from 'globby' 7 | 8 | jest.mock('child_process') 9 | const exec: jest.Mock = jest.mocked(baseExec) 10 | 11 | jest.mock('env-ci') 12 | const envCi: jest.Mock = jest.mocked(baseEnvCi) 13 | 14 | const logger = Object.fromEntries( 15 | Object.entries(console).map(([consoleProperty, consolePropertyValue]) => [ 16 | consoleProperty, 17 | typeof consolePropertyValue === 'function' 18 | ? jest.fn() 19 | : consolePropertyValue, 20 | ]) 21 | ) 22 | jest.mock('../Logger', () => ({ 23 | __esModule: true, 24 | default: class { 25 | constructor() { 26 | return logger 27 | } 28 | }, 29 | })) 30 | 31 | const glob: jest.Mock = jest.mocked(baseGlob) 32 | jest.mock('globby') 33 | 34 | const fs: { 35 | readFile: jest.Mock 36 | writeFile: jest.Mock 37 | } = jest.mocked(baseFs) 38 | jest.mock('fs-extra') 39 | 40 | const originalProcessEnv = process.env 41 | 42 | describe('Markdown injection', () => { 43 | beforeEach(async () => { 44 | exec.mockImplementation((...args) => { 45 | const cb = args.pop() 46 | const err: any = null 47 | const stdout = '' 48 | cb(err, stdout) 49 | }) 50 | 51 | envCi.mockImplementation(() => ({ 52 | isCi: false, 53 | isPr: false, 54 | })) 55 | 56 | jest.clearAllMocks() 57 | 58 | process.env = originalProcessEnv 59 | }) 60 | 61 | it('warns and exits with no action on pull request', async () => { 62 | envCi.mockImplementation(() => ({ 63 | isCi: true, 64 | isPr: true, 65 | })) 66 | 67 | await injectMarkdown() 68 | 69 | expect(logger.warn).toHaveBeenCalledWith( 70 | expect.stringContaining('not run during pull') 71 | ) 72 | // Exit code is not non-0 73 | expect([null, undefined, 0]).toContain(process.exitCode) 74 | expect(glob).not.toHaveBeenCalled() 75 | }) 76 | 77 | it('does not warn / exit early in CI on non-PR builds', async () => { 78 | envCi.mockImplementation(() => ({ 79 | isCi: true, 80 | isPr: false, 81 | })) 82 | 83 | await injectMarkdown() 84 | 85 | expect(logger.warn).not.toHaveBeenCalledWith( 86 | expect.stringContaining('not run during pull') 87 | ) 88 | expect(glob).toHaveBeenCalled() 89 | }) 90 | 91 | it('collects all in-repo markdown files', async () => { 92 | await injectMarkdown() 93 | 94 | expect(glob).toHaveBeenCalledWith( 95 | '**/*.md', 96 | expect.objectContaining({ gitignore: true }) 97 | ) 98 | }) 99 | 100 | it('throws gracefully when an error occurs while reading the file', async () => { 101 | fs.readFile.mockRejectedValue('some error') 102 | glob.mockResolvedValue(['foo.md']) 103 | 104 | await injectMarkdown() 105 | 106 | expect(logger.error).toHaveBeenCalledWith('foo.md: Error reading file') 107 | expect(logger.error).toHaveBeenCalledWith('some error') 108 | expect(process.exitCode).toBe(1) 109 | }) 110 | 111 | it('does nothing', async () => { 112 | glob.mockResolvedValue(['foo.md']) 113 | fs.readFile.mockResolvedValue('# Foo') 114 | 115 | await injectMarkdown() 116 | 117 | expect(fs.writeFile).not.toHaveBeenCalled() 118 | }) 119 | 120 | it('reads all files', async () => { 121 | glob.mockResolvedValue(['foo.md', 'bar.md', 'baz.md', 'qux.md']) 122 | fs.readFile.mockResolvedValue('# Foo') 123 | 124 | await injectMarkdown() 125 | 126 | expect(fs.readFile).toHaveBeenCalledWith('foo.md', { encoding: 'utf-8' }) 127 | expect(fs.readFile).toHaveBeenCalledWith('bar.md', { encoding: 'utf-8' }) 128 | expect(fs.readFile).toHaveBeenCalledWith('baz.md', { encoding: 'utf-8' }) 129 | expect(fs.readFile).toHaveBeenCalledWith('qux.md', { encoding: 'utf-8' }) 130 | }) 131 | 132 | it('throws gracefully when the config is malformed', async () => { 133 | glob.mockResolvedValue(['foo.md']) 134 | fs.readFile.mockResolvedValue( 135 | '' 136 | ) 137 | 138 | await injectMarkdown() 139 | 140 | expect(logger.error).toHaveBeenCalledWith( 141 | 'Error parsing config:\n{foo: bar}' 142 | ) 143 | expect(logger.error).toHaveBeenCalledWith( 144 | expect.objectContaining({ 145 | message: expect.stringMatching( 146 | /Unexpected token|Expected property name/ 147 | ), 148 | }) 149 | ) 150 | expect(process.exitCode).toBe(1) 151 | }) 152 | 153 | it('throws if an invalid block type is passed', async () => { 154 | mock({ 155 | config: { 156 | type: 'git', 157 | }, 158 | }) 159 | 160 | await injectMarkdown() 161 | 162 | expect(logger.error).toHaveBeenCalledWith( 163 | expect.objectContaining({ 164 | message: 165 | 'Unexpected "type" of "git". Valid types are "command", "file"', 166 | }) 167 | ) 168 | expect(process.exitCode).toBe(1) 169 | }) 170 | 171 | it('runs an arbitrary command', async () => { 172 | mock({ 173 | config: { 174 | type: 'command', 175 | value: 'some arbitrary command', 176 | }, 177 | }) 178 | 179 | await injectMarkdown() 180 | 181 | expect(exec).toHaveBeenCalledWith( 182 | 'some arbitrary command', 183 | expect.anything(), // config 184 | expect.anything() // callback 185 | ) 186 | }) 187 | 188 | it('imports a file', async () => { 189 | mock({ 190 | mockFileName: 'foo.md', 191 | config: { 192 | type: 'file', 193 | value: 'bar.js', 194 | }, 195 | }) 196 | 197 | await injectMarkdown() 198 | 199 | expect(fs.readFile).toHaveBeenCalledWith('foo.md', { encoding: 'utf-8' }) 200 | expect(fs.readFile).toHaveBeenCalledWith( 201 | expect.stringContaining('bar.js'), 202 | { encoding: 'utf-8' } 203 | ) 204 | }) 205 | 206 | it('defaults to file import type', async () => { 207 | mock({ 208 | mockFileName: 'foo.md', 209 | config: { 210 | value: 'bar.js', 211 | }, 212 | }) 213 | 214 | await injectMarkdown() 215 | 216 | expect(fs.readFile).toHaveBeenCalledWith('foo.md', { encoding: 'utf-8' }) 217 | expect(fs.readFile).toHaveBeenCalledWith( 218 | expect.stringContaining('bar.js'), 219 | { encoding: 'utf-8' } 220 | ) 221 | }) 222 | 223 | it.each([ 224 | [ 225 | ``, 226 | ], 227 | [ 228 | ``, 229 | ], 230 | [ 231 | ` 232 | 239 | 240 | 243 | `, 244 | ], 245 | [ 246 | ``, 247 | ], 248 | [ 249 | ``, 250 | ], 251 | [ 252 | ``, 253 | ], 254 | [ 255 | ` `, 256 | ], 257 | [ 258 | `Foo`, 259 | ], 260 | [ 261 | ` Foo `, 262 | ], 263 | [ 264 | ` 265 | `, 266 | ], 267 | [ 268 | ` 269 | Foo`, 270 | ], 271 | [ 272 | ` 273 | Foo 274 | `, 275 | ], 276 | [ 277 | ` 278 | Foo 279 | `, 280 | ], 281 | [ 282 | `{/* CODEBLOCK_START {"type": "command", "value": "some arbitrary command"} */} Foo {/* CODEBLOCK_END */}`, 283 | ], 284 | ])('handles wonky formatting', async (markdownContent) => { 285 | glob.mockResolvedValue(['foo.md']) 286 | fs.readFile.mockResolvedValue(markdownContent) 287 | 288 | await injectMarkdown() 289 | 290 | expect(exec).toHaveBeenCalledTimes(1) 291 | expect(exec).toHaveBeenCalledWith( 292 | 'some arbitrary command', 293 | expect.anything(), 294 | expect.anything() 295 | ) 296 | }) 297 | 298 | it('writes to the markdown document (command)', async () => { 299 | mock({ 300 | config: { 301 | type: 'command', 302 | value: 'some arbitrary command', 303 | }, 304 | mockResponse: 'The output of some arbitrary command', 305 | }) 306 | 307 | await injectMarkdown() 308 | 309 | const outFile = ` 310 | 311 | 312 | ~~~~~~~~~~bash 313 | $ some arbitrary command 314 | 315 | The output of some arbitrary command 316 | ~~~~~~~~~~ 317 | 318 | ` 319 | expect(fs.writeFile).toHaveBeenCalledWith('foo.md', outFile) 320 | }) 321 | 322 | it('writes to the markdown document (command) with mdx syntax', async () => { 323 | mock({ 324 | mockFileName: 'foo.mdx', 325 | config: { 326 | type: 'command', 327 | value: 'some arbitrary command', 328 | }, 329 | mockResponse: 'The output of some arbitrary command', 330 | }) 331 | 332 | await injectMarkdown() 333 | 334 | const outFile = ` 335 | {/* CODEBLOCK_START {"type":"command","value":"some arbitrary command"} */} 336 | {/* prettier-ignore */} 337 | ~~~~~~~~~~bash 338 | $ some arbitrary command 339 | 340 | The output of some arbitrary command 341 | ~~~~~~~~~~ 342 | 343 | {/* CODEBLOCK_END */}` 344 | expect(fs.writeFile).toHaveBeenCalledWith('foo.mdx', outFile) 345 | }) 346 | 347 | it('fails to write to the markdown document (command) with mixed syntax', async () => { 348 | const inFile = ` 349 | {/* CODEBLOCK_START {"type":"command","value":"some arbitrary command"} */} 350 | 351 | {/* CODEBLOCK_END */}` 352 | 353 | const inFileName = ` 354 | ~~~~~~~~~~bash 355 | $ some arbitrary command 356 | 357 | The output of some arbitrary command 358 | ~~~~~~~~~~` 359 | 360 | glob.mockResolvedValue([inFileName]) 361 | 362 | fs.readFile.mockImplementation(async (fileName) => { 363 | if (fileName === inFileName) { 364 | return inFile 365 | } 366 | throw new Error('Unexpected file name passed') 367 | }) 368 | 369 | await injectMarkdown() 370 | 371 | expect(fs.readFile).toHaveBeenCalledWith(inFileName, { encoding: 'utf-8' }) 372 | 373 | expect(fs.writeFile).not.toHaveBeenCalled() 374 | }) 375 | 376 | it('does not write to the markdown document (command) because of bad syntax', async () => { 377 | const inFile = ` 378 | 383 | ~~~~~~~~~~bash 384 | $ some arbitrary command 385 | 386 | The output of some arbitrary command 387 | ~~~~~~~~~~` 388 | 389 | glob.mockResolvedValue([inFileName]) 390 | 391 | fs.readFile.mockImplementation(async (fileName) => { 392 | if (fileName === inFileName) { 393 | return inFile 394 | } 395 | throw new Error('Unexpected file name passed') 396 | }) 397 | 398 | await injectMarkdown() 399 | 400 | expect(fs.readFile).toHaveBeenCalledWith(inFileName, { encoding: 'utf-8' }) 401 | 402 | expect(fs.writeFile).not.toHaveBeenCalled() 403 | }) 404 | 405 | it('writes to the markdown document (file)', async () => { 406 | mock({ 407 | config: { 408 | type: 'file', 409 | value: 'bar.js', 410 | }, 411 | mockResponse: `console.log('baz')`, 412 | }) 413 | 414 | await injectMarkdown() 415 | 416 | const outFile = ` 417 | 418 | 419 | ~~~~~~~~~~js 420 | File: bar.js 421 | 422 | console.log('baz') 423 | ~~~~~~~~~~ 424 | 425 | ` 426 | expect(fs.writeFile).toHaveBeenCalledWith('foo.md', outFile) 427 | }) 428 | 429 | it('trims whitespace (command)', async () => { 430 | mock({ 431 | config: { 432 | type: 'command', 433 | value: 'some arbitrary command', 434 | }, 435 | mockResponse: ` 436 | 437 | 438 | The output of some arbitrary command 439 | 440 | 441 | `, 442 | }) 443 | 444 | await injectMarkdown() 445 | 446 | expect(fs.writeFile).toHaveBeenCalledWith( 447 | 'foo.md', 448 | expect.stringMatching( 449 | /[^\n]\n{2}The output of some arbitrary command\n[^\n]/ 450 | ) 451 | ) 452 | }) 453 | 454 | it('trims whitespace (file)', async () => { 455 | mock({ 456 | config: { 457 | value: 'bar.js', 458 | }, 459 | mockResponse: ` 460 | 461 | 462 | 463 | console.log('baz') 464 | 465 | 466 | 467 | 468 | 469 | `, 470 | }) 471 | 472 | await injectMarkdown() 473 | 474 | expect(fs.writeFile).toHaveBeenCalledWith( 475 | 'foo.md', 476 | expect.stringMatching(/[^\n]\n{2}console\.log\('baz'\)\n{1}[^\n]/) 477 | ) 478 | }) 479 | 480 | it('can retain whitespace (command)', async () => { 481 | mock({ 482 | config: { 483 | type: 'command', 484 | value: 'some arbitrary command', 485 | trim: false, 486 | }, 487 | mockResponse: ` 488 | 489 | 490 | The output of some arbitrary command 491 | 492 | `, 493 | }) 494 | 495 | await injectMarkdown() 496 | 497 | expect(fs.writeFile).toHaveBeenCalledWith( 498 | 'foo.md', 499 | expect.stringMatching(/\n{3,}The output of some arbitrary command\n{2,}/) 500 | ) 501 | }) 502 | 503 | it('can retain whitespace (file)', async () => { 504 | mock({ 505 | config: { 506 | value: 'bar.js', 507 | trim: false, 508 | }, 509 | mockResponse: ` 510 | 511 | 512 | 513 | console.log('baz') 514 | 515 | 516 | 517 | 518 | 519 | `, 520 | }) 521 | 522 | await injectMarkdown() 523 | 524 | expect(fs.writeFile).toHaveBeenCalledWith( 525 | 'foo.md', 526 | expect.stringMatching(/\n{4,}console\.log\('baz'\)\n{6,}/) 527 | ) 528 | }) 529 | 530 | it('displays the input command', async () => { 531 | mock({ 532 | config: { 533 | type: 'command', 534 | value: 'some arbitrary command', 535 | }, 536 | mockResponse: 'some arbitrary stdout', 537 | }) 538 | 539 | await injectMarkdown() 540 | 541 | expect(fs.writeFile).toHaveBeenCalledWith( 542 | 'foo.md', 543 | expect.stringContaining('$ some arbitrary command') 544 | ) 545 | }) 546 | 547 | it('displays the input file', async () => { 548 | mock({ 549 | config: { 550 | value: 'bar.js', 551 | }, 552 | mockResponse: 553 | 'Weight lifting. Lawyer regulatory board. Pole vaulter’s nemesis', 554 | }) 555 | 556 | await injectMarkdown() 557 | 558 | expect(fs.writeFile).toHaveBeenCalledWith( 559 | 'foo.md', 560 | expect.stringContaining('File: bar.js') 561 | ) 562 | }) 563 | 564 | it('can hide the input command', async () => { 565 | mock({ 566 | config: { 567 | type: 'command', 568 | value: 'some arbitrary command', 569 | hideValue: true, 570 | }, 571 | mockResponse: 'some arbitrary stdout', 572 | }) 573 | 574 | await injectMarkdown() 575 | 576 | expect(fs.writeFile).toHaveBeenCalledWith( 577 | 'foo.md', 578 | expect.not.stringContaining('$ some arbitrary command') 579 | ) 580 | }) 581 | 582 | it('can hide the input file', async () => { 583 | mock({ 584 | config: { 585 | value: 'bar.js', 586 | hideValue: true, 587 | }, 588 | mockResponse: 'Speakeasies', 589 | }) 590 | 591 | await injectMarkdown() 592 | 593 | expect(fs.writeFile).toHaveBeenCalledWith( 594 | 'foo.md', 595 | expect.not.stringMatching('File: bar.js') 596 | ) 597 | }) 598 | 599 | it('can select a language (file)', async () => { 600 | mock({ 601 | config: { 602 | value: 'bar.js', 603 | language: 'coffeescript', // :shrug: 604 | }, 605 | mockResponse: 'Coffee bar?', 606 | }) 607 | 608 | await injectMarkdown() 609 | 610 | expect(fs.writeFile).toHaveBeenCalledWith( 611 | 'foo.md', 612 | expect.stringMatching(/^~{10}coffeescript$/m) 613 | ) 614 | }) 615 | 616 | it('can select a language (command)', async () => { 617 | mock({ 618 | config: { 619 | type: 'command', 620 | value: 'npm view react-scripts --json', 621 | language: 'json', 622 | }, 623 | mockResponse: '{ "version": "17.x" }', 624 | }) 625 | 626 | await injectMarkdown() 627 | 628 | expect(fs.writeFile).toHaveBeenCalledWith( 629 | 'foo.md', 630 | expect.stringMatching(/^~{10}json$/m) 631 | ) 632 | }) 633 | 634 | it('language is inferred from file extension', async () => { 635 | mock({ 636 | config: { 637 | value: 'bar.sh', 638 | }, 639 | mockResponse: 'echo "bar"', 640 | }) 641 | 642 | await injectMarkdown() 643 | 644 | expect(fs.writeFile).toHaveBeenCalledWith( 645 | 'foo.md', 646 | expect.stringMatching(/^~{10}sh$/m) 647 | ) 648 | }) 649 | 650 | it('language defaults to bash when unspecified', async () => { 651 | mock({ 652 | config: { 653 | type: 'command', 654 | value: 'some arbitrary command', 655 | }, 656 | mockResponse: 'some arbitrary stdout', 657 | }) 658 | 659 | await injectMarkdown() 660 | 661 | expect(fs.writeFile).toHaveBeenCalledWith( 662 | 'foo.md', 663 | expect.stringMatching(/^~{10}bash$/m) 664 | ) 665 | }) 666 | 667 | it('language defaults to bash when it can not be inferred', async () => { 668 | mock({ 669 | config: { 670 | value: 'shell-scripts/foo', 671 | }, 672 | mockResponse: 'something', 673 | }) 674 | 675 | await injectMarkdown() 676 | 677 | expect(fs.writeFile).toHaveBeenCalledWith( 678 | 'foo.md', 679 | expect.stringMatching(/^~{10}bash$/m) 680 | ) 681 | }) 682 | 683 | it('writes over content that already exists', async () => { 684 | mock({ 685 | config: { 686 | value: 'shell-scripts/foo', 687 | }, 688 | blockContents: `~~~~~~~~~~bash 689 | File: shell-scripts/foo 690 | 691 | echo "Hello America" 692 | ~~~~~~~~~~ 693 | `, 694 | mockResponse: 'echo "Hello World"', 695 | }) 696 | 697 | await injectMarkdown() 698 | 699 | expect(fs.writeFile).toHaveBeenCalledWith( 700 | 'foo.md', 701 | expect.stringMatching('Hello World') 702 | ) 703 | }) 704 | 705 | it('does not perform a write if no change was made', async () => { 706 | mock({ 707 | config: { 708 | value: 'shell-scripts/foo', 709 | }, 710 | blockContents: `~~~~~~~~~~bash 711 | File: shell-scripts/foo 712 | 713 | echo "Hello World" 714 | ~~~~~~~~~~ 715 | `, 716 | mockResponse: 'echo "Hello World"', 717 | }) 718 | 719 | await injectMarkdown() 720 | 721 | expect(fs.writeFile).not.toHaveBeenCalled() 722 | }) 723 | 724 | it('prevents prettier auto-formatting of code block and interior syntax', async () => { 725 | mock({ 726 | config: { 727 | value: 'bar.js', 728 | }, 729 | mockResponse: 'module.exports = () => console.log("5:00")', 730 | }) 731 | 732 | await injectMarkdown() 733 | 734 | expect(fs.writeFile).toHaveBeenCalledWith( 735 | 'foo.md', 736 | expect.stringMatching(/\n~{10}/) 737 | ) 738 | }) 739 | 740 | it('can ignore a block', async () => { 741 | mock({ 742 | name: '_IGNORE', 743 | config: { 744 | value: 'bar.js', 745 | ignore: true, 746 | }, 747 | }) 748 | 749 | await injectMarkdown() 750 | 751 | expect(fs.writeFile).not.toHaveBeenCalled() 752 | }) 753 | 754 | it('ignores nested blocks', async () => { 755 | mock({ 756 | name: '_IGNORE', 757 | config: { 758 | value: 'bar.js', 759 | ignore: true, 760 | }, 761 | blockContents: `~~~ 762 | 763 | 764 | 765 | ~~~`, 766 | }) 767 | 768 | await injectMarkdown() 769 | 770 | expect(fs.writeFile).not.toHaveBeenCalled() 771 | }) 772 | 773 | it('supports block naming', async () => { 774 | mock({ 775 | name: '_NAMED', 776 | config: { 777 | value: 'bar.js', 778 | }, 779 | blockContents: ` 780 | 781 | {/* CODEBLOCK_END */} 782 | 783 | 784 | `, 785 | mockResponse: 'console.log("👋")', 786 | }) 787 | 788 | await injectMarkdown() 789 | 790 | expect(fs.writeFile).toHaveBeenCalledWith( 791 | 'foo.md', 792 | ` 793 | 794 | 795 | ~~~~~~~~~~js 796 | File: bar.js 797 | 798 | console.log("👋") 799 | ~~~~~~~~~~ 800 | 801 | ` 802 | ) 803 | }) 804 | 805 | it('performs surgical replacement', async () => { 806 | glob.mockResolvedValue(['foo.md']) 807 | 808 | fs.readFile.mockImplementation(async (fileName) => { 809 | if (fileName === 'foo.md') { 810 | return ` 811 | 812 | ~~~ 813 | 814 | 815 | ~~~ 816 | 817 | 818 | 819 | 820 | 821 | 822 | ~~~ 823 | 824 | 825 | ~~~ 826 | 827 | 828 | ` 829 | } 830 | 831 | if (fileName.includes('bar.js')) { 832 | return "console.log('Hello World')" 833 | } 834 | 835 | throw new Error('Unexpected file name passed') 836 | }) 837 | 838 | await injectMarkdown() 839 | 840 | expect(fs.writeFile).toHaveBeenCalledWith( 841 | 'foo.md', 842 | ` 843 | 844 | ~~~ 845 | 846 | 847 | ~~~ 848 | 849 | 850 | 851 | 852 | ~~~~~~~~~~js 853 | File: bar.js 854 | 855 | console.log('Hello World') 856 | ~~~~~~~~~~ 857 | 858 | 859 | 860 | 861 | ~~~ 862 | 863 | 864 | ~~~ 865 | 866 | 867 | ` 868 | ) 869 | }) 870 | 871 | it('handles multiple blocks in one file', async () => { 872 | glob.mockResolvedValue(['foo.md']) 873 | fs.readFile.mockImplementation( 874 | async () => ` 875 | # Foo Package 876 | 877 | 884 | 885 | 886 | {/* 887 | CODEBLOCK_START 888 | { 889 | "type": "command", 890 | "value": "npm view foo" 891 | } 892 | */} 893 | {/* CODEBLOCK_END */} 894 | 895 | # Bar Package 896 | 897 | 904 | ` 905 | ) 906 | exec.mockImplementation((cmd, env, cb) => { 907 | cb(null, `OUT: ${cmd}`) 908 | }) 909 | 910 | await injectMarkdown() 911 | 912 | expect(fs.writeFile).toHaveBeenCalledWith( 913 | 'foo.md', 914 | expect.stringMatching(/OUT: npm view foo(.|\n)*OUT: npm view bar/) 915 | ) 916 | }) 917 | 918 | it('removes color from commands', async () => { 919 | mock({ 920 | config: { 921 | type: 'command', 922 | value: 'npm view react-scripts', 923 | }, 924 | }) 925 | 926 | await injectMarkdown() 927 | 928 | const [, execConfig] = exec.mock.calls[0] 929 | 930 | /* 931 | "expect(execConfig.env.FORCE_COLOR).toBe('0')" is purposefully surgical, as 932 | "expect(mock).toHaveBeenCalledWith(..., { env: expect.objectContaining({FORCE_COLOR: '0'}) }, ...)" 933 | will write all of process.env to the console if the assertion fails. 934 | */ 935 | expect(execConfig.env.FORCE_COLOR).toBe('0') 936 | }) 937 | 938 | it('passes configured environment to commands', async () => { 939 | mock({ 940 | config: { 941 | type: 'command', 942 | value: 'npm view react-scripts', 943 | environment: { 944 | FOO_ENV: 'bar val', 945 | }, 946 | }, 947 | }) 948 | 949 | await injectMarkdown() 950 | 951 | const [, execConfig] = exec.mock.calls[0] 952 | 953 | expect(execConfig.env.FOO_ENV).toBe('bar val') 954 | }) 955 | 956 | it('passes system environment to commands', async () => { 957 | process.env.MY_SYS_ENV = 'a test' 958 | 959 | mock({ 960 | config: { 961 | type: 'command', 962 | value: 'npm view react-scripts', 963 | }, 964 | }) 965 | 966 | await injectMarkdown() 967 | 968 | const [, execConfig] = exec.mock.calls[0] 969 | 970 | expect(exec).toHaveBeenCalledTimes(1) 971 | 972 | expect(execConfig.env.MY_SYS_ENV).toBe('a test') 973 | }) 974 | 975 | it('can prevent system environment from being passed', async () => { 976 | process.env.MY_SYS_ENV = 'b test' 977 | 978 | mock({ 979 | config: { 980 | type: 'command', 981 | value: 'npm view react-scripts', 982 | }, 983 | }) 984 | 985 | await injectMarkdown({ 986 | blockPrefix: 'CODEBLOCK', 987 | followSymbolicLinks: true, 988 | globPattern: '**/*.md', 989 | quiet: false, 990 | useSystemEnvironment: false, 991 | }) 992 | 993 | const [, execConfig] = exec.mock.calls[0] 994 | 995 | expect(exec).toHaveBeenCalledTimes(1) 996 | 997 | expect(execConfig.env.MY_SYS_ENV).not.toBeDefined() 998 | }) 999 | 1000 | it('can overwrite FORCE_COLOR', async () => { 1001 | mock({ 1002 | config: { 1003 | type: 'command', 1004 | value: 'npm view react-scripts', 1005 | environment: { 1006 | FORCE_COLOR: 'true', 1007 | }, 1008 | }, 1009 | }) 1010 | 1011 | await injectMarkdown() 1012 | 1013 | const [, execConfig] = exec.mock.calls[0] 1014 | 1015 | expect(execConfig.env.FORCE_COLOR).toBe('true') 1016 | }) 1017 | 1018 | it('substitutes passed environment variables from system environment variables', async () => { 1019 | process.env.MY_SYS_ENV = 'c test' 1020 | mock({ 1021 | config: { 1022 | type: 'command', 1023 | value: 'npm view react-scripts', 1024 | environment: { 1025 | MY_PASSED_ENV: '$MY_SYS_ENV', 1026 | }, 1027 | }, 1028 | }) 1029 | 1030 | await injectMarkdown() 1031 | 1032 | const [, execConfig] = exec.mock.calls[0] 1033 | 1034 | expect(execConfig.env.MY_PASSED_ENV).toBe('c test') 1035 | }) 1036 | 1037 | it('overwrites system environment', async () => { 1038 | process.env.MY_SYS_ENV = 'd test' 1039 | mock({ 1040 | config: { 1041 | type: 'command', 1042 | value: 'npm view react-scripts', 1043 | environment: { 1044 | MY_SYS_ENV: 'e test', 1045 | }, 1046 | }, 1047 | }) 1048 | 1049 | await injectMarkdown() 1050 | 1051 | const [, execConfig] = exec.mock.calls[0] 1052 | 1053 | expect(execConfig.env.MY_SYS_ENV).toBe('e test') 1054 | }) 1055 | 1056 | it('throws if a file is empty (after trimming)', async () => { 1057 | mock({ 1058 | config: { 1059 | value: 'foo.md', 1060 | }, 1061 | mockResponse: ` 1062 | 1063 | 1064 | `, 1065 | }) 1066 | 1067 | await injectMarkdown() 1068 | 1069 | expect(logger.error).toHaveBeenCalledWith( 1070 | expect.objectContaining({ 1071 | message: expect.stringContaining('No content was returned'), 1072 | }) 1073 | ) 1074 | expect(process.exitCode).toBe(1) 1075 | }) 1076 | 1077 | it('throws if a command returns no output (after trimming)', async () => { 1078 | mock({ 1079 | config: { 1080 | type: 'command', 1081 | value: `echo ''`, 1082 | }, 1083 | mockResponse: ` 1084 | `, 1085 | }) 1086 | 1087 | await injectMarkdown() 1088 | 1089 | expect(logger.error).toHaveBeenCalledWith( 1090 | expect.objectContaining({ 1091 | message: expect.stringContaining('No content was returned'), 1092 | }) 1093 | ) 1094 | expect(process.exitCode).toBe(1) 1095 | }) 1096 | }) 1097 | 1098 | const mock = ({ 1099 | name = '', 1100 | mockFileName = 'foo.md', 1101 | config, 1102 | includePrettierIgnore = true, 1103 | blockContents = '', 1104 | mockResponse = '', 1105 | }: { 1106 | name?: string 1107 | mockFileName?: string 1108 | config: any 1109 | includePrettierIgnore?: boolean 1110 | blockContents?: string 1111 | mockResponse?: string 1112 | }) => { 1113 | glob.mockResolvedValue([mockFileName]) 1114 | 1115 | fs.readFile.mockImplementation(async (fileName) => { 1116 | if (fileName === mockFileName) { 1117 | return fileName.includes('mdx') 1118 | ? ` 1119 | {/* CODEBLOCK_START${name} ${JSON.stringify(config)} */} 1120 | ${includePrettierIgnore ? '{/* prettier-ignore */}\n' : ''}${blockContents} 1121 | {/* CODEBLOCK_END${name} */}` 1122 | : ` 1123 | 1124 | ${includePrettierIgnore ? '\n' : ''}${blockContents} 1125 | ` 1126 | } 1127 | 1128 | if (config.type !== 'command' && fileName.includes(config.value)) { 1129 | return mockResponse 1130 | } 1131 | throw new Error('Unexpected file name passed') 1132 | }) 1133 | 1134 | if (config.type === 'command') { 1135 | exec.mockImplementation((...args) => { 1136 | const cb = args.pop() 1137 | cb(null, mockResponse) 1138 | }) 1139 | } 1140 | } 1141 | -------------------------------------------------------------------------------- /src/__tests__/meta.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | describe('package-lock.json', () => { 5 | it('does not contain private registry references', () => { 6 | const lockFile = fs.readFileSync( 7 | path.join(process.cwd(), 'package-lock.json'), 8 | { 9 | encoding: 'utf-8', 10 | } 11 | ) 12 | expect(lockFile.indexOf('artifactory')).toBe(-1) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from 'commander' 4 | import injectMarkdown from './md-inject' 5 | 6 | const { name, version } = require('../package.json') 7 | 8 | const allGlobPattern = './**/*.md' 9 | 10 | const program = new Command() 11 | program 12 | .version(version, '-v, --version') 13 | .name(name) 14 | .arguments('[globPattern]') 15 | .option('-a, --all', `applies a globPattern of '${allGlobPattern}'`, false) 16 | .option( 17 | '-b, --block-prefix ', 18 | 'specifies the prefix for START and END HTML comment blocks', 19 | 'CODEBLOCK' 20 | ) 21 | .option( 22 | '-n, --no-follow-symbolic-links', 23 | 'prevents globs from following symlinks' 24 | ) 25 | .option('-q, --quiet', 'emits no console log statements', false) 26 | .option( 27 | '-e, --no-system-environment', 28 | 'prevents "command"s from receiving system environment', 29 | false 30 | ) 31 | .description('Add file or command output to markdown documents.') 32 | .usage( 33 | `[options] 34 | 35 | Examples: 36 | $ npx ${name} -a 37 | $ npx ${name} 'README.md' 38 | $ npx ${name} './**/*.{md,mdx}'` 39 | ) 40 | .action(async (globPattern, options) => { 41 | if (options.all && globPattern) { 42 | console.error( 43 | `Options -a / --all and a globPattern ('${globPattern}') can not be provided together. Please select one or the other.` 44 | ) 45 | return process.exit(1) 46 | } 47 | 48 | if (options.all) { 49 | globPattern = allGlobPattern 50 | } 51 | 52 | if (!globPattern) { 53 | return program.help() 54 | } 55 | 56 | await injectMarkdown({ 57 | blockPrefix: options.blockPrefix, 58 | followSymbolicLinks: options.followSymbolicLinks, 59 | globPattern, 60 | quiet: options.quiet, 61 | useSystemEnvironment: options.systemEnvironment, 62 | }) 63 | }) 64 | 65 | program.parse() 66 | -------------------------------------------------------------------------------- /src/md-inject.ts: -------------------------------------------------------------------------------- 1 | import glob from 'globby' 2 | import fs from 'fs-extra' 3 | import path from 'path' 4 | import { exec } from 'child_process' 5 | import Logger from './Logger' 6 | import envCi from 'env-ci' 7 | 8 | enum BlockSourceType { 9 | file = 'file', 10 | command = 'command', 11 | } 12 | 13 | export interface BlockOptions { 14 | value: string 15 | hideValue?: boolean 16 | environment?: NodeJS.ProcessEnv 17 | ignore?: boolean 18 | language?: string 19 | trim?: boolean 20 | type?: BlockSourceType 21 | } 22 | 23 | interface BlockInputOptions extends Omit { 24 | type?: `${BlockSourceType}` 25 | } 26 | 27 | interface ReplaceOptions { 28 | blockPrefix: string 29 | followSymbolicLinks: boolean 30 | globPattern: string 31 | quiet: boolean 32 | useSystemEnvironment: boolean 33 | } 34 | 35 | const main = async ( 36 | { 37 | blockPrefix, 38 | followSymbolicLinks, 39 | globPattern, 40 | quiet, 41 | useSystemEnvironment, 42 | }: ReplaceOptions = { 43 | blockPrefix: 'CODEBLOCK', 44 | followSymbolicLinks: true, 45 | globPattern: '**/*.md', 46 | quiet: false, 47 | useSystemEnvironment: true, 48 | } 49 | ): Promise => { 50 | const logger = new Logger(quiet) 51 | 52 | const ciEnv = envCi() 53 | 54 | if (ciEnv.isCi && 'isPr' in ciEnv && ciEnv.isPr) { 55 | logger.warn( 56 | 'markdown-inject does not run during pull request builds. Exiting with no changes.' 57 | ) 58 | return 59 | } 60 | 61 | logger.group('Injecting Markdown Blocks') 62 | 63 | const markdownFiles = await glob(globPattern, { 64 | followSymbolicLinks, 65 | gitignore: true, 66 | }) 67 | 68 | const processMarkdownFile = async (fileName: string) => { 69 | let originalFileContents 70 | try { 71 | originalFileContents = await fs.readFile(fileName, { encoding: 'utf-8' }) 72 | } catch (err) { 73 | logger.error(`${fileName}: Error reading file`) 74 | throw err 75 | } 76 | 77 | let modifiedFileContents = originalFileContents 78 | 79 | let codeblockMatch: RegExpExecArray 80 | let blocksChanged = 0 81 | let blocksIgnored = 0 82 | let totalBlocks = 0 83 | 84 | const comment = { 85 | html: { 86 | start: '', 88 | }, 89 | mdx: { 90 | start: '\\{\\s*/\\*', 91 | end: '\\*/\\s*\\}', 92 | }, 93 | } as const 94 | const codeblockRegex = new RegExp( 95 | Object.entries(comment) 96 | .map( 97 | ([commentType, { start: commentStart, end: commentEnd }]) => 98 | `(?<${commentType}_start_pragma>${commentStart}\\s*${blockPrefix}_START(?<${commentType}_name_ext>\\w*)\\s+(?<${commentType}_config>\\{(?:.|\\n)+?\\})\\s*${commentEnd}).*?(?<${commentType}_end_pragma>${commentStart}\\s*${blockPrefix}_END\\k<${commentType}_name_ext>\\s*${commentEnd})` 99 | ) 100 | .join('|'), 101 | 'gs' 102 | ) 103 | 104 | while ((codeblockMatch = codeblockRegex.exec(modifiedFileContents))) { 105 | const matchGroups = Object.fromEntries( 106 | Object.entries(codeblockMatch.groups) 107 | .filter(([groupName]) => 108 | groupName.startsWith( 109 | codeblockMatch.groups.html_config ? 'html_' : 'mdx_' 110 | ) 111 | ) 112 | .map(([groupName, groupValue]) => [ 113 | groupName.replace(/^(html|mdx)_/, ''), 114 | groupValue, 115 | ]) 116 | ) 117 | try { 118 | let inputConfig: BlockInputOptions 119 | try { 120 | inputConfig = JSON.parse(matchGroups.config) 121 | } catch (err) { 122 | logger.error(`Error parsing config:\n${matchGroups.config}`) 123 | throw err 124 | } 125 | 126 | const resolvedType = BlockSourceType[inputConfig.type] 127 | 128 | const blockSourceTypes = { 129 | command: 'command', 130 | file: 'file', 131 | } 132 | 133 | if (inputConfig.type !== undefined && resolvedType === undefined) { 134 | throw new Error( 135 | `Unexpected "type" of "${ 136 | inputConfig.type 137 | }". Valid types are ${Object.values(blockSourceTypes) 138 | .map((s) => `"${s}"`) 139 | .join(', ')}` 140 | ) 141 | } 142 | 143 | const config: BlockOptions = { 144 | ...inputConfig, 145 | type: resolvedType, 146 | } 147 | 148 | const { 149 | type: blockSourceType = BlockSourceType.file, 150 | hideValue = false, 151 | trim = true, 152 | ignore = false, 153 | environment = {}, 154 | } = config 155 | 156 | if (ignore) { 157 | blocksIgnored++ 158 | totalBlocks++ 159 | continue 160 | } 161 | 162 | let { language, value } = config 163 | 164 | if (!value) { 165 | throw new Error('No "value" was provided.') 166 | } 167 | 168 | const [originalBlock] = codeblockMatch 169 | const startPragma = matchGroups.start_pragma 170 | const endPragma = matchGroups.end_pragma 171 | 172 | let out: string 173 | 174 | if (blockSourceType === BlockSourceType.command) { 175 | out = await new Promise((resolve, reject) => { 176 | exec( 177 | value, 178 | { env: prepareEnvironment(environment, useSystemEnvironment) }, 179 | (err, stdout) => { 180 | if (err) { 181 | return reject(err) 182 | } 183 | return resolve(stdout) 184 | } 185 | ) 186 | }) 187 | } else { 188 | // BlockSourceType.file 189 | const fileLocation = path.resolve(path.dirname(fileName), value) 190 | out = await fs.readFile(fileLocation, { encoding: 'utf-8' }) 191 | if (!language) { 192 | language = path.extname(fileLocation).replace(/^\./, '') 193 | } 194 | value = path.relative(process.cwd(), fileLocation) 195 | } 196 | 197 | if (!out || !out.trim()) { 198 | throw new Error('No content was returned.') 199 | } 200 | 201 | if (!language) { 202 | language = 'bash' 203 | } 204 | 205 | // Code blocks can start with an arbitrary length, and must end with at least the same. 206 | // This allows us to write ``` in our code blocks without inadvertently terminating them. 207 | // https://github.github.com/gfm/#example-94 208 | const codeblockFence = '~~~~~~~~~~' 209 | 210 | const checkFileName = fileName 211 | const prettierIgnore = checkFileName.includes('mdx') 212 | ? '{/* prettier-ignore */}' 213 | : '' 214 | 215 | if (trim) { 216 | out = out.trim() 217 | } 218 | 219 | const newBlock = `${startPragma} 220 | ${prettierIgnore} 221 | ${codeblockFence}${language}${ 222 | hideValue 223 | ? '' 224 | : `\n${ 225 | blockSourceType === BlockSourceType.command ? '$' : 'File:' 226 | } ${value}\n` 227 | } 228 | ${out} 229 | ${codeblockFence} 230 | 231 | ${endPragma}` 232 | 233 | totalBlocks++ 234 | if (newBlock !== originalBlock) { 235 | blocksChanged++ 236 | 237 | const { input, index } = codeblockMatch 238 | const matchLength = codeblockMatch[0].length 239 | 240 | const pre = input.substring(0, index) 241 | const post = input.substr(index + matchLength) 242 | 243 | modifiedFileContents = pre + newBlock + post 244 | } 245 | } catch (err) { 246 | const lines = codeblockMatch.input 247 | .slice(0, codeblockMatch.index) 248 | .split('\n') 249 | const lineNo = lines.length 250 | const col = lines.pop().length 251 | 252 | console.error( 253 | `Error processing codeblock at "${path.join( 254 | process.cwd(), 255 | fileName 256 | )}:${lineNo}:${col}":` 257 | ) 258 | 259 | throw err 260 | } 261 | } 262 | 263 | if (modifiedFileContents !== originalFileContents) { 264 | await fs.writeFile(fileName, modifiedFileContents) 265 | logger.log( 266 | `${fileName}: ${blocksChanged} of ${totalBlocks} blocks changed (${blocksIgnored} ignored)` 267 | ) 268 | } 269 | 270 | return [blocksChanged, blocksIgnored, totalBlocks] 271 | } 272 | 273 | try { 274 | const results = await Promise.all(markdownFiles.map(processMarkdownFile)) 275 | 276 | if (results.length === 0) { 277 | logger.warn('No markdown files identified') 278 | logger.groupEnd() 279 | return 280 | } 281 | 282 | const [totalChanges, totalIgnored, totalBlocks] = results.reduce( 283 | ( 284 | [totalChanges, totalIgnored, totalBlocks], 285 | [itemChanges, itemIgnored, itemTotal] 286 | ) => [ 287 | totalChanges + itemChanges, 288 | totalIgnored + itemIgnored, 289 | totalBlocks + itemTotal, 290 | ], 291 | [0, 0, 0] 292 | ) 293 | 294 | if (totalBlocks === 0) { 295 | logger.warn(`No markdown files with "${blockPrefix}" pragmas located`) 296 | logger.groupEnd() 297 | return 298 | } 299 | 300 | logger.log( 301 | `Total: ${totalChanges} of ${totalBlocks} blocks (${totalIgnored} ignored)` 302 | ) 303 | } catch (err) { 304 | logger.error(err) 305 | process.exitCode = 1 306 | } 307 | logger.groupEnd() 308 | } 309 | 310 | const prepareEnvironment = ( 311 | providedEnvironment: NodeJS.ProcessEnv, 312 | useSystemEnvironment: boolean 313 | ) => { 314 | const systemEnvironment = useSystemEnvironment ? process.env : {} 315 | providedEnvironment = Object.entries(providedEnvironment) 316 | .map(([key, value]) => { 317 | const valueEnvMatch = /^\$(\w+)$/.exec(value || '') 318 | if (valueEnvMatch) { 319 | const envKey = valueEnvMatch[1] 320 | value = process.env[envKey] 321 | } 322 | return [key, value] as const 323 | }) 324 | .reduce((agg, [k, v]) => ({ ...agg, [k]: v }), {}) 325 | 326 | return { 327 | ...systemEnvironment, 328 | FORCE_COLOR: '0', 329 | ...providedEnvironment, 330 | } 331 | } 332 | 333 | export default main 334 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "esModuleInterop": true, 5 | "noImplicitAny": true, 6 | "resolveJsonModule": true, 7 | "lib": ["ES2019"], 8 | "module": "commonjs", 9 | "target": "ES2019", 10 | "sourceMap": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["**/*.test.*"] 14 | } 15 | --------------------------------------------------------------------------------