├── .eslintrc.cjs ├── .github └── workflows │ ├── build.yml │ ├── create-release.yaml │ └── test-typescript.yml ├── .gitignore ├── .npmignore ├── .prettierrc.cjs ├── .vscode ├── extensions.json └── settings.json ├── README.MD ├── jest.config.cjs ├── package-lock.json ├── package.json ├── src ├── contracts.spec.ts ├── contracts.ts ├── example │ ├── additionalModifiers.ts │ ├── configs.ts │ ├── exampleConfig.json │ ├── exampleConfig.ts │ ├── exampleConfigHelpWithMissingArgs.ts │ ├── exampleConfigUsingPrintHelp.ts │ ├── exampleConfigWithHelp.ts │ ├── exitProcessExample.ts │ ├── insert-code-sample.md │ ├── insert-code.example.ts │ ├── noExitProcessExample.ts │ ├── tsconfig.json │ ├── typicalAppWithGroups.ts │ ├── unknownArgumentsExample.ts │ └── usageGuideWithExamples.ts ├── helpers │ ├── add-content.helper.spec.ts │ ├── add-content.helper.ts │ ├── command-line.helper.spec.ts │ ├── command-line.helper.ts │ ├── index.ts │ ├── insert-code.helper.spec.ts │ ├── insert-code.helper.ts │ ├── line-ending.helper.spec.ts │ ├── line-ending.helper.ts │ ├── markdown.helper.spec.ts │ ├── markdown.helper.ts │ ├── options.helper.ts │ ├── string.helper.spec.ts │ ├── string.helper.ts │ └── visitor.ts ├── index.ts ├── parse.spec.ts ├── parse.ts ├── write-markdown.constants.ts └── write-markdown.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | jest: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/eslint-recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | 'prettier/@typescript-eslint', 12 | 'plugin:prettier/recommended', 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 11, 17 | sourceType: 'module', 18 | }, 19 | plugins: ['@typescript-eslint'], 20 | rules: { 21 | indent: 'off', 22 | semi: ['error', 'always'], 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 25 | }, 26 | ignorePatterns: ['dist/', 'dist-original'], 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [ 14.x, 16.x, 18.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm ci 26 | - run: npm run build-release -------------------------------------------------------------------------------- /.github/workflows/create-release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build: 9 | name: Create Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: '16.x' 22 | 23 | - name: Install node modules and verify build 24 | run: npm ci && npm run build-release 25 | 26 | - name: Release 27 | uses: justincy/github-action-npm-release@2.0.2 28 | id: release 29 | 30 | - name: Print release output 31 | if: ${{ steps.release.outputs.released == 'true' }} 32 | run: echo Release ID ${{ steps.release.outputs.release_id }} 33 | 34 | - name: Publish 35 | if: steps.release.outputs.released == 'true' 36 | uses: JS-DevTools/npm-publish@v1 37 | with: 38 | token: ${{ secrets.NPM_TOKEN }} 39 | 40 | - name: Upload coverage to Codecov 41 | uses: codecov/codecov-action@v2 -------------------------------------------------------------------------------- /.github/workflows/test-typescript.yml: -------------------------------------------------------------------------------- 1 | name: Compatible Typescript Versions 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | typescript-version: [4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9 ] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js 16.x 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: 16.x 25 | - run: npm ci 26 | - run: npm install typescript@${{ matrix.typescript-version }} 27 | - run: npm run build-release 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | dist-original/ 4 | *.tgz 5 | sampleMarkdown.md 6 | coverage/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/example/**/*.* -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 4 7 | }; -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "cSpell.words": [ 4 | "organise" 5 | ] 6 | } -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # ts-command-line-args 2 | 3 | > A Typescript wrapper around [`command-line-args`](https://www.npmjs.com/package/command-line-args) with additional support for markdown usage guide generation 4 | 5 | ![npm](https://img.shields.io/npm/v/ts-command-line-args) 6 | [![Build Status](https://travis-ci.com/Roaders/ts-command-line-args.svg?branch=master)](https://travis-ci.com/Roaders/ts-command-line-args) 7 | ![NPM](https://img.shields.io/npm/l/ts-command-line-args) 8 | ![Typescript](https://img.shields.io/badge/types-TypeScript-blue) 9 | [![codecov](https://codecov.io/gh/Roaders/ts-command-line-args/branch/master/graph/badge.svg?token=06AMYJTIUK)](https://codecov.io/gh/Roaders/ts-command-line-args/) 10 | 11 | ## Features 12 | 13 | * Converts command line arguments: 14 | ```bash 15 | $ myExampleCli --sourcePath=pathOne --targetPath=pathTwo 16 | ``` 17 | into strongly typed objects: 18 | ```typescript 19 | { sourcePath: "pathOne", targetPath: "pathTwo"} 20 | ``` 21 | * Support for optional values, multiple values, aliases 22 | * Prints a useful error message and exits the process if required arguments are missing: 23 | ``` 24 | $ myExampleCli 25 | Required parameter 'sourcePath' was not passed. Please provide a value by running 'myExampleCli --sourcePath=passedValue' 26 | Required parameter 'targetPath' was not passed. Please provide a value by running 'myExampleCli --targetPath=passedValue' 27 | To view the help guide run 'myExampleCli -h' 28 | ``` 29 | * Supports printing usage guides to the console with a help argument: 30 | 31 | ``` 32 | $ myExampleCli -h 33 | My Example CLI 34 | 35 | Thanks for using Our Awesome Library 36 | 37 | Options 38 | --sourcePath string 39 | --targetPath string 40 | -h, --help Prints this usage guide 41 | ``` 42 | * Supports writing the same usage guide to markdown (see [Markdown Generation](#markdown-generation) section below, generated by `write-markdown`) 43 | 44 | ## Usage 45 | 46 | Take a typescript interface (or type or class): 47 | 48 | ```typescript 49 | interface ICopyFilesArguments{ 50 | sourcePath: string; 51 | targetPath: string; 52 | copyFiles: boolean; 53 | resetPermissions: boolean; 54 | filter?: string; 55 | excludePaths?: string[]; 56 | } 57 | ``` 58 | 59 | and use this to enforce the correct generation of options for `command-line-args`: 60 | 61 | ```typescript 62 | import { parse } from 'ts-command-line-args'; 63 | 64 | // args typed as ICopyFilesArguments 65 | export const args = parse({ 66 | sourcePath: String, 67 | targetPath: String, 68 | copyFiles: { type: Boolean, alias: 'c' }, 69 | resetPermissions: Boolean, 70 | filter: { type: String, optional: true }, 71 | excludePaths: { type: String, multiple: true, optional: true }, 72 | }); 73 | ``` 74 | 75 | With the above setup these commands will all work 76 | 77 | ```bash 78 | $ node exampleConfig.js --sourcePath=source --targetPath=target 79 | $ node exampleConfig.js --sourcePath source --targetPath target --copyFiles 80 | $ node exampleConfig.js --sourcePath source --targetPath target -c 81 | $ node exampleConfig.js --sourcePath source --targetPath target --filter src --excludePaths=one --excludePaths=two 82 | $ node exampleConfig.js --sourcePath source --targetPath target --filter src --excludePaths one two 83 | ``` 84 | 85 | ## Usage Guide 86 | 87 | [`command-line-usage`](https://github.com/75lb/command-line-usage) support is included: 88 | 89 | ```typescript 90 | import { parse } from 'ts-command-line-args'; 91 | 92 | interface ICopyFilesArguments { 93 | sourcePath: string; 94 | targetPath: string; 95 | copyFiles: boolean; 96 | resetPermissions: boolean; 97 | filter?: string; 98 | excludePaths?: string[]; 99 | help?: boolean; 100 | } 101 | 102 | export const args = parse( 103 | { 104 | sourcePath: String, 105 | targetPath: String, 106 | copyFiles: { type: Boolean, alias: 'c', description: 'Copies files rather than moves them' }, 107 | resetPermissions: Boolean, 108 | filter: { type: String, optional: true }, 109 | excludePaths: { type: String, multiple: true, optional: true }, 110 | help: { type: Boolean, optional: true, alias: 'h', description: 'Prints this usage guide' }, 111 | }, 112 | { 113 | helpArg: 'help', 114 | headerContentSections: [{ header: 'My Example Config', content: 'Thanks for using Our Awesome Library' }], 115 | footerContentSections: [{ header: 'Footer', content: `Copyright: Big Faceless Corp. inc.` }], 116 | }, 117 | ); 118 | ``` 119 | 120 | with the above config these commands: 121 | 122 | ```bash 123 | $ node exampleConfigWithHelp.js -h 124 | $ node exampleConfigWithHelp.js --help 125 | ``` 126 | will give the following output: 127 | 128 | ``` 129 | My Example Config 130 | 131 | Thanks for using Our Awesome Library 132 | 133 | Options 134 | 135 | --sourcePath string 136 | --targetPath string 137 | -c, --copyFiles Copies files rather than moves them 138 | --resetPermissions 139 | --filter string 140 | --excludePaths string[] 141 | -h, --help Prints this usage guide 142 | 143 | Footer 144 | 145 | Copyright: Big Faceless Corp. inc. 146 | ``` 147 | 148 | ### Loading Config from File 149 | 150 | The arguments for an application can also be defined in a config file. The following config allows this: 151 | 152 | ```typescript 153 | interface ICopyFilesArguments { 154 | sourcePath: string; 155 | targetPath: string; 156 | copyFiles: boolean; 157 | resetPermissions: boolean; 158 | filter?: string; 159 | excludePaths?: string[]; 160 | configFile?: string; 161 | jsonPath?: string; 162 | } 163 | 164 | export const args = parse( 165 | { 166 | sourcePath: String, 167 | targetPath: String, 168 | copyFiles: { type: Boolean }, 169 | resetPermissions: Boolean, 170 | filter: { type: String, optional: true }, 171 | excludePaths: { type: String, multiple: true, optional: true }, 172 | configFile: { type: String, optional: true }, 173 | jsonPath: { type: String, optional: true }, 174 | }, 175 | { 176 | loadFromFileArg: 'configFile', 177 | loadFromFileJsonPathArg: 'jsonPath', 178 | }, 179 | ); 180 | ``` 181 | 182 | With this configuration the same command line arguments can be passed: 183 | 184 | ```bash 185 | $ node exampleConfig.js --sourcePath source --targetPath target 186 | ``` 187 | 188 | but the user of the script can use a config file instead: 189 | 190 | **package.json** (for example) 191 | ```json 192 | { 193 | "name": "myPackage", 194 | "version": "0.1.1", 195 | "copyFileConfig": { 196 | "copyFileOne": { 197 | "sourcePath": "source", 198 | "targetPath": "target", 199 | "copyFiles": true, 200 | "resetPermissions": false, 201 | "excludePaths": ["one", "two", "three"] 202 | } 203 | } 204 | } 205 | ``` 206 | 207 | ```bash 208 | $ node exampleConfig.js --configFile package.json --jsonPath copyFileConfig.copyFileOne 209 | ``` 210 | 211 | Any params passed on the command line will ovverride those defined in the file: 212 | 213 | ```bash 214 | $ node exampleConfig.js --configFile package.json --jsonPath copyFileConfig.copyFileOne --resetPermissions 215 | ``` 216 | 217 | Boolean values in a file can be overridden by just specfying the argName for true specifying the value: 218 | 219 | ```bash 220 | $ myCommand --booleanOne --booleanTwo=false --booleanThree false -b=false -o=true --booleanFour=1 221 | ``` 222 | 223 | When defining settings in a json file the values are still passed through the `type` function defined in your config so for this setup: 224 | 225 | ```typescript 226 | interface ISampleConfig { 227 | startDate: Date; 228 | endDate: Date; 229 | inclusive: boolean; 230 | includeStart: boolean; 231 | includeEnd: boolean; 232 | } 233 | 234 | function parseDate(value: any) { 235 | return new Date(Date.parse(value)); 236 | } 237 | 238 | export const args = parse({ 239 | startDate: { type: parseDate }, 240 | endDate: { type: parseDate }, 241 | initialCount: Number, 242 | endCount: Number, 243 | inclusive: Boolean, 244 | includeStart: Boolean, 245 | includeEnd: Boolean, 246 | }); 247 | ``` 248 | 249 | This settings file would be correctly parsed: 250 | 251 | ```json 252 | { 253 | "startDate": "01/01/2020", 254 | "endDate": "00010201T000000Z", 255 | "initialCount": 1, 256 | "endCount": "2", 257 | "inclusive": true, 258 | "includeStart": "false", 259 | "includeEnd": 1, 260 | } 261 | ``` 262 | 263 | [//]: ####ts-command-line-args_write-markdown_replaceBelow 264 | 265 | ## Markdown Generation 266 | 267 | A markdown version of the usage guide can be generated and inserted into an existing markdown document. 268 | Markers in the document describe where the content should be inserted, existing content betweeen the markers is overwritten. 269 | 270 | `write-markdown -m README.MD -j usageGuideConstants.js` 271 | 272 | ### write-markdown cli options 273 | 274 | | Argument | Alias | Type | Description | 275 | |-|-|-|-| 276 | | **markdownPath** | **m** | string | The file to write to. Without replacement markers the whole file content will be replaced. Path can be absolute or relative. | 277 | | **replaceBelow** | | string | A marker in the file to replace text below. | 278 | | **replaceAbove** | | string | A marker in the file to replace text above. | 279 | | **insertCodeBelow** | | string | A marker in the file to insert code below. File path to insert must be added at the end of the line and optionally codeComment flag: 'insertToken file="path/toFile.md" codeComment="ts"' | 280 | | **insertCodeAbove** | | string | A marker in the file to insert code above. | 281 | | **copyCodeBelow** | | string | A marker in the file being inserted to say only copy code below this line | 282 | | **copyCodeAbove** | | string | A marker in the file being inserted to say only copy code above this line | 283 | | **jsFile** | **j** | string[] | jsFile to 'require' that has an export with the 'UsageGuideConfig' export. Multiple files can be specified. | 284 | | **configImportName** | **c** | string[] | Export name of the 'UsageGuideConfig' object. Defaults to 'usageGuideInfo'. Multiple exports can be specified. | 285 | | **verify** | **v** | boolean | Verify the markdown file. Does not update the file but returns a non zero exit code if the markdown file is not correct. Useful for a pre-publish script. | 286 | | **configFile** | **f** | string | Optional config file to load config from. package.json can be used if jsonPath specified as well | 287 | | **jsonPath** | **p** | string | Used in conjunction with 'configFile'. The path within the config file to load the config from. For example: 'configs.writeMarkdown' | 288 | | **verifyMessage** | | string | Optional message that is printed when markdown verification fails. Use '{fileName}' to refer to the file being processed. | 289 | | **removeDoubleBlankLines** | | boolean | When replacing content removes any more than a single blank line | 290 | | **skipFooter** | | boolean | Does not add the 'Markdown Generated by...' footer to the end of the markdown | 291 | | **help** | **h** | boolean | Show this usage guide. | 292 | 293 | ### Default Replacement Markers 294 | 295 | replaceBelow defaults to: 296 | 297 | ``` 298 | '[//]: ####ts-command-line-args_write-markdown_replaceBelow' 299 | ``` 300 | 301 | replaceAbove defaults to: 302 | 303 | ``` 304 | '[//]: ####ts-command-line-args_write-markdown_replaceAbove' 305 | ``` 306 | 307 | insertCodeBelow defaults to: 308 | 309 | ``` 310 | '[//]: # (ts-command-line-args_write-markdown_insertCodeBelow' 311 | ``` 312 | 313 | insertCodeAbove defaults to: 314 | 315 | ``` 316 | '[//]: # (ts-command-line-args_write-markdown_insertCodeAbove)' 317 | ``` 318 | 319 | copyCodeBelow defaults to: 320 | 321 | ``` 322 | '// ts-command-line-args_write-markdown_copyCodeBelow' 323 | ``` 324 | 325 | copyCodeAbove defaults to: 326 | 327 | ``` 328 | '// ts-command-line-args_write-markdown_copyCodeAbove' 329 | ``` 330 | 331 | [//]: ####ts-command-line-args_write-markdown_replaceAbove 332 | 333 | (the **Markdown Generation** section above was generated using `write-markdown` ) 334 | 335 | ### Insert Code 336 | 337 | Markdown generation can also insert some or all of a file into your markdown. This is useful for including example code. Rather than copying code that will likely get out of date you can directly include code that is checked by your compiler as part of your normal build. 338 | To include code markers must be added to your markdown that indicates which file to copy from. The default marker (which can be changed if desired) is: 339 | 340 | ``` 341 | [//]: # (ts-command-line-args_write-markdown_insertCodeBelow file="path/from/markdown/to/file.ts" ) 342 | CODE INSERTED HERE 343 | [//]: # (ts-command-line-args_write-markdown_insertCodeBelow 344 | ``` 345 | 346 | Whatever marker you use you must include the `file="path/from/markdown/to/file.ts"` in that format. 347 | You can also surround the inserted code with a triple backticks by including `codeComment` or `codeComment="myLanguage"`. For example: 348 | 349 | ``` 350 | [//]: # (ts-command-line-args_write-markdown_insertCodeBelow file="path/from/markdown/to/file.ts" codeComment="typescript" ) 351 | ``` 352 | 353 | Areas to include from the file that is being inserted can be designated with more markers within the file. For example: 354 | 355 | ```ts 356 | export const someExport = "not copied"; 357 | // ts-command-line-args_write-markdown_copyCodeBelow 358 | export function (){ 359 | //this function will be copied 360 | } 361 | // ts-command-line-args_write-markdown_copyCodeAbove 362 | ``` 363 | 364 | Insert code can also be performed in code: 365 | 366 | [//]: # (ts-command-line-args_readme-generation_insertCodeBelow file="src/example/insert-code.example.ts" codeComment="typescript" ) 367 | ```typescript 368 | async function insertSampleCode() { 369 | // this function is inserted into markdown from a ts file using insertCode 370 | await insertCode('src/example/insert-code-sample.md', { 371 | insertCodeBelow: insertCodeBelowDefault, 372 | insertCodeAbove: insertCodeAboveDefault, 373 | copyCodeBelow: copyCodeBelowDefault, 374 | copyCodeAbove: copyCodeAboveDefault, 375 | }); 376 | } 377 | ``` 378 | [//]: # (ts-command-line-args_readme-generation_insertCodeAbove) 379 | 380 | #### Snippets 381 | 382 | If you have a file that you want to copy multiple chunks of code from `snippetName` can be used to specify which section of code you want to copy: 383 | 384 | ``` 385 | [//]: # (ts-command-line-args_write-markdown_insertCodeBelow file="path/from/markdown/to/file.ts" snippetName="mySnippet" ) 386 | ``` 387 | 388 | ```ts 389 | export const someExport = "not copied"; 390 | // ts-command-line-args_write-markdown_copyCodeBelow mySnippet 391 | export function (){ 392 | //this function will be copied 393 | } 394 | // ts-command-line-args_write-markdown_copyCodeAbove 395 | ``` 396 | 397 | ### String Formatting 398 | 399 | The only chalk modifiers supported when converting to markdown are `bold` and `italic`. 400 | For example: 401 | 402 | ``` 403 | {bold bold text} {italic italic text} {italic.bold bold italic text} 404 | ``` 405 | 406 | will be converted to: 407 | 408 | ``` 409 | **boldText** *italic text* ***bold italic text*** 410 | ``` 411 | 412 | ### Additional Modifiers 413 | 414 | Two additional style modifiers have been added that are supported when writing markdown. They are removed when printing to the console. 415 | 416 | ``` 417 | {highlight someText} 418 | ``` 419 | 420 | surrounds the text in backticks: 421 | `someText` 422 | and 423 | 424 | ``` 425 | {code.typescript function(message: string)\\{console.log(message);\\}} 426 | ``` 427 | 428 | Surrounds the text in triple back ticks (with an optional language specifer, in this case typescript): 429 | ```typescript 430 | function(message: string){console.log(message);} 431 | ``` 432 | 433 | ### Javascript Exports 434 | 435 | To generate markdown we must export the argument definitions and options used by `command-line-usage` so that we can `require` the javascript file and import the definitions when running `write-markdown`. We cannot export these values from a javascript file that has any side effects (such as copying files in the case of a `copy-files` node executable) as the same side effects would be executed when we are just trying to generate markdown. 436 | 437 | For example, if we had a `copy-files` application you would organise you code like this: 438 | 439 | `copy-file.constants.ts:` 440 | ```typescript 441 | export interface ICopyFilesArguments { 442 | sourcePath: string; 443 | targetPath: string; 444 | help?: boolean; 445 | } 446 | 447 | const argumentConfig: ArgumentConfig = { 448 | sourcePath: String, 449 | targetPath: String, 450 | help: { type: Boolean, optional: true, alias: 'h', description: 'Prints this usage guide' }, 451 | }; 452 | 453 | const parseOptions: ParseOptions = { 454 | helpArg: 'help', 455 | headerContentSections: [{ header: 'copy-files', content: 'Copies files from sourcePath to targetPath' }], 456 | } 457 | 458 | export const usageGuideInfo: UsageGuideConfig = { 459 | arguments: argumentConfig, 460 | parseOptions, 461 | }; 462 | ``` 463 | 464 | `copy-file.ts:` 465 | ```typescript 466 | // The file that actually does the work and is executed by node to copy files 467 | import { usageGuideInfo } from "./copy-file-constants" 468 | 469 | const args: ICopyFilesArguments = parse(usageGuideInfo.arguments, usageGuideInfo.parseOptions); 470 | 471 | // Perform file copy operations 472 | ``` 473 | 474 | The usage guide would be displayed on the command line with the following command: 475 | 476 | ```bash 477 | $ copy-files -h 478 | ``` 479 | 480 | and markdown would be generated (after typescript had been transpiled into javascript) with: 481 | 482 | ```bash 483 | $ write-markdown -m markdownFile.md -j copy-file.constants.js 484 | ``` 485 | 486 | ## Documentation 487 | 488 | This library is a wrapper around [`command-line-args`](https://www.npmjs.com/package/command-line-args) so any docs or options for that library should apply to this library as well. 489 | 490 | ### Parse 491 | 492 | ```typescript 493 | function parse(config: ArgumentConfig, options: ParseOptions = {}, exitProcess = true): T 494 | ``` 495 | 496 | `parse` will return an object containing all of your command line options. For example with this config: 497 | ```typescript 498 | import {parse} from 'ts-command-line-args'; 499 | 500 | export const args = parse({ 501 | sourcePath: String, 502 | targetPath: { type: String, alias: 't' }, 503 | copyFiles: { type: Boolean, alias: 'c' }, 504 | resetPermissions: Boolean, 505 | filter: { type: String, optional: true }, 506 | excludePaths: { type: String, multiple: true, optional: true }, 507 | }); 508 | ``` 509 | and this command: 510 | ```bash 511 | $ node exampleConfig.js --sourcePath mySource --targetPath myTarget 512 | ``` 513 | the following object will be returned: 514 | ```javascript 515 | { 516 | "sourcePath":"mySource", 517 | "targetPath":"myTarget", 518 | "copyFiles":false, 519 | "resetPermissions":false 520 | } 521 | ``` 522 | (booleans are defaulted to false unless they are marked as `optional`) 523 | 524 | If any required options are omitted (in this case just `sourcePath` and `targetPath`) then an error message will be logged and the process will exit with no further code will be executed after the call to `parse`: 525 | 526 | ```bash 527 | $ node exampleConfigWithHelp.js 528 | Required parameter 'sourcePath' was not passed. Please provide a value by passing '--sourcePath=passedValue' in command line arguments 529 | Required parameter 'targetPath' was not passed. Please provide a value by passing '--targetPath=passedValue' or '-t passedValue' in command line arguments 530 | ``` 531 | 532 | If you do not want the process to exit that this can be disabled by passing false as the last argument: 533 | 534 | ```typescript 535 | import {parse} from 'ts-command-line-args'; 536 | 537 | export const args = parse({ 538 | sourcePath: String, 539 | targetPath: { type: String, alias: 't' }, 540 | copyFiles: { type: Boolean, alias: 'c' }, 541 | resetPermissions: Boolean, 542 | filter: { type: String, optional: true }, 543 | excludePaths: { type: String, multiple: true, optional: true }, 544 | }, 545 | {}, // empty options object 546 | false 547 | ); 548 | ``` 549 | In this case errors will still be logged to the console but the process will not exit and code execution will continue after the call to `parse`. 550 | 551 | ### Option Definitions 552 | 553 | Option definitions must be passed to `parse`. For the most part option definitions are the same as [`OptionDefinition`](https://github.com/75lb/command-line-args/blob/master/doc/option-definition.md) from `command-line-args` except that `name` is not required. Name is not required as we pass an object: 554 | 555 | ```typescript 556 | { 557 | propertyName: {} 558 | } 559 | ``` 560 | rather than an array of options: 561 | ```typescript 562 | [ 563 | { name: "propertyName" } 564 | ] 565 | ``` 566 | 567 | #### Simple Arguments 568 | 569 | For a simple, single, required argument you only need to define the `type`: 570 | 571 | ```typescript 572 | parse({ 573 | stringArg: String, 574 | numberArg: Number, 575 | }); 576 | ``` 577 | 578 | the `type` can be any function with the signature `(value?: string) => T | undefined`. The javascript provided functions `String`, `Number` and `Boolean` all do this for simple data types but a function could be written to convert a passed in string value to a `Date` for example: 579 | 580 | ```typescript 581 | function parseDate(value?: string) { 582 | return value ? new Date(Date.parse(value)) : undefined; 583 | } 584 | 585 | parse({ myDate: parseDate }); 586 | ``` 587 | 588 | A similar function could be written for any complex type. 589 | 590 | #### Further Configuration 591 | 592 | For anything other than a single, required, simple argument a configuration object must be defined for each argument. This call: 593 | 594 | ```typescript 595 | parse({ 596 | stringArg: String, 597 | numberArg: Number 598 | }); 599 | ``` 600 | 601 | and this: 602 | 603 | ```typescript 604 | parse({ 605 | stringArg: { type: String }, 606 | numberArg: { type: Number }, 607 | }); 608 | ``` 609 | 610 | are identical. 611 | 612 | **Optional Arguments** 613 | 614 | If an argument is optional it must be defined as such to avoid console errors being logged and the process exiting when `parse` is called. 615 | 616 | This interface: 617 | 618 | ```typescript 619 | { 620 | requiredArg: string, 621 | optionalArg?: string, 622 | } 623 | ``` 624 | defines `optionalArg` as optional. This must be reflected in the config passed to `parse`: 625 | 626 | ```typescript 627 | parse({ 628 | requiredArg: String, 629 | optionalArg: { type: String, optional: true }, 630 | }); 631 | ``` 632 | 633 | Typescript compilation will fail in the above case without `optional: true` being added. 634 | 635 | **Multiple Values** 636 | 637 | If multiple values can be passed then the argument should be defined as an array: 638 | 639 | ```typescript 640 | { 641 | users: string[], 642 | } 643 | ``` 644 | 645 | and it must defined as `multiple` in the config: 646 | 647 | ```typescript 648 | parse({ 649 | users: {requiredArg: Number, multiple: true}, 650 | }); 651 | ``` 652 | 653 | Typescript compilation will fail in the above case without `multiple: true` being added. 654 | 655 | Multiple values can be passed on the command line as follows: 656 | 657 | ```bash 658 | $ node myApp.js users=Jeoff users=Frank users=Dave 659 | $ node myApp.js users Jeoff users Frank users Dave 660 | $ node myApp.js users=Jeoff Frank Dave 661 | $ node myApp.js users Jeoff Frank Dave 662 | ``` 663 | 664 | For further Option Definition documentation refer to [these docs](https://github.com/75lb/command-line-args/blob/master/doc/option-definition.md). 665 | 666 | ### Options 667 | 668 | Most of the available options are the same as the options defined by [`command-line-args`](https://www.npmjs.com/package/command-line-args): https://github.com/75lb/command-line-args/blob/master/doc/API.md. 669 | 670 | A few additional options have been added: 671 | 672 | **logger** - used for logging errors or help guide to the console. Defaults to `console.log` and `console.error`. 673 | 674 | **helpArg** - used to defined the argument used to generate the usage guide. This is expected to be boolean but the comparison is not strict so any argument type / value that is resolved as truthy will work. 675 | 676 | **headerContentSections** / **footerContentSections** - optional help sections that will appear before / after the `Options` section that is generated from the option config. In most cases you should probably include one header section that explains what the application does. 677 | 678 | [//]: ####ts-command-line-args_generated-by-footer 679 | Markdown Generated by [ts-command-line-args](https://www.npmjs.com/package/ts-command-line-args) -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // The directory where Jest should store its cached dependency information 12 | // cacheDirectory: "C:\\Users\\Giles\\AppData\\Local\\Temp\\jest", 13 | 14 | // Automatically clear mock calls and instances between every test 15 | clearMocks: true, 16 | 17 | // Indicates whether the coverage information should be collected while executing the test 18 | // collectCoverage: false, 19 | 20 | // An array of glob patterns indicating a set of files for which coverage information should be collected 21 | // collectCoverageFrom: undefined, 22 | 23 | // The directory where Jest should output its coverage files 24 | // coverageDirectory: undefined, 25 | 26 | // An array of regexp pattern strings used to skip coverage collection 27 | // coveragePathIgnorePatterns: [ 28 | // "\\\\node_modules\\\\" 29 | // ], 30 | 31 | // Indicates which provider should be used to instrument code for coverage 32 | coverageProvider: 'v8', 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {} 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | preset: 'ts-jest', 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: undefined, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // The number of seconds after which a test is considered as slow and reported as such in the results. 132 | // slowTestThreshold: 5, 133 | 134 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 135 | // snapshotSerializers: [], 136 | 137 | // The test environment that will be used for testing 138 | testEnvironment: 'node' 139 | 140 | // Options that will be passed to the testEnvironment 141 | // testEnvironmentOptions: {}, 142 | 143 | // Adds a location field to test results 144 | // testLocationInResults: false, 145 | 146 | // The glob patterns Jest uses to detect test files 147 | // testMatch: [ 148 | // "**/__tests__/**/*.[jt]s?(x)", 149 | // "**/?(*.)+(spec|test).[tj]s?(x)" 150 | // ], 151 | 152 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 153 | // testPathIgnorePatterns: [ 154 | // "\\\\node_modules\\\\" 155 | // ], 156 | 157 | // The regexp pattern or array of patterns that Jest uses to detect test files 158 | // testRegex: [], 159 | 160 | // This option allows the use of a custom results processor 161 | // testResultsProcessor: undefined, 162 | 163 | // This option allows use of a custom test runner 164 | // testRunner: "jasmine2", 165 | 166 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 167 | // testURL: "http://localhost", 168 | 169 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 170 | // timers: "real", 171 | 172 | // A map from regular expressions to paths to transformers 173 | // transform: undefined, 174 | 175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 176 | // transformIgnorePatterns: [ 177 | // "\\\\node_modules\\\\" 178 | // ], 179 | 180 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 181 | // unmockedModulePathPatterns: undefined, 182 | 183 | // Indicates whether each individual test should be reported during the run 184 | // verbose: undefined, 185 | 186 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 187 | // watchPathIgnorePatterns: [], 188 | 189 | // Whether to use watchman for file crawling 190 | // watchman: true, 191 | } 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-command-line-args", 3 | "version": "2.5.1", 4 | "type": "module", 5 | "description": "A Typescript wrapper around command-line-args with additional support for markdown usage guide generation", 6 | "bin": { 7 | "write-markdown": "dist/write-markdown.js" 8 | }, 9 | "keywords": [ 10 | "argv", 11 | "parse", 12 | "argument", 13 | "args", 14 | "option", 15 | "options", 16 | "parser", 17 | "parsing", 18 | "cli", 19 | "command", 20 | "line", 21 | "typescript" 22 | ], 23 | "main": "dist/index.js", 24 | "types": "dist/index.d.ts", 25 | "scripts": { 26 | "clean": "rimraf dist coverage", 27 | "build": "tsc -p tsconfig.build.json", 28 | "build:example": "tsc -p src/example", 29 | "watch-build:example": "tsc -p src/example --watch", 30 | "watch-build": "tsc --watch -p tsconfig.build.json", 31 | "lint": "eslint . --ext .ts", 32 | "lint:fix": "eslint . --ext .ts --fix", 33 | "test": "jest --ci --coverage && tsc --noemit", 34 | "watch-test": "jest --watch", 35 | "prebuild-release": "npm run clean", 36 | "build-release": "concurrently --kill-others-on-fail npm:test npm:lint npm:build npm:build:example npm:verify-markdown", 37 | "prewrite-markdown": "npm run build", 38 | "preverify-markdown": "npm run build", 39 | "write-markdown": "node dist/write-markdown -f package.json -p markdownConfig", 40 | "verify-markdown": "node dist/write-markdown -f package.json -p markdownConfig -v", 41 | "prepublishOnly": "npm run build --if-present && npm run test --if-present && npm run lint --if-present" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/Roaders/ts-command-line-args.git" 46 | }, 47 | "author": "", 48 | "license": "ISC", 49 | "bugs": { 50 | "url": "https://github.com/Roaders/ts-command-line-args/issues" 51 | }, 52 | "homepage": "https://github.com/Roaders/ts-command-line-args#readme", 53 | "dependencies": { 54 | "chalk": "^4.1.0", 55 | "command-line-args": "^5.1.1", 56 | "command-line-usage": "^6.1.0", 57 | "string-format": "^2.0.0" 58 | }, 59 | "devDependencies": { 60 | "@morgan-stanley/ts-mocking-bird": "^0.7.0", 61 | "@types/command-line-args": "^5.0.0", 62 | "@types/command-line-usage": "^5.0.1", 63 | "@types/jest": "^27.5.1", 64 | "@types/node": "^16.18.23", 65 | "@types/string-format": "^2.0.0", 66 | "@typescript-eslint/eslint-plugin": "^5.29.0", 67 | "@typescript-eslint/parser": "^5.29.0", 68 | "ansi-regex": "^5.0.1", 69 | "concurrently": "^6.3.0", 70 | "eslint": "^7.7.0", 71 | "eslint-config-prettier": "^6.11.0", 72 | "eslint-config-standard": "^14.1.1", 73 | "eslint-plugin-import": "^2.22.0", 74 | "eslint-plugin-node": "^11.1.0", 75 | "eslint-plugin-prettier": "^3.1.4", 76 | "eslint-plugin-promise": "^4.2.1", 77 | "eslint-plugin-standard": "^4.0.1", 78 | "hosted-git-info": "^2.8.9", 79 | "jest": "^28.1.3", 80 | "lodash": "^4.17.21", 81 | "prettier": "^2.1.1", 82 | "rimraf": "^3.0.2", 83 | "ts-jest": "^28.0.8", 84 | "typescript": "5.0" 85 | }, 86 | "markdownConfig": { 87 | "markdownPath": "README.MD", 88 | "jsFile": "dist/write-markdown.constants.js", 89 | "verifyMessage": "'{fileName}' is out of date. Please regenerate by running 'npm run write-markdown'", 90 | "removeDoubleBlankLines": true, 91 | "insertCodeBelow": "[//]: # (ts-command-line-args_readme-generation_insertCodeBelow", 92 | "insertCodeAbove": "[//]: # (ts-command-line-args_readme-generation_insertCodeAbove)" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/contracts.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | 4 | import { ArgumentConfig } from './contracts'; 5 | 6 | /** 7 | * This file is just used for testing type checking at compile time using the // @ts-expect-error feature 8 | */ 9 | describe('contracts', () => { 10 | describe('ArgumentConfig', () => { 11 | describe('simple properties', () => { 12 | interface AllRequired { 13 | name: string; 14 | age: number; 15 | member: boolean; 16 | } 17 | 18 | it('should allow object with sample values', () => { 19 | const sampleConfig: ArgumentConfig = { 20 | name: String, 21 | age: Number, 22 | member: Boolean, 23 | }; 24 | }); 25 | 26 | it('should allow an object with type option definitions', () => { 27 | const config: ArgumentConfig = { 28 | name: { type: String }, 29 | age: { type: Number }, 30 | member: { type: Boolean }, 31 | }; 32 | }); 33 | 34 | it('should not allow missing properties', () => { 35 | // @ts-expect-error 36 | const config: ArgumentConfig = { 37 | name: String, 38 | age: Number, 39 | }; 40 | }); 41 | 42 | it('sample values alone should not allow optional properties', () => { 43 | interface OptionalProperties { 44 | name?: string; 45 | age?: number; 46 | member?: boolean; 47 | } 48 | 49 | const config: ArgumentConfig = { 50 | // @ts-expect-error 51 | name: String, 52 | // @ts-expect-error 53 | age: Number, 54 | // @ts-expect-error 55 | member: Boolean, 56 | }; 57 | }); 58 | 59 | it('should not allow arrays', () => { 60 | interface ArrayProperties { 61 | name: string[]; 62 | age: number[]; 63 | member: boolean[]; 64 | } 65 | 66 | const config: ArgumentConfig = { 67 | // @ts-expect-error 68 | name: String, 69 | // @ts-expect-error 70 | age: Number, 71 | // @ts-expect-error 72 | member: Boolean, 73 | }; 74 | }); 75 | 76 | it('should not allow wrong type constructor', () => { 77 | const configSample: ArgumentConfig = { 78 | // @ts-expect-error 79 | name: Number, 80 | age: Number, 81 | member: Boolean, 82 | }; 83 | 84 | const configTypeOption: ArgumentConfig = { 85 | // @ts-expect-error 86 | name: { type: Number }, 87 | age: { type: Number }, 88 | member: { type: Boolean }, 89 | }; 90 | }); 91 | }); 92 | 93 | describe('complex properties', () => { 94 | interface ComplexProperties { 95 | requiredStringOne: string; 96 | requiredStringTwo: string; 97 | optionalString?: string; 98 | requiredArray: string[]; 99 | optionalArray?: string[]; 100 | } 101 | 102 | it('should not allow object with sample values', () => { 103 | const config: ArgumentConfig = { 104 | // @ts-expect-error 105 | optionalString: String, 106 | // @ts-expect-error 107 | requiredArray: String, 108 | // @ts-expect-error 109 | optionalArray: String, 110 | }; 111 | }); 112 | 113 | it('should allow an object with type option definitions', () => { 114 | const config: ArgumentConfig = { 115 | requiredStringOne: String, 116 | requiredStringTwo: { type: String }, 117 | optionalString: { type: String, optional: true }, 118 | requiredArray: { type: String, multiple: true }, 119 | optionalArray: { type: String, lazyMultiple: true, optional: true }, 120 | }; 121 | }); 122 | 123 | it('should not allow missing properties', () => { 124 | // @ts-expect-error 125 | const config: ArgumentConfig = { 126 | requiredStringOne: String, 127 | requiredStringTwo: { type: String }, 128 | requiredArray: { type: String, multiple: true }, 129 | optionalArray: { type: String, multiple: true, optional: true }, 130 | }; 131 | }); 132 | 133 | it('should not allow wrong type constructor', () => { 134 | const configTypeOption: ArgumentConfig = { 135 | // @ts-expect-error 136 | requiredStringOne: Number, 137 | // @ts-expect-error 138 | requiredStringTwo: { type: Number }, 139 | optionalString: { type: String, optional: true }, 140 | requiredArray: { type: String, multiple: true }, 141 | optionalArray: { type: String, multiple: true, optional: true }, 142 | }; 143 | }); 144 | 145 | it('should allow a complex type with an associated constructor', () => { 146 | interface IMyComplexType { 147 | name: string; 148 | } 149 | 150 | interface IExpectedArgs { 151 | complex: IMyComplexType; 152 | } 153 | 154 | const configTypeOption: ArgumentConfig = { 155 | complex: { type: (value) => (value ? { name: value } : undefined) }, 156 | }; 157 | }); 158 | }); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /src/contracts.ts: -------------------------------------------------------------------------------- 1 | export type ArgumentConfig = { 2 | [P in keyof T]-?: PropertyConfig; 3 | }; 4 | 5 | export type ArgumentOptions = { 6 | [P in keyof T]-?: PropertyOptions; 7 | }; 8 | 9 | interface OptionDefinition { 10 | name: string; 11 | } 12 | 13 | export type CommandLineOption = PropertyOptions & OptionDefinition; 14 | 15 | export type PropertyConfig = undefined extends T ? PropertyOptions : RequiredPropertyOptions; 16 | export type RequiredPropertyOptions = Array extends T 17 | ? PropertyOptions 18 | : TypeConstructor | PropertyOptions; 19 | 20 | export type TypeConstructor = (value: any) => T extends Array ? R | undefined : T | undefined; 21 | 22 | export type PropertyOptions = { 23 | /** 24 | * A setter function (you receive the output from this) enabling you to be specific about the type and value received. Typical values 25 | * are `String`, `Number` and `Boolean` but you can use a custom function. 26 | */ 27 | type: TypeConstructor; 28 | 29 | /** 30 | * A getopt-style short option name. Can be any single character except a digit or hyphen. 31 | */ 32 | alias?: string; 33 | 34 | /** 35 | * Set this flag if the option accepts multiple values. In the output, you will receive an array of values each passed through the `type` function. 36 | */ 37 | multiple?: boolean; 38 | 39 | /** 40 | * Identical to `multiple` but with greedy parsing disabled. 41 | */ 42 | lazyMultiple?: boolean; 43 | 44 | /** 45 | * Any values unaccounted for by an option definition will be set on the `defaultOption`. This flag is typically set 46 | * on the most commonly-used option to enable more concise usage. 47 | */ 48 | defaultOption?: boolean; 49 | 50 | /** 51 | * An initial value for the option. 52 | */ 53 | defaultValue?: T; 54 | 55 | /** 56 | * When your app has a large amount of options it makes sense to organise them in groups. 57 | * 58 | * There are two automatic groups: _all (contains all options) and _none (contains options without a group specified in their definition). 59 | */ 60 | group?: string | string[]; 61 | /** A string describing the option. */ 62 | description?: string; 63 | /** A string to replace the default type string (e.g. ). It's often more useful to set a more descriptive type label, like , , , etc.. */ 64 | typeLabel?: string; 65 | } & OptionalPropertyOptions & 66 | MultiplePropertyOptions; 67 | 68 | export type OptionalProperty = { optional: true }; 69 | 70 | export type OptionalPropertyOptions = undefined extends T ? OptionalProperty : unknown; 71 | 72 | export type MultiplePropertyOptions = Array extends T ? { multiple: true } | { lazyMultiple: true } : unknown; 73 | 74 | export type HeaderLevel = 1 | 2 | 3 | 4 | 5; 75 | 76 | export type PickType = Pick[K] extends TType ? K : never }[keyof T]>; 77 | 78 | export interface UsageGuideOptions { 79 | /** 80 | * help sections to be listed before the options section 81 | */ 82 | headerContentSections?: Content[]; 83 | 84 | /** 85 | * help sections to be listed after the options section 86 | */ 87 | footerContentSections?: Content[]; 88 | 89 | /** 90 | * Used when generating error messages. 91 | * For example if a param is missing and there is a help option the error message will contain: 92 | * 93 | * 'To view help guide run myBaseCommand -h' 94 | */ 95 | baseCommand?: string; 96 | 97 | /** 98 | * Heading level to use for the options header 99 | * Only used when generating markdown 100 | * Defaults to 2 101 | */ 102 | optionsHeaderLevel?: HeaderLevel; 103 | 104 | /** 105 | * The header level to use for sections. Can be overridden on individual section definitions 106 | * defaults to 1 107 | */ 108 | defaultSectionHeaderLevel?: HeaderLevel; 109 | 110 | /** 111 | * Heading level text to use for options section 112 | * defaults to "Options"; 113 | */ 114 | optionsHeaderText?: string; 115 | 116 | /** 117 | * Used to define multiple options sections. If this is used `optionsHeaderLevel` and `optionsHeaderText` are ignored. 118 | */ 119 | optionSections?: OptionContent[]; 120 | } 121 | 122 | export interface ArgsParseOptions extends UsageGuideOptions { 123 | /** 124 | * An array of strings which if present will be parsed instead of `process.argv`. 125 | */ 126 | argv?: string[]; 127 | 128 | /** 129 | * A logger for printing errors for missing properties. 130 | * Defaults to console 131 | */ 132 | logger?: typeof console; 133 | 134 | /** 135 | * The command line argument used to show help 136 | * By default when this property is true help will be printed and the process will exit 137 | */ 138 | helpArg?: keyof PickType; 139 | 140 | /** 141 | * The command line argument with path of file to load arguments from 142 | * If this property is set the file will be loaded and used to create the returned arguments object. 143 | * The file can contain a partial object, missing required arguments must be specified on the command line 144 | * Any arguments specified on the command line will override those specified in the file. 145 | * The config object must be all strings (or arrays of strings) that will then be passed to the type function specified for that argument 146 | * For boolean use: 147 | * { 148 | * myBooleanArg: "true" 149 | * } 150 | */ 151 | loadFromFileArg?: keyof PickType; 152 | 153 | /** 154 | * The command line argument specifying the json path of the config object within the file 155 | * If loadFromFileArg is specified the json path is used to locate the config object in the loaded json file 156 | * If not specified the whole file will be used 157 | * This allows the specification to be defined within the package.json file for example: 158 | * loadFromFileJsonPath: "config.writeMarkdown" 159 | * package.json: 160 | * { 161 | * name: "myApp", 162 | * version: "1.1.1", 163 | * dependencies: {}, 164 | * config: { 165 | * writeMarkdown: { 166 | * markdownPath: [ "myMarkdownFile.md" ] 167 | * } 168 | * } 169 | * } 170 | */ 171 | loadFromFileJsonPathArg?: keyof PickType; 172 | 173 | /** 174 | * When set to true the error message stating which arguments are missing are not printed 175 | */ 176 | hideMissingArgMessages?: boolean; 177 | 178 | /** 179 | * By default when a required arg is missing an error will be thrown. 180 | * If this set to true the usage guide will be printed out instead 181 | */ 182 | showHelpWhenArgsMissing?: boolean; 183 | 184 | /** 185 | * If showHelpWhenArgsMissing is enabled this header section is displayed before the help content. 186 | * A static section can be defined or a function that will return a section. This function is passed an array of required params that where not supplied. 187 | */ 188 | helpWhenArgMissingHeader?: 189 | | ((missingArgs: CommandLineOption[]) => Omit) 190 | | Omit; 191 | 192 | /** 193 | * adds a (O), (D) or both to typeLabel to indicate if a property is optional or the default option 194 | */ 195 | displayOptionalAndDefault?: boolean; 196 | 197 | /** 198 | * if displayOptionalAndDefault is true and any params are optional or default adds a footer explaining what the (O), (D) means 199 | */ 200 | addOptionalDefaultExplanatoryFooter?: boolean; 201 | 202 | /** 203 | * prepends the supplied description with details about the param. These include default option, optional and the default value. 204 | */ 205 | prependParamOptionsToDescription?: boolean; 206 | 207 | /** 208 | * sets the exit code of the process when exiting early due to missing args or showing usage guide 209 | * 0 will be used for an exit code if this is not specified. 210 | */ 211 | processExitCode?: number | ProcessExitCodeFunction; 212 | } 213 | 214 | export type ProcessExitCodeFunction = ( 215 | reason: ExitReason, 216 | passedArgs: Partial, 217 | missingArgs: CommandLineOption[], 218 | ) => number; 219 | export type ExitReason = 'missingArgs' | 'usageGuide'; 220 | 221 | export interface PartialParseOptions extends ArgsParseOptions { 222 | /** 223 | * If `true`, `commandLineArgs` will not throw on unknown options or values, instead returning them in the `_unknown` property of the output. 224 | */ 225 | partial: true; 226 | } 227 | 228 | export interface StopParseOptions extends ArgsParseOptions { 229 | /** 230 | * If `true`, `commandLineArgs` will not throw on unknown options or values. Instead, parsing will stop at the first unknown argument 231 | * and the remaining arguments returned in the `_unknown` property of the output. If set, `partial: true` is implied. 232 | */ 233 | stopAtFirstUnknown: true; 234 | } 235 | 236 | export type CommandLineResults = R extends false 237 | ? // eslint-disable-next-line @typescript-eslint/ban-types 238 | {} 239 | : { 240 | _commandLineResults: { 241 | missingArgs: CommandLineOption[]; 242 | printHelp: () => void; 243 | }; 244 | }; 245 | 246 | type UnknownProps = { _unknown: string[] }; 247 | 248 | export type UnknownProperties = T extends PartialParseOptions 249 | ? UnknownProps 250 | : T extends StopParseOptions 251 | ? UnknownProps 252 | : unknown; 253 | 254 | export type ParseOptions = ArgsParseOptions | PartialParseOptions | StopParseOptions; 255 | 256 | export interface SectionHeader { 257 | /** The section header, always bold and underlined. */ 258 | header?: string; 259 | 260 | /** 261 | * Heading level to use for the header 262 | * Only used when generating markdown 263 | * Defaults to 1 264 | */ 265 | headerLevel?: HeaderLevel; 266 | } 267 | 268 | export interface OptionContent extends SectionHeader { 269 | /** The group name or names. use '_none' for options without a group */ 270 | group?: string | string[]; 271 | /** The names of one of more option definitions to hide from the option list. */ 272 | hide?: string | string[]; 273 | /** If true, the option alias will be displayed after the name, i.e. --verbose, -v instead of -v, --verbose). */ 274 | reverseNameOrder?: boolean; 275 | } 276 | 277 | /** A Content section comprises a header and one or more lines of content. */ 278 | export interface Content extends SectionHeader { 279 | /** 280 | * Overloaded property, accepting data in one of four formats. 281 | * 1. A single string (one line of text). 282 | * 2. An array of strings (multiple lines of text). 283 | * 3. An array of objects (recordset-style data). In this case, the data will be rendered in table format. The property names of each object are not important, so long as they are 284 | * consistent throughout the array. 285 | * 4. An object with two properties - data and options. In this case, the data and options will be passed directly to the underlying table layout module for rendering. 286 | */ 287 | content?: string | string[] | any[]; 288 | 289 | includeIn?: 'markdown' | 'cli' | 'both'; 290 | } 291 | 292 | export interface IInsertCodeOptions { 293 | insertCodeBelow?: string; 294 | insertCodeAbove?: string; 295 | copyCodeBelow?: string; 296 | copyCodeAbove?: string; 297 | removeDoubleBlankLines: boolean; 298 | } 299 | 300 | export interface IReplaceOptions { 301 | replaceBelow?: string; 302 | replaceAbove?: string; 303 | removeDoubleBlankLines: boolean; 304 | } 305 | 306 | export type JsImport = { jsFile: string; importName: string }; 307 | 308 | export interface IWriteMarkDown extends IReplaceOptions, IInsertCodeOptions { 309 | markdownPath: string; 310 | jsFile?: string[]; 311 | configImportName: string[]; 312 | help: boolean; 313 | verify: boolean; 314 | configFile?: string; 315 | jsonPath?: string; 316 | verifyMessage?: string; 317 | skipFooter: boolean; 318 | } 319 | 320 | export type UsageGuideConfig = { 321 | arguments: ArgumentConfig; 322 | parseOptions?: ParseOptions; 323 | }; 324 | 325 | export interface OptionList { 326 | header?: string; 327 | /** An array of option definition objects. */ 328 | optionList?: OptionDefinition[]; 329 | /** If specified, only options from this particular group will be printed. */ 330 | group?: string | string[]; 331 | /** The names of one of more option definitions to hide from the option list. */ 332 | hide?: string | string[]; 333 | /** If true, the option alias will be displayed after the name, i.e. --verbose, -v instead of -v, --verbose). */ 334 | reverseNameOrder?: boolean; 335 | /** An options object suitable for passing into table-layout. */ 336 | tableOptions?: any; 337 | } 338 | -------------------------------------------------------------------------------- /src/example/additionalModifiers.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | import { additionalModifiers } from './configs'; 3 | 4 | const args = parse(additionalModifiers.arguments, additionalModifiers.parseOptions); 5 | 6 | console.log(`args: ${JSON.stringify(args)}`); 7 | -------------------------------------------------------------------------------- /src/example/configs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | import { ArgumentConfig, UsageGuideConfig } from '../'; 3 | 4 | /** 5 | * we do not have any side effects in this file as this file is required by write-markdown 6 | * If there were side effects they would be executed when generating markdown 7 | * The execution for this config is performed in 'example.ts' that imports this file. 8 | */ 9 | 10 | export interface ICopyFilesArguments { 11 | sourcePath: string; 12 | targetPath: string; 13 | copyFiles: boolean; 14 | resetPermissions: boolean; 15 | filter?: string; 16 | excludePaths?: string[]; 17 | } 18 | 19 | export const argumentConfig: ArgumentConfig = { 20 | sourcePath: { type: String, defaultOption: true }, 21 | targetPath: String, 22 | copyFiles: { 23 | type: Boolean, 24 | alias: 'c', 25 | typeLabel: `{underline.bold file[]}`, 26 | description: `{bold bold text} {italic italic text} {italic.bold bold italic text}`, 27 | }, 28 | resetPermissions: Boolean, 29 | filter: { type: String, optional: true }, 30 | excludePaths: { type: String, multiple: true, optional: true }, 31 | }; 32 | 33 | export const usageGuideInfo: UsageGuideConfig = { 34 | arguments: argumentConfig, 35 | }; 36 | 37 | export interface ITypicalAppWithGroups { 38 | help: boolean; 39 | src: string[]; 40 | timeout: string; 41 | plugin?: string; 42 | } 43 | 44 | const typicalAppConfig: ArgumentConfig = { 45 | help: { 46 | description: 'Display this usage guide.', 47 | alias: 'h', 48 | type: Boolean, 49 | group: 'main', 50 | }, 51 | src: { 52 | description: 'The input files to process', 53 | multiple: true, 54 | defaultOption: true, 55 | typeLabel: '{underline file} ...', 56 | group: 'input', 57 | type: String, 58 | }, 59 | timeout: { 60 | description: 'Timeout value in ms', 61 | alias: 't', 62 | typeLabel: '{underline ms}', 63 | group: 'main', 64 | type: String, 65 | defaultValue: '1000', 66 | }, 67 | plugin: { 68 | description: 'A plugin path', 69 | type: String, 70 | optional: true, 71 | }, 72 | }; 73 | 74 | export const typicalAppWithGroupsInfo: UsageGuideConfig = { 75 | arguments: typicalAppConfig, 76 | parseOptions: { 77 | helpArg: 'help', 78 | headerContentSections: [ 79 | { 80 | header: 'A typical app', 81 | content: 'Generates something {italic very} important.', 82 | }, 83 | ], 84 | optionSections: [ 85 | { 86 | header: 'Main options', 87 | group: ['main', 'input'], 88 | }, 89 | { 90 | header: 'Misc', 91 | group: '_none', 92 | }, 93 | ], 94 | prependParamOptionsToDescription: true, 95 | }, 96 | }; 97 | 98 | export const exampleSections: UsageGuideConfig = { 99 | arguments: typicalAppConfig, 100 | parseOptions: { 101 | helpArg: 'help', 102 | headerContentSections: [ 103 | { 104 | header: 'A typical app', 105 | content: 'Generates something {italic very} important.', 106 | }, 107 | { 108 | header: 'both', 109 | includeIn: 'both', 110 | }, 111 | { 112 | header: 'cli', 113 | includeIn: 'cli', 114 | }, 115 | { 116 | header: 'markdown', 117 | includeIn: 'markdown', 118 | }, 119 | { 120 | header: 'Synopsis', 121 | content: [ 122 | '$ example [{bold --timeout} {underline ms}] {bold --src} {underline file} ...', 123 | '$ example {bold --help}', 124 | ], 125 | }, 126 | ], 127 | footerContentSections: [ 128 | { 129 | header: 'Examples', 130 | content: [ 131 | { 132 | Description: '1. A concise example. ', 133 | Example: '$ example -t 100 lib/*.js', 134 | }, 135 | { 136 | Description: '2. A long example. ', 137 | Example: '$ example --timeout 100 --src lib/*.js', 138 | }, 139 | { 140 | Description: 141 | '3. This example will scan space for unknown things. Take cure when scanning space, it could take some time. ', 142 | Example: 143 | '$ example --src galaxy1.facts galaxy1.facts galaxy2.facts galaxy3.facts galaxy4.facts galaxy5.facts', 144 | }, 145 | ], 146 | }, 147 | { 148 | header: 'both', 149 | includeIn: 'both', 150 | }, 151 | { 152 | header: 'cli', 153 | includeIn: 'cli', 154 | }, 155 | { 156 | header: 'markdown', 157 | includeIn: 'markdown', 158 | }, 159 | { 160 | content: 'Project home: {underline https://github.com/me/example}', 161 | }, 162 | ], 163 | }, 164 | }; 165 | 166 | export const additionalModifiers: UsageGuideConfig = { 167 | arguments: typicalAppConfig, 168 | parseOptions: { 169 | helpArg: 'help', 170 | headerContentSections: [ 171 | { 172 | header: 'Highlight Modifier', 173 | content: 'Some text that {highlight highlights} certain words', 174 | }, 175 | { 176 | header: 'Code Modifier', 177 | content: [`Block of code: {code function logMessage(message: string) \\{console.log(message);\\}}`], 178 | }, 179 | { 180 | header: 'Code With Language Modifier', 181 | content: [ 182 | `Block of code: {code.typescript function logMessage(message: string) \\{console.log(message);\\}}`, 183 | ], 184 | }, 185 | ], 186 | }, 187 | }; 188 | -------------------------------------------------------------------------------- /src/example/exampleConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": { 3 | "exampleConfig": { 4 | "sourcePath": "sourceFromExampleConfigFile", 5 | "targetPath": "targetFromExampleConfigFile", 6 | "copyFiles": true, 7 | "resetPermissions": false, 8 | "excludePaths": ["one", "two"] 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/example/exampleConfig.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | import { argumentConfig } from './configs'; 3 | 4 | const args = parse(argumentConfig); 5 | 6 | console.log(`exampleConfig args: ${JSON.stringify(args)}`); 7 | -------------------------------------------------------------------------------- /src/example/exampleConfigHelpWithMissingArgs.ts: -------------------------------------------------------------------------------- 1 | import { parse, CommandLineOption } from '../'; 2 | import { argumentConfig } from './configs'; 3 | 4 | const args = parse(argumentConfig, { 5 | showHelpWhenArgsMissing: true, 6 | helpWhenArgMissingHeader: (missingArgs: CommandLineOption[]) => ({ 7 | header: 'Missing Arguments', 8 | content: `The arguments [${missingArgs 9 | .map((arg) => arg.name) 10 | .join(', ')}] were not supplied. Please see help below:`, 11 | }), 12 | }); 13 | 14 | console.log(`exampleConfig args: ${JSON.stringify(args)}`); 15 | -------------------------------------------------------------------------------- /src/example/exampleConfigUsingPrintHelp.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | import { argumentConfig } from './configs'; 3 | 4 | const args = parse(argumentConfig, { hideMissingArgMessages: true }, false, true); 5 | 6 | if (args._commandLineResults.missingArgs.length > 0) { 7 | args._commandLineResults.printHelp(); 8 | } else { 9 | console.log(`exampleConfig args: ${JSON.stringify(args)}`); 10 | } 11 | -------------------------------------------------------------------------------- /src/example/exampleConfigWithHelp.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | 3 | interface ICopyFilesArguments { 4 | sourcePath?: string; 5 | targetPath: string; 6 | copyFiles: boolean; 7 | resetPermissions: boolean; 8 | filter?: string; 9 | excludePaths?: string[]; 10 | help?: boolean; 11 | configFile?: string; 12 | configPath?: string; 13 | } 14 | 15 | export const args = parse( 16 | { 17 | sourcePath: { type: String, defaultOption: true, optional: true, description: 'The path to copy files from' }, 18 | targetPath: { type: String, defaultValue: 'dist' }, 19 | copyFiles: { 20 | type: Boolean, 21 | alias: 'c', 22 | typeLabel: `{underline file[]}`, 23 | description: `{bold bold text} {italic italic text} {italic.bold bold italic text}`, 24 | }, 25 | resetPermissions: Boolean, 26 | filter: { type: String, optional: true }, 27 | excludePaths: { type: String, multiple: true, optional: true }, 28 | help: { type: Boolean, optional: true, alias: 'h', description: 'Prints this usage guide' }, 29 | configFile: { type: String, optional: true }, 30 | configPath: { type: String, optional: true }, 31 | }, 32 | { 33 | helpArg: 'help', 34 | baseCommand: 'node exampleConfigWithHelp', 35 | headerContentSections: [{ header: 'My Example Config', content: 'Thanks for using Our Awesome Library' }], 36 | footerContentSections: [{ header: 'Footer', content: `Copyright: Big Faceless Corp. inc.` }], 37 | loadFromFileArg: 'configFile', 38 | loadFromFileJsonPathArg: 'configPath', 39 | prependParamOptionsToDescription: true, 40 | }, 41 | ); 42 | 43 | console.log(`args: ${JSON.stringify(args)}`); 44 | -------------------------------------------------------------------------------- /src/example/exitProcessExample.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | import { argumentConfig } from './configs'; 3 | 4 | /** 5 | * will log errors if there are any missing properties then call process.exit() 6 | * result typed as IMyExampleInterface 7 | */ 8 | const exampleArguments = parse(argumentConfig); 9 | 10 | console.log(`ParsedArguments:`); 11 | console.log(JSON.stringify(exampleArguments, undefined, 2)); 12 | -------------------------------------------------------------------------------- /src/example/insert-code-sample.md: -------------------------------------------------------------------------------- 1 | # Code inserted from file: 2 | 3 | [//]: # (ts-command-line-args_write-markdown_insertCodeBelow file="./insert-code.example.ts" codeComment="ts") 4 | ```ts 5 | async function insertSampleCode() { 6 | // this function is inserted into markdown from a ts file using insertCode 7 | await insertCode('src/example/insert-code-sample.md', { 8 | insertCodeBelow: insertCodeBelowDefault, 9 | insertCodeAbove: insertCodeAboveDefault, 10 | copyCodeBelow: copyCodeBelowDefault, 11 | copyCodeAbove: copyCodeAboveDefault, 12 | }); 13 | } 14 | ``` 15 | [//]: # (ts-command-line-args_write-markdown_insertCodeAbove) 16 | 17 | The above code was inserted using `ts-command-line-args` -------------------------------------------------------------------------------- /src/example/insert-code.example.ts: -------------------------------------------------------------------------------- 1 | import { insertCode } from '../'; 2 | import { 3 | copyCodeAboveDefault, 4 | copyCodeBelowDefault, 5 | insertCodeAboveDefault, 6 | insertCodeBelowDefault, 7 | } from '../write-markdown.constants'; 8 | 9 | // ts-command-line-args_write-markdown_copyCodeBelow 10 | async function insertSampleCode() { 11 | // this function is inserted into markdown from a ts file using insertCode 12 | await insertCode('src/example/insert-code-sample.md', { 13 | insertCodeBelow: insertCodeBelowDefault, 14 | insertCodeAbove: insertCodeAboveDefault, 15 | copyCodeBelow: copyCodeBelowDefault, 16 | copyCodeAbove: copyCodeAboveDefault, 17 | }); 18 | } 19 | // ts-command-line-args_write-markdown_copyCodeAbove 20 | 21 | insertSampleCode(); 22 | -------------------------------------------------------------------------------- /src/example/noExitProcessExample.ts: -------------------------------------------------------------------------------- 1 | import { argumentConfig } from './configs'; 2 | import { parse } from '../'; 3 | 4 | /** 5 | * will ignore missing errors and not exit 6 | * result typed as IMyExampleInterface | undefined 7 | */ 8 | const exampleArgumentsOrUndefined = parse(argumentConfig, {}, false); 9 | 10 | console.log(`ParsedArguments:`); 11 | console.log(JSON.stringify(exampleArgumentsOrUndefined, undefined, 2)); 12 | -------------------------------------------------------------------------------- /src/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | } 6 | } -------------------------------------------------------------------------------- /src/example/typicalAppWithGroups.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | import { typicalAppWithGroupsInfo } from './configs'; 3 | 4 | const args = parse(typicalAppWithGroupsInfo.arguments, typicalAppWithGroupsInfo.parseOptions); 5 | 6 | console.log(`args: ${JSON.stringify(args)}`); 7 | -------------------------------------------------------------------------------- /src/example/unknownArgumentsExample.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | import { argumentConfig } from './configs'; 3 | 4 | /** 5 | * adds unknown arguments to _unknown property and does not throw for unknowns 6 | * result typed as IMyExampleInterface & { _unknown: string[] } 7 | */ 8 | const exampleArgumentsWithUnknown = parse(argumentConfig, { partial: true }); 9 | 10 | console.log(`Unknown Arguments:`); 11 | console.log(JSON.stringify(exampleArgumentsWithUnknown._unknown, undefined, 2)); 12 | -------------------------------------------------------------------------------- /src/example/usageGuideWithExamples.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../'; 2 | import { exampleSections } from './configs'; 3 | 4 | const args = parse(exampleSections.arguments, exampleSections.parseOptions); 5 | 6 | console.log(`args: ${JSON.stringify(args)}`); 7 | -------------------------------------------------------------------------------- /src/helpers/add-content.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { addContent } from './add-content.helper'; 2 | import { IReplaceOptions } from '../contracts'; 3 | 4 | describe('content.helper', () => { 5 | describe('addContent', () => { 6 | const newContent = `new content line one 7 | new content line two`; 8 | 9 | let config: IReplaceOptions; 10 | 11 | beforeEach(() => { 12 | config = { 13 | replaceAbove: '##replaceAbove', 14 | replaceBelow: '##replaceBelow', 15 | removeDoubleBlankLines: false, 16 | }; 17 | }); 18 | 19 | it('should replace whole content when no markers found', () => { 20 | const initial = `content line 1 21 | content line 2`; 22 | const result = addContent(initial, newContent, config); 23 | 24 | expect(result).toBe(newContent); 25 | }); 26 | 27 | it('should add content at the end when replaceBelow found at end of content', () => { 28 | const initial = `content line 1 29 | content line 2 30 | ##replaceBelow`; 31 | const result = addContent(initial, newContent, config); 32 | 33 | expect(result).toBe(`content line 1 34 | content line 2 35 | ##replaceBelow 36 | new content line one 37 | new content line two`); 38 | }); 39 | 40 | it('should replace content at the end when replaceBelow found mid-content', () => { 41 | const initial = `content line 1 42 | ##replaceBelow 43 | content line 2`; 44 | const result = addContent(initial, newContent, config); 45 | 46 | expect(result).toBe(`content line 1 47 | ##replaceBelow 48 | new content line one 49 | new content line two`); 50 | }); 51 | 52 | it('should add content at the top when replace above found at top of content', () => { 53 | const initial = `##replaceAbove 54 | content line 1 55 | content line 2`; 56 | const result = addContent(initial, newContent, config); 57 | 58 | expect(result).toBe(`new content line one 59 | new content line two 60 | ##replaceAbove 61 | content line 1 62 | content line 2`); 63 | }); 64 | 65 | it('should replace content at the top when replace above found mid-document', () => { 66 | const initial = `content line 1 67 | ##replaceAbove 68 | content line 2`; 69 | const result = addContent(initial, newContent, config); 70 | 71 | expect(result).toBe(`new content line one 72 | new content line two 73 | ##replaceAbove 74 | content line 2`); 75 | }); 76 | 77 | it('should add content between markers when no content exists already', () => { 78 | const initial = `content line 1 79 | ##replaceBelow 80 | ##replaceAbove 81 | content line 2`; 82 | const result = addContent(initial, newContent, config); 83 | 84 | expect(result).toBe(`content line 1 85 | ##replaceBelow 86 | new content line one 87 | new content line two 88 | ##replaceAbove 89 | content line 2`); 90 | }); 91 | 92 | it('should replace content between markers when content already exists', () => { 93 | const initial = `content line 1 94 | ##replaceBelow 95 | content line 2 96 | ##replaceAbove 97 | content line 3`; 98 | const result = addContent(initial, newContent, config); 99 | 100 | expect(result).toBe(`content line 1 101 | ##replaceBelow 102 | new content line one 103 | new content line two 104 | ##replaceAbove 105 | content line 3`); 106 | }); 107 | 108 | it('should replace content between markers when content already exists and an array of content passed in', () => { 109 | const initial = `content line 1 110 | ##replaceBelow 111 | content line 2 112 | ##replaceAbove 113 | content line 3`; 114 | const result = addContent( 115 | initial, 116 | [ 117 | newContent, 118 | `other new content line one 119 | other new content line two`, 120 | ], 121 | config, 122 | ); 123 | 124 | expect(result).toBe(`content line 1 125 | ##replaceBelow 126 | new content line one 127 | new content line two 128 | other new content line one 129 | other new content line two 130 | ##replaceAbove 131 | content line 3`); 132 | }); 133 | 134 | it('should throw an error if add below appears above add above', () => { 135 | const initial = `content line 1 136 | ##replaceAbove 137 | ##replaceBelow 138 | content line 3`; 139 | expect(() => addContent(initial, newContent, config)).toThrowError( 140 | `The replaceAbove marker '##replaceAbove' was found before the replaceBelow marker '##replaceBelow'. The replaceBelow marked must be before the replaceAbove.`, 141 | ); 142 | }); 143 | 144 | it('should not remove empty lines', () => { 145 | const initial = `content line 1 146 | 147 | 148 | content line 2 149 | ##replaceBelow 150 | ##replaceAbove 151 | content line 3`; 152 | const result = addContent( 153 | initial, 154 | `new content line one 155 | 156 | 157 | 158 | new content line two`, 159 | config, 160 | ); 161 | 162 | expect(result).toBe(`content line 1 163 | 164 | 165 | content line 2 166 | ##replaceBelow 167 | new content line one 168 | 169 | 170 | 171 | new content line two 172 | ##replaceAbove 173 | content line 3`); 174 | }); 175 | 176 | it('should remove empty lines when passed in config', () => { 177 | const initial = `content line 1 178 | 179 | 180 | content line 2 181 | ##replaceBelow 182 | ##replaceAbove 183 | content line 3`; 184 | const result = addContent( 185 | initial, 186 | `new content line one 187 | 188 | 189 | 190 | new content line two`, 191 | { ...config, removeDoubleBlankLines: true }, 192 | ); 193 | 194 | expect(result).toBe(`content line 1 195 | 196 | content line 2 197 | ##replaceBelow 198 | new content line one 199 | 200 | new content line two 201 | ##replaceAbove 202 | content line 3`); 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /src/helpers/add-content.helper.ts: -------------------------------------------------------------------------------- 1 | import { IReplaceOptions } from '../contracts'; 2 | import { footerReplaceBelowMarker } from '../write-markdown.constants.js'; 3 | import { splitContent, findEscapeSequence, filterDoubleBlankLines } from './line-ending.helper.js'; 4 | 5 | /** 6 | * Adds or replaces content between 2 markers within a text string 7 | * @param inputString 8 | * @param content 9 | * @param options 10 | * @returns 11 | */ 12 | export function addContent(inputString: string, content: string | string[], options: IReplaceOptions): string { 13 | const replaceBelow = options?.replaceBelow; 14 | const replaceAbove = options?.replaceAbove; 15 | content = Array.isArray(content) ? content : [content]; 16 | 17 | const lineBreak = findEscapeSequence(inputString); 18 | const lines = splitContent(inputString); 19 | const replaceBelowLine = 20 | replaceBelow != null ? lines.filter((line) => line.indexOf(replaceBelow) === 0)[0] : undefined; 21 | const replaceBelowIndex = replaceBelowLine != null ? lines.indexOf(replaceBelowLine) : -1; 22 | const replaceAboveLine = 23 | replaceAbove != null ? lines.filter((line) => line.indexOf(replaceAbove) === 0)[0] : undefined; 24 | const replaceAboveIndex = replaceAboveLine != null ? lines.indexOf(replaceAboveLine) : -1; 25 | 26 | if (replaceAboveIndex > -1 && replaceBelowIndex > -1 && replaceAboveIndex < replaceBelowIndex) { 27 | throw new Error( 28 | `The replaceAbove marker '${options.replaceAbove}' was found before the replaceBelow marker '${options.replaceBelow}'. The replaceBelow marked must be before the replaceAbove.`, 29 | ); 30 | } 31 | 32 | const linesBefore = lines.slice(0, replaceBelowIndex + 1); 33 | const linesAfter = replaceAboveIndex >= 0 ? lines.slice(replaceAboveIndex) : []; 34 | 35 | const contentLines = content.reduce( 36 | (lines, currentContent) => [...lines, ...splitContent(currentContent)], 37 | new Array(), 38 | ); 39 | 40 | let allLines = [...linesBefore, ...contentLines, ...linesAfter]; 41 | 42 | if (options.removeDoubleBlankLines) { 43 | allLines = allLines.filter((line, index, lines) => filterDoubleBlankLines(line, index, lines)); 44 | } 45 | 46 | return allLines.join(lineBreak); 47 | } 48 | 49 | export function addCommandLineArgsFooter(fileContent: string): string { 50 | if (fileContent.indexOf(footerReplaceBelowMarker) < 0) { 51 | fileContent = `${fileContent} 52 | 53 | ${footerReplaceBelowMarker}`; 54 | } 55 | 56 | const footerContent = `Markdown Generated by [ts-command-line-args](https://www.npmjs.com/package/ts-command-line-args)`; 57 | 58 | return addContent(fileContent, footerContent, { 59 | replaceBelow: footerReplaceBelowMarker, 60 | removeDoubleBlankLines: false, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/helpers/command-line.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { createCommandLineConfig, mergeConfig, normaliseConfig } from './command-line.helper'; 2 | import { ArgumentConfig, ArgumentOptions } from '../contracts'; 3 | 4 | describe('command-line.helper', () => { 5 | interface ComplexProperties { 6 | requiredStringOne: string; 7 | requiredStringTwo: string; 8 | optionalString?: string; 9 | requiredArray: string[]; 10 | optionalArray?: string[]; 11 | } 12 | 13 | function getConfig(): ArgumentConfig { 14 | return { 15 | requiredStringOne: String, 16 | requiredStringTwo: { type: String }, 17 | optionalString: { type: String, optional: true }, 18 | requiredArray: { type: String, multiple: true }, 19 | optionalArray: { type: String, lazyMultiple: true, optional: true }, 20 | }; 21 | } 22 | 23 | describe('normaliseConfig', () => { 24 | it('should replace type constructors with objects', () => { 25 | const normalised = normaliseConfig(getConfig()); 26 | 27 | expect(normalised).toEqual({ 28 | requiredStringOne: { type: String }, 29 | requiredStringTwo: { type: String }, 30 | optionalString: { type: String, optional: true }, 31 | requiredArray: { type: String, multiple: true }, 32 | optionalArray: { type: String, lazyMultiple: true, optional: true }, 33 | }); 34 | }); 35 | }); 36 | 37 | describe('createCommandLineConfig', () => { 38 | it('should create expected config', () => { 39 | const commandLineConfig = createCommandLineConfig(normaliseConfig(getConfig())); 40 | 41 | expect(commandLineConfig).toEqual([ 42 | { name: 'requiredStringOne', type: String }, 43 | { name: 'requiredStringTwo', type: String }, 44 | { name: 'optionalString', type: String, optional: true }, 45 | { name: 'requiredArray', type: String, multiple: true }, 46 | { name: 'optionalArray', type: String, lazyMultiple: true, optional: true }, 47 | ]); 48 | }); 49 | }); 50 | 51 | describe('mergeConfig', () => { 52 | interface ISampleInterface { 53 | stringOne: string; 54 | stringTwo: string; 55 | strings: string[]; 56 | number: number; 57 | boolean: boolean; 58 | dates: Date[]; 59 | optionalObject?: { value: string }; 60 | configPath?: string; 61 | } 62 | 63 | let options: ArgumentOptions; 64 | 65 | beforeEach(() => { 66 | options = { 67 | stringOne: { type: String }, 68 | stringTwo: { type: String }, 69 | strings: { type: String, multiple: true }, 70 | number: { type: Number }, 71 | boolean: { type: Boolean }, 72 | dates: { type: (value) => new Date(Date.parse(value)), multiple: true }, 73 | optionalObject: { type: (value) => (typeof value === 'string' ? { value } : value), optional: true }, 74 | configPath: { type: String, optional: true }, 75 | }; 76 | }); 77 | 78 | type FileConfigTest = { 79 | description: string; 80 | parsedArgs: Partial>; 81 | parsedArgsNoDefaults?: Partial>; 82 | fileContent: Record; 83 | expected: Partial; 84 | jsonPath?: keyof ISampleInterface; 85 | }; 86 | const fileConfigTests: FileConfigTest[] = [ 87 | { 88 | description: 'no arguments passed', 89 | parsedArgs: {}, 90 | fileContent: { 91 | stringOne: 'stringOneFromFile', 92 | stringTwo: 'stringTwoFromFile', 93 | }, 94 | expected: { 95 | stringOne: 'stringOneFromFile', 96 | stringTwo: 'stringTwoFromFile', 97 | }, 98 | }, 99 | { 100 | description: 'file content is empty', 101 | parsedArgs: { 102 | stringOne: 'stringOneFromArgs', 103 | stringTwo: 'stringTwoFromArgs', 104 | number: 36, 105 | boolean: false, 106 | dates: [new Date()], 107 | }, 108 | fileContent: {}, 109 | expected: { 110 | stringOne: 'stringOneFromArgs', 111 | stringTwo: 'stringTwoFromArgs', 112 | number: 36, 113 | boolean: false, 114 | dates: [new Date()], 115 | }, 116 | }, 117 | { 118 | description: 'both file content and parsed args have values', 119 | parsedArgs: { 120 | stringOne: 'stringOneFromArgs', 121 | boolean: false, 122 | }, 123 | fileContent: { 124 | stringTwo: 'stringTwoFromFile', 125 | number: 55, 126 | }, 127 | expected: { 128 | stringOne: 'stringOneFromArgs', 129 | boolean: false, 130 | stringTwo: 'stringTwoFromFile', 131 | number: 55, 132 | }, 133 | }, 134 | { 135 | description: 'file content and parsed args have conflicting values', 136 | parsedArgs: { 137 | stringOne: 'stringOneFromArgs', 138 | number: 55, 139 | boolean: false, 140 | dates: [new Date(2020, 5, 1)], 141 | }, 142 | fileContent: { 143 | stringOne: 'stringOneFromFile', 144 | stringTwo: 'stringTwoFromFile', 145 | number: 36, 146 | boolean: true, 147 | dates: 'March 1 2020', 148 | randomOtherProp: '', 149 | }, 150 | expected: { 151 | stringOne: 'stringOneFromArgs', 152 | stringTwo: 'stringTwoFromFile', 153 | number: 55, 154 | boolean: false, 155 | dates: [new Date(2020, 5, 1)], 156 | }, 157 | }, 158 | { 159 | description: 'config file overrides default', 160 | parsedArgs: { optionalObject: { value: 'parsedValue' } }, 161 | parsedArgsNoDefaults: {}, 162 | fileContent: { 163 | optionalObject: { value: 'valueFromFile' }, 164 | }, 165 | expected: { 166 | optionalObject: { value: 'valueFromFile' }, 167 | }, 168 | }, 169 | { 170 | description: 'parsed args overrides config file and default', 171 | parsedArgs: { optionalObject: { value: 'parsedValue' } }, 172 | parsedArgsNoDefaults: { optionalObject: { value: 'parsedValue' } }, 173 | fileContent: { 174 | optionalObject: { value: 'valueFromFile' }, 175 | }, 176 | expected: { 177 | optionalObject: { value: 'parsedValue' }, 178 | }, 179 | }, 180 | { 181 | description: 'jsonPath set', 182 | parsedArgs: { 183 | configPath: 'configs.cmdLineConfig.example', 184 | }, 185 | fileContent: { 186 | configs: { 187 | cmdLineConfig: { 188 | example: { 189 | stringOne: 'stringOneFromFile', 190 | stringTwo: 'stringTwoFromFile', 191 | }, 192 | }, 193 | }, 194 | }, 195 | expected: { 196 | stringOne: 'stringOneFromFile', 197 | stringTwo: 'stringTwoFromFile', 198 | configPath: 'configs.cmdLineConfig.example', 199 | }, 200 | jsonPath: 'configPath', 201 | }, 202 | ]; 203 | 204 | fileConfigTests.forEach((test) => { 205 | it(`should return configFromFile when ${test.description}`, () => { 206 | const result = mergeConfig( 207 | test.parsedArgs, 208 | test.parsedArgsNoDefaults || test.parsedArgs, 209 | test.fileContent, 210 | options, 211 | test.jsonPath, 212 | ); 213 | 214 | expect(result).toEqual(test.expected); 215 | }); 216 | }); 217 | 218 | type ConversionTest = { 219 | fromFile: Partial>; 220 | expected: Partial; 221 | }; 222 | 223 | const typeConversionTests: ConversionTest[] = [ 224 | { fromFile: { stringOne: 'stringOne' }, expected: { stringOne: 'stringOne' } }, 225 | { fromFile: { strings: 'stringOne' }, expected: { strings: ['stringOne'] } }, 226 | { fromFile: { strings: ['stringOne', 'stringTwo'] }, expected: { strings: ['stringOne', 'stringTwo'] } }, 227 | { fromFile: { number: '1' }, expected: { number: 1 } }, 228 | { fromFile: { number: 1 }, expected: { number: 1 } }, 229 | { fromFile: { number: 'one' }, expected: { number: NaN } }, 230 | { fromFile: { boolean: true }, expected: { boolean: true } }, 231 | { fromFile: { boolean: false }, expected: { boolean: false } }, 232 | { fromFile: { boolean: 1 }, expected: { boolean: true } }, 233 | { fromFile: { boolean: 0 }, expected: { boolean: false } }, 234 | { fromFile: { boolean: 'true' }, expected: { boolean: true } }, 235 | { fromFile: { boolean: 'false' }, expected: { boolean: false } }, 236 | { fromFile: { dates: '2020/03/04' }, expected: { dates: [new Date(2020, 2, 4)] } }, 237 | { 238 | fromFile: { dates: ['2020/03/04', '2020/05/06'] }, 239 | expected: { dates: [new Date(2020, 2, 4), new Date(2020, 4, 6)] }, 240 | }, 241 | ]; 242 | 243 | typeConversionTests.forEach((test) => { 244 | it(`should convert all configfromFile properties with type conversion function with input: '${JSON.stringify( 245 | test.fromFile, 246 | )}'`, () => { 247 | const result = mergeConfig({}, {}, test.fromFile, options, undefined); 248 | 249 | expect(result).toEqual(test.expected); 250 | }); 251 | }); 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /src/helpers/command-line.helper.ts: -------------------------------------------------------------------------------- 1 | import { PropertyOptions, ArgumentConfig, ArgumentOptions, CommandLineOption } from '../contracts'; 2 | import { isBoolean } from './options.helper.js'; 3 | 4 | export function createCommandLineConfig(config: ArgumentOptions): CommandLineOption[] { 5 | return Object.keys(config).map((key) => { 6 | const argConfig: any = config[key as keyof T]; 7 | const definition: PropertyOptions = typeof argConfig === 'object' ? argConfig : { type: argConfig }; 8 | 9 | return { name: key, ...definition }; 10 | }); 11 | } 12 | 13 | export function normaliseConfig(config: ArgumentConfig): ArgumentOptions { 14 | Object.keys(config).forEach((key) => { 15 | const argConfig: any = config[key as keyof T]; 16 | config[key as keyof T] = typeof argConfig === 'object' ? argConfig : { type: argConfig }; 17 | }); 18 | 19 | return config as ArgumentOptions; 20 | } 21 | 22 | export function mergeConfig( 23 | parsedConfig: Partial, 24 | parsedConfigWithoutDefaults: Partial, 25 | fileContent: Record, 26 | options: ArgumentOptions, 27 | jsonPath: keyof T | undefined, 28 | ): Partial { 29 | const configPath: string | undefined = jsonPath ? (parsedConfig[jsonPath] as any) : undefined; 30 | const configFromFile = resolveConfigFromFile(fileContent, configPath); 31 | if (configFromFile == null) { 32 | throw new Error(`Could not resolve config object from specified file and path`); 33 | } 34 | return { ...parsedConfig, ...applyTypeConversion(configFromFile, options), ...parsedConfigWithoutDefaults }; 35 | } 36 | 37 | function resolveConfigFromFile(configfromFile: any, configPath?: string): Partial> { 38 | if (configPath == null || configPath == '') { 39 | return configfromFile as Partial>; 40 | } 41 | const paths = configPath.split('.'); 42 | const key = paths.shift(); 43 | 44 | if (key == null) { 45 | return configfromFile; 46 | } 47 | 48 | const config = configfromFile[key]; 49 | return resolveConfigFromFile(config, paths.join('.')); 50 | } 51 | 52 | function applyTypeConversion( 53 | configfromFile: Partial>, 54 | options: ArgumentOptions, 55 | ): Partial { 56 | const transformedParams: Partial = {}; 57 | 58 | Object.keys(configfromFile).forEach((prop) => { 59 | const key = prop as keyof T; 60 | const argumentOptions = options[key]; 61 | if (argumentOptions == null) { 62 | return; 63 | } 64 | const fileValue = configfromFile[key]; 65 | if (argumentOptions.multiple || argumentOptions.lazyMultiple) { 66 | const fileArrayValue = Array.isArray(fileValue) ? fileValue : [fileValue]; 67 | 68 | transformedParams[key] = fileArrayValue.map((arrayValue) => 69 | convertType(arrayValue, argumentOptions), 70 | ) as any; 71 | } else { 72 | transformedParams[key] = convertType(fileValue, argumentOptions) as any; 73 | } 74 | }); 75 | 76 | return transformedParams; 77 | } 78 | 79 | function convertType(value: any, propOptions: PropertyOptions): any { 80 | if (propOptions.type.name === 'Boolean') { 81 | switch (value) { 82 | case 'true': 83 | return propOptions.type(true); 84 | case 'false': 85 | return propOptions.type(false); 86 | } 87 | } 88 | 89 | return propOptions.type(value); 90 | } 91 | 92 | type ArgsAndLastOption = { args: string[]; lastOption?: PropertyOptions }; 93 | type PartialAndLastOption = { 94 | partial: Partial; 95 | lastOption?: PropertyOptions; 96 | lastName?: Extract; 97 | }; 98 | const argNameRegExp = /^-{1,2}(\w+)(=(\w+))?$/; 99 | const booleanValue = ['1', '0', 'true', 'false']; 100 | 101 | /** 102 | * commandLineArgs throws an error if we pass aa value for a boolean arg as follows: 103 | * myCommand -a=true --booleanArg=false --otherArg true 104 | * this function removes these booleans so as to avoid errors from commandLineArgs 105 | * @param args 106 | * @param config 107 | */ 108 | export function removeBooleanValues(args: string[], config: ArgumentOptions): string[] { 109 | function removeBooleanArgs(argsAndLastValue: ArgsAndLastOption, arg: string): ArgsAndLastOption { 110 | const { argOptions, argValue } = getParamConfig(arg, config); 111 | 112 | const lastOption = argsAndLastValue.lastOption; 113 | 114 | if (lastOption != null && isBoolean(lastOption) && booleanValue.some((boolValue) => boolValue === arg)) { 115 | const args = argsAndLastValue.args.concat(); 116 | args.pop(); 117 | return { args }; 118 | } else if (argOptions != null && isBoolean(argOptions) && argValue != null) { 119 | return { args: argsAndLastValue.args }; 120 | } else { 121 | return { args: [...argsAndLastValue.args, arg], lastOption: argOptions }; 122 | } 123 | } 124 | 125 | return args.reduce(removeBooleanArgs, { args: [] }).args; 126 | } 127 | 128 | /** 129 | * Gets the values of any boolean arguments that were specified on the command line with a value 130 | * These arguments were removed by removeBooleanValues 131 | * @param args 132 | * @param config 133 | */ 134 | export function getBooleanValues(args: string[], config: ArgumentOptions): Partial { 135 | function getBooleanValues(argsAndLastOption: PartialAndLastOption, arg: string): PartialAndLastOption { 136 | const { argOptions, argName, argValue } = getParamConfig(arg, config); 137 | 138 | const lastOption = argsAndLastOption.lastOption; 139 | 140 | if (argOptions != null && isBoolean(argOptions) && argValue != null && argName != null) { 141 | argsAndLastOption.partial[argName] = convertType(argValue, argOptions) as any; 142 | } else if ( 143 | argsAndLastOption.lastName != null && 144 | lastOption != null && 145 | isBoolean(lastOption) && 146 | booleanValue.some((boolValue) => boolValue === arg) 147 | ) { 148 | argsAndLastOption.partial[argsAndLastOption.lastName] = convertType(arg, lastOption) as any; 149 | } 150 | return { partial: argsAndLastOption.partial, lastName: argName, lastOption: argOptions }; 151 | } 152 | 153 | return args.reduce>(getBooleanValues, { partial: {} }).partial; 154 | } 155 | 156 | function getParamConfig( 157 | arg: string, 158 | config: ArgumentOptions, 159 | ): { argName?: Extract; argOptions?: PropertyOptions; argValue?: string } { 160 | const regExpResult = argNameRegExp.exec(arg); 161 | if (regExpResult == null) { 162 | return {}; 163 | } 164 | 165 | const nameOrAlias = regExpResult[1]; 166 | 167 | for (const argName in config) { 168 | const argConfig = config[argName]; 169 | 170 | if (argName === nameOrAlias || argConfig.alias === nameOrAlias) { 171 | return { argOptions: argConfig as PropertyOptions, argName, argValue: regExpResult[3] }; 172 | } 173 | } 174 | return {}; 175 | } 176 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './command-line.helper.js'; 2 | export * from './add-content.helper.js'; 3 | export * from './markdown.helper.js'; 4 | export * from './visitor.js'; 5 | export * from './line-ending.helper.js'; 6 | export * from './options.helper.js'; 7 | export * from './string.helper.js'; 8 | export * from './insert-code.helper.js'; 9 | -------------------------------------------------------------------------------- /src/helpers/insert-code.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { IInsertCodeOptions } from '../contracts'; 2 | import { 3 | insertCodeBelowDefault, 4 | insertCodeAboveDefault, 5 | copyCodeBelowDefault, 6 | copyCodeAboveDefault, 7 | } from '../write-markdown.constants'; 8 | import { insertCode } from './insert-code.helper'; 9 | import * as originalFs from 'fs'; 10 | import { any, IMocked, Mock, registerMock, reset, setupFunction } from '@morgan-stanley/ts-mocking-bird'; 11 | import { EOL } from 'os'; 12 | import { resolve, join } from 'path'; 13 | 14 | const beforeInsertionLine = `beforeInsertion`; 15 | const afterInsertionLine = `afterInsertion`; 16 | 17 | const insertLineOne = `insertLineOne`; 18 | const insertLineTwo = `insertLineTwo`; 19 | 20 | let insertBelowToken = `${insertCodeBelowDefault} file="someFile.ts" )`; 21 | 22 | const sampleDirName = `sample/dirname`; 23 | 24 | // eslint-disable-next-line @typescript-eslint/no-var-requires 25 | jest.mock('fs', () => require('@morgan-stanley/ts-mocking-bird').proxyJestModule(require.resolve('fs'))); 26 | 27 | describe(`(${insertCode.name}) insert-code.helper`, () => { 28 | let mockedFs: IMocked; 29 | let insertCodeFromContent: string; 30 | 31 | beforeEach(() => { 32 | insertBelowToken = `${insertCodeBelowDefault} file="someFile.ts" )`; 33 | insertCodeFromContent = `${insertLineOne}${EOL}${insertLineTwo}`; 34 | 35 | mockedFs = Mock.create().setup( 36 | setupFunction('readFile', ((_path: string, callback: (err: Error | null, data: Buffer) => void) => { 37 | callback(null, Buffer.from(insertCodeFromContent)); 38 | }) as any), 39 | setupFunction('writeFile', ((_path: string, _data: any, callback: () => void) => { 40 | callback(); 41 | }) as any), 42 | ); 43 | 44 | registerMock(originalFs, mockedFs.mock); 45 | }); 46 | 47 | afterEach(() => { 48 | reset(originalFs); 49 | }); 50 | 51 | function createOptions(partialOptions?: Partial): IInsertCodeOptions { 52 | return { 53 | insertCodeBelow: insertCodeBelowDefault, 54 | insertCodeAbove: insertCodeAboveDefault, 55 | copyCodeBelow: copyCodeBelowDefault, 56 | copyCodeAbove: copyCodeAboveDefault, 57 | removeDoubleBlankLines: false, 58 | ...partialOptions, 59 | }; 60 | } 61 | 62 | it(`should return original string when no insertBelow token provided`, async () => { 63 | const fileContent = [beforeInsertionLine, afterInsertionLine].join('\n'); 64 | 65 | const result = await insertCode( 66 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 67 | createOptions({ insertCodeAbove: undefined, insertCodeBelow: undefined }), 68 | ); 69 | 70 | expect(result).toEqual(fileContent); 71 | }); 72 | 73 | it(`should return original string when no insertBelow token found`, async () => { 74 | const fileContent = [beforeInsertionLine, afterInsertionLine].join('\n'); 75 | 76 | const result = await insertCode( 77 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 78 | createOptions(), 79 | ); 80 | 81 | expect(result).toEqual(fileContent); 82 | }); 83 | 84 | it(`should insert all file content with default tokens`, async () => { 85 | const fileContent = [beforeInsertionLine, insertBelowToken, insertCodeAboveDefault, afterInsertionLine].join( 86 | '\n', 87 | ); 88 | 89 | const result = await insertCode( 90 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 91 | createOptions(), 92 | ); 93 | 94 | expect( 95 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 96 | ).wasCalledOnce(); 97 | 98 | const expectedContent = [ 99 | beforeInsertionLine, 100 | insertBelowToken, 101 | insertLineOne, 102 | insertLineTwo, 103 | insertCodeAboveDefault, 104 | afterInsertionLine, 105 | ].join('\n'); 106 | 107 | expect(result).toEqual(expectedContent); 108 | }); 109 | 110 | it(`should insert all file content when passed a file path`, async () => { 111 | const fileContent = [beforeInsertionLine, insertBelowToken, insertCodeAboveDefault, afterInsertionLine].join( 112 | '\n', 113 | ); 114 | 115 | mockedFs.setupFunction('readFile', ((path: string, callback: (err: Error | null, data: Buffer) => void) => { 116 | if (path.indexOf(`originalFilePath.ts`) > 0) { 117 | callback(null, Buffer.from(fileContent)); 118 | } else { 119 | callback(null, Buffer.from(`${insertLineOne}${EOL}${insertLineTwo}`)); 120 | } 121 | }) as any); 122 | 123 | const result = await insertCode(`${sampleDirName}/originalFilePath.ts`, createOptions()); 124 | 125 | expect( 126 | mockedFs.withFunction('readFile').withParameters(resolve(sampleDirName, 'someFile.ts'), any()), 127 | ).wasCalledOnce(); 128 | expect( 129 | mockedFs.withFunction('readFile').withParameters(resolve(`${sampleDirName}/originalFilePath.ts`), any()), 130 | ).wasCalledOnce(); 131 | expect( 132 | mockedFs 133 | .withFunction('writeFile') 134 | .withParameters(resolve(`${sampleDirName}/originalFilePath.ts`), any(), any()), 135 | ).wasCalledOnce(); 136 | 137 | const expectedContent = [ 138 | beforeInsertionLine, 139 | insertBelowToken, 140 | insertLineOne, 141 | insertLineTwo, 142 | insertCodeAboveDefault, 143 | afterInsertionLine, 144 | ].join('\n'); 145 | 146 | expect(result).toEqual(expectedContent); 147 | }); 148 | 149 | it(`should remove double blank lines if set to true`, async () => { 150 | const fileContent = [ 151 | beforeInsertionLine, 152 | insertBelowToken, 153 | '', 154 | '', 155 | '', 156 | insertCodeAboveDefault, 157 | afterInsertionLine, 158 | ].join('\n'); 159 | 160 | const result = await insertCode( 161 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 162 | createOptions({ removeDoubleBlankLines: true }), 163 | ); 164 | 165 | expect( 166 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 167 | ).wasCalledOnce(); 168 | 169 | const expectedContent = [ 170 | beforeInsertionLine, 171 | insertBelowToken, 172 | insertLineOne, 173 | insertLineTwo, 174 | insertCodeAboveDefault, 175 | afterInsertionLine, 176 | ].join('\n'); 177 | 178 | expect(result).toEqual(expectedContent); 179 | }); 180 | 181 | it(`should insert all file content with custom tokens`, async () => { 182 | const fileContent = [ 183 | beforeInsertionLine, 184 | `customInsertAfterToken file="somePath"`, 185 | `customInsertBeforeToken`, 186 | afterInsertionLine, 187 | ].join('\n'); 188 | 189 | const result = await insertCode( 190 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 191 | createOptions({ insertCodeBelow: `customInsertAfterToken`, insertCodeAbove: `customInsertBeforeToken` }), 192 | ); 193 | 194 | expect( 195 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'somePath'), any()), 196 | ).wasCalledOnce(); 197 | 198 | const expectedContent = [ 199 | beforeInsertionLine, 200 | `customInsertAfterToken file="somePath"`, 201 | insertLineOne, 202 | insertLineTwo, 203 | `customInsertBeforeToken`, 204 | afterInsertionLine, 205 | ].join('\n'); 206 | 207 | expect(result).toEqual(expectedContent); 208 | }); 209 | 210 | it(`should remove end of file if no insertAbove token`, async () => { 211 | const fileContent = [beforeInsertionLine, insertBelowToken, afterInsertionLine].join('\n'); 212 | 213 | const result = await insertCode( 214 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 215 | createOptions(), 216 | ); 217 | 218 | expect( 219 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 220 | ).wasCalledOnce(); 221 | 222 | const expectedContent = [beforeInsertionLine, insertBelowToken, insertLineOne, insertLineTwo].join('\n'); 223 | 224 | expect(result).toEqual(expectedContent); 225 | }); 226 | 227 | it(`should throw error if insertBelow token provided with no file`, async () => { 228 | const fileContent = [ 229 | beforeInsertionLine, 230 | insertCodeBelowDefault, 231 | insertCodeAboveDefault, 232 | afterInsertionLine, 233 | ].join('\n'); 234 | 235 | let error: Error | undefined; 236 | 237 | try { 238 | await insertCode({ fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, createOptions()); 239 | } catch (e: any) { 240 | error = e; 241 | } 242 | 243 | expect(error?.message).toEqual( 244 | `insert code token ([//]: # (ts-command-line-args_write-markdown_insertCodeBelow) found in file but file path not specified (file="relativePath/from/markdown/toFile.whatever")`, 245 | ); 246 | }); 247 | 248 | it(`should should only insert file content between copyAbove and copyBelow tokens`, async () => { 249 | insertCodeFromContent = [ 250 | 'randomFirstLine', 251 | copyCodeBelowDefault, 252 | insertLineOne, 253 | copyCodeAboveDefault, 254 | insertLineTwo, 255 | ].join('\n'); 256 | const fileContent = [beforeInsertionLine, insertBelowToken, insertCodeAboveDefault, afterInsertionLine].join( 257 | '\n', 258 | ); 259 | 260 | const result = await insertCode( 261 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 262 | createOptions(), 263 | ); 264 | 265 | expect( 266 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 267 | ).wasCalledOnce(); 268 | 269 | const expectedContent = [ 270 | beforeInsertionLine, 271 | insertBelowToken, 272 | insertLineOne, 273 | insertCodeAboveDefault, 274 | afterInsertionLine, 275 | ].join('\n'); 276 | 277 | expect(result).toEqual(expectedContent); 278 | }); 279 | 280 | it(`should insert selected snippet when snippet defined`, async () => { 281 | insertCodeFromContent = [ 282 | 'randomFirstLine', 283 | `// ts-command-line-args_write-markdown_copyCodeBelow expectedSnippet`, 284 | insertLineOne, 285 | copyCodeAboveDefault, 286 | copyCodeBelowDefault, 287 | insertLineTwo, 288 | copyCodeAboveDefault, 289 | ].join('\n'); 290 | insertBelowToken = `${insertCodeBelowDefault} file="someFile.ts" snippetName="expectedSnippet" )`; 291 | 292 | const fileContent = [beforeInsertionLine, insertBelowToken, insertCodeAboveDefault, afterInsertionLine].join( 293 | '\n', 294 | ); 295 | 296 | const result = await insertCode( 297 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 298 | createOptions(), 299 | ); 300 | 301 | expect( 302 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 303 | ).wasCalledOnce(); 304 | 305 | const expectedContent = [ 306 | beforeInsertionLine, 307 | insertBelowToken, 308 | insertLineOne, 309 | insertCodeAboveDefault, 310 | afterInsertionLine, 311 | ].join('\n'); 312 | 313 | expect(result).toEqual(expectedContent); 314 | }); 315 | 316 | it(`should should only insert file content after copyBelow token`, async () => { 317 | const fileContent = [beforeInsertionLine, insertBelowToken, insertCodeAboveDefault, afterInsertionLine].join( 318 | '\n', 319 | ); 320 | 321 | const fileLines = [insertLineOne, copyCodeBelowDefault, insertLineTwo]; 322 | 323 | mockedFs.setupFunction('readFile', ((_path: string, callback: (err: Error | null, data: Buffer) => void) => { 324 | callback(null, Buffer.from(fileLines.join(EOL))); 325 | }) as any); 326 | 327 | const result = await insertCode( 328 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 329 | createOptions(), 330 | ); 331 | 332 | expect( 333 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 334 | ).wasCalledOnce(); 335 | 336 | const expectedContent = [ 337 | beforeInsertionLine, 338 | insertBelowToken, 339 | insertLineTwo, 340 | insertCodeAboveDefault, 341 | afterInsertionLine, 342 | ].join('\n'); 343 | 344 | expect(result).toEqual(expectedContent); 345 | }); 346 | 347 | it(`should should only insert file content above copyAbove token`, async () => { 348 | const fileContent = [beforeInsertionLine, insertBelowToken, insertCodeAboveDefault, afterInsertionLine].join( 349 | '\n', 350 | ); 351 | 352 | const fileLines = [insertLineOne, copyCodeAboveDefault, insertLineTwo]; 353 | 354 | mockedFs.setupFunction('readFile', ((_path: string, callback: (err: Error | null, data: Buffer) => void) => { 355 | callback(null, Buffer.from(fileLines.join(EOL))); 356 | }) as any); 357 | 358 | const result = await insertCode( 359 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 360 | createOptions(), 361 | ); 362 | 363 | expect( 364 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 365 | ).wasCalledOnce(); 366 | 367 | const expectedContent = [ 368 | beforeInsertionLine, 369 | insertBelowToken, 370 | insertLineOne, 371 | insertCodeAboveDefault, 372 | afterInsertionLine, 373 | ].join('\n'); 374 | 375 | expect(result).toEqual(expectedContent); 376 | }); 377 | 378 | it(`should insert a code comment`, async () => { 379 | const fileContent = [ 380 | beforeInsertionLine, 381 | `${insertCodeBelowDefault} file="someFile.ts" codeComment )`, 382 | insertCodeAboveDefault, 383 | afterInsertionLine, 384 | ].join('\n'); 385 | 386 | const result = await insertCode( 387 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 388 | createOptions(), 389 | ); 390 | 391 | expect( 392 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 393 | ).wasCalledOnce(); 394 | 395 | const expectedContent = [ 396 | beforeInsertionLine, 397 | `${insertCodeBelowDefault} file="someFile.ts" codeComment )`, 398 | '```', 399 | insertLineOne, 400 | insertLineTwo, 401 | '```', 402 | insertCodeAboveDefault, 403 | afterInsertionLine, 404 | ].join('\n'); 405 | 406 | expect(result).toEqual(expectedContent); 407 | }); 408 | 409 | it(`should insert a name code comment`, async () => { 410 | const fileContent = [ 411 | beforeInsertionLine, 412 | `${insertCodeBelowDefault} file="someFile.ts" codeComment="ts" )`, 413 | insertCodeAboveDefault, 414 | afterInsertionLine, 415 | ].join('\n'); 416 | 417 | const result = await insertCode( 418 | { fileContent, filePath: `${sampleDirName}/'originalFilePath.ts` }, 419 | createOptions(), 420 | ); 421 | 422 | expect( 423 | mockedFs.withFunction('readFile').withParameters(join(sampleDirName, 'someFile.ts'), any()), 424 | ).wasCalledOnce(); 425 | 426 | const expectedContent = [ 427 | beforeInsertionLine, 428 | `${insertCodeBelowDefault} file="someFile.ts" codeComment="ts" )`, 429 | '```ts', 430 | insertLineOne, 431 | insertLineTwo, 432 | '```', 433 | insertCodeAboveDefault, 434 | afterInsertionLine, 435 | ].join('\n'); 436 | 437 | expect(result).toEqual(expectedContent); 438 | }); 439 | 440 | it(`should insert content from 2 different files in 2 different locations`, async () => { 441 | const inBetweenFilesLine = 'in between files'; 442 | const fileContent = [ 443 | beforeInsertionLine, 444 | `${insertCodeBelowDefault} file="insertFileOne.ts" )`, 445 | insertCodeAboveDefault, 446 | inBetweenFilesLine, 447 | `${insertCodeBelowDefault} file="insertFileTwo.ts" )`, 448 | insertCodeAboveDefault, 449 | afterInsertionLine, 450 | ].join('\n'); 451 | 452 | mockedFs.setupFunction('readFile', ((path: string, callback: (err: Error | null, data: Buffer) => void) => { 453 | if (path.indexOf(`originalFilePath.ts`) > 0) { 454 | callback(null, Buffer.from(fileContent)); 455 | } else if (path.indexOf(`insertFileOne.ts`) > 0) { 456 | callback(null, Buffer.from(`fileOneLineOne${EOL}fileOneLineTwo`)); 457 | } else if (path.indexOf(`insertFileTwo.ts`) > 0) { 458 | callback(null, Buffer.from(`fileTwoLineOne${EOL}fileTwoLineTwo`)); 459 | } else { 460 | throw new Error(`unknown file path: ${path}`); 461 | } 462 | }) as any); 463 | 464 | const result = await insertCode(`${sampleDirName}/'originalFilePath.ts`, createOptions()); 465 | 466 | expect( 467 | mockedFs.withFunction('readFile').withParameters(resolve(sampleDirName, 'insertFileOne.ts'), any()), 468 | ).wasCalledOnce(); 469 | expect( 470 | mockedFs.withFunction('readFile').withParameters(resolve(sampleDirName, 'insertFileTwo.ts'), any()), 471 | ).wasCalledOnce(); 472 | expect( 473 | mockedFs.withFunction('readFile').withParameters(resolve(`${sampleDirName}/'originalFilePath.ts`), any()), 474 | ).wasCalledOnce(); 475 | 476 | const expectedContent = [ 477 | beforeInsertionLine, 478 | `${insertCodeBelowDefault} file="insertFileOne.ts" )`, 479 | `fileOneLineOne`, 480 | `fileOneLineTwo`, 481 | insertCodeAboveDefault, 482 | inBetweenFilesLine, 483 | `${insertCodeBelowDefault} file="insertFileTwo.ts" )`, 484 | `fileTwoLineOne`, 485 | `fileTwoLineTwo`, 486 | insertCodeAboveDefault, 487 | afterInsertionLine, 488 | ].join('\n'); 489 | 490 | expect(result).toEqual(expectedContent); 491 | }); 492 | }); 493 | -------------------------------------------------------------------------------- /src/helpers/insert-code.helper.ts: -------------------------------------------------------------------------------- 1 | import { IInsertCodeOptions } from '../contracts'; 2 | import { filterDoubleBlankLines, findEscapeSequence, splitContent } from './line-ending.helper.js'; 3 | import { isAbsolute, resolve, dirname, join } from 'path'; 4 | import { promisify } from 'util'; 5 | import { readFile, writeFile } from 'fs'; 6 | import chalk from 'chalk'; 7 | 8 | const asyncReadFile = promisify(readFile); 9 | const asyncWriteFile = promisify(writeFile); 10 | 11 | export type FileDetails = { 12 | filePath: string; 13 | fileContent: string; 14 | }; 15 | 16 | /** 17 | * Loads content from other files and inserts it into the target file 18 | * @param input - if a string is provided the target file is loaded from that path AND saved to that path once content has been inserted. If a `FileDetails` object is provided the content is not saved when done. 19 | * @param partialOptions - optional. changes the default tokens 20 | */ 21 | export async function insertCode( 22 | input: FileDetails | string, 23 | partialOptions?: Partial, 24 | ): Promise { 25 | const options: IInsertCodeOptions = { removeDoubleBlankLines: false, ...partialOptions }; 26 | 27 | let fileDetails: FileDetails; 28 | 29 | if (typeof input === 'string') { 30 | const filePath = resolve(input); 31 | console.log(`Loading existing file from '${chalk.blue(filePath)}'`); 32 | fileDetails = { filePath, fileContent: (await asyncReadFile(filePath)).toString() }; 33 | } else { 34 | fileDetails = input; 35 | } 36 | 37 | const content = fileDetails.fileContent; 38 | 39 | const lineBreak = findEscapeSequence(content); 40 | let lines = splitContent(content); 41 | 42 | lines = await insertCodeImpl(fileDetails.filePath, lines, options, 0); 43 | 44 | if (options.removeDoubleBlankLines) { 45 | lines = lines.filter((line, index, lines) => filterDoubleBlankLines(line, index, lines)); 46 | } 47 | 48 | const modifiedContent = lines.join(lineBreak); 49 | 50 | if (typeof input === 'string') { 51 | console.log(`Saving modified content to '${chalk.blue(fileDetails.filePath)}'`); 52 | await asyncWriteFile(fileDetails.filePath, modifiedContent); 53 | } 54 | 55 | return modifiedContent; 56 | } 57 | 58 | async function insertCodeImpl( 59 | filePath: string, 60 | lines: string[], 61 | options: IInsertCodeOptions, 62 | startLine: number, 63 | ): Promise { 64 | const insertCodeBelow = options?.insertCodeBelow; 65 | const insertCodeAbove = options?.insertCodeAbove; 66 | 67 | if (insertCodeBelow == null) { 68 | return Promise.resolve(lines); 69 | } 70 | 71 | const insertCodeBelowResult = 72 | insertCodeBelow != null 73 | ? findIndex(lines, (line) => line.indexOf(insertCodeBelow) === 0, startLine) 74 | : undefined; 75 | 76 | if (insertCodeBelowResult == null) { 77 | return Promise.resolve(lines); 78 | } 79 | 80 | const insertCodeAboveResult = 81 | insertCodeAbove != null 82 | ? findIndex(lines, (line) => line.indexOf(insertCodeAbove) === 0, insertCodeBelowResult.lineIndex) 83 | : undefined; 84 | 85 | const linesFromFile = await loadLines(filePath, options, insertCodeBelowResult); 86 | 87 | const linesBefore = lines.slice(0, insertCodeBelowResult.lineIndex + 1); 88 | const linesAfter = insertCodeAboveResult != null ? lines.slice(insertCodeAboveResult.lineIndex) : []; 89 | 90 | lines = [...linesBefore, ...linesFromFile, ...linesAfter]; 91 | 92 | return insertCodeAboveResult == null 93 | ? lines 94 | : insertCodeImpl(filePath, lines, options, insertCodeAboveResult.lineIndex); 95 | } 96 | 97 | const fileRegExp = /file="([^"]+)"/; 98 | const codeCommentRegExp = /codeComment(="([^"]+)")?/; //https://regex101.com/r/3MVdBO/1 99 | const snippetRegExp = /snippetName="([^"]+)"/; 100 | 101 | async function loadLines( 102 | targetFilePath: string, 103 | options: IInsertCodeOptions, 104 | result: FindLineResults, 105 | ): Promise { 106 | const partialPathResult = fileRegExp.exec(result.line); 107 | 108 | if (partialPathResult == null) { 109 | throw new Error( 110 | `insert code token (${options.insertCodeBelow}) found in file but file path not specified (file="relativePath/from/markdown/toFile.whatever")`, 111 | ); 112 | } 113 | const codeCommentResult = codeCommentRegExp.exec(result.line); 114 | const snippetResult = snippetRegExp.exec(result.line); 115 | const partialPath = partialPathResult[1]; 116 | 117 | const filePath = isAbsolute(partialPath) ? partialPath : join(dirname(targetFilePath), partialPathResult[1]); 118 | console.log(`Inserting code from '${chalk.blue(filePath)}' into '${chalk.blue(targetFilePath)}'`); 119 | 120 | const fileBuffer = await asyncReadFile(filePath); 121 | 122 | let contentLines = splitContent(fileBuffer.toString()); 123 | 124 | const copyBelowMarker = options.copyCodeBelow; 125 | const copyAboveMarker = options.copyCodeAbove; 126 | 127 | const copyBelowIndex = 128 | copyBelowMarker != null ? contentLines.findIndex(findLine(copyBelowMarker, snippetResult?.[1])) : -1; 129 | const copyAboveIndex = 130 | copyAboveMarker != null 131 | ? contentLines.findIndex((line, index) => line.indexOf(copyAboveMarker) === 0 && index > copyBelowIndex) 132 | : -1; 133 | 134 | if (snippetResult != null && copyBelowIndex < 0) { 135 | throw new Error( 136 | `The copyCodeBelow marker '${options.copyCodeBelow}' was not found with the requested snippet: '${snippetResult[1]}'`, 137 | ); 138 | } 139 | 140 | contentLines = contentLines.slice(copyBelowIndex + 1, copyAboveIndex > 0 ? copyAboveIndex : undefined); 141 | 142 | if (codeCommentResult != null) { 143 | contentLines = ['```' + (codeCommentResult[2] ?? ''), ...contentLines, '```']; 144 | } 145 | 146 | return contentLines; 147 | } 148 | 149 | function findLine(copyBelowMarker: string, snippetName?: string): (line: string) => boolean { 150 | return (line: string): boolean => { 151 | return line.indexOf(copyBelowMarker) === 0 && (snippetName == null || line.indexOf(snippetName) > 0); 152 | }; 153 | } 154 | 155 | type FindLineResults = { line: string; lineIndex: number }; 156 | 157 | function findIndex( 158 | lines: string[], 159 | predicate: (line: string) => boolean, 160 | startLine: number, 161 | ): FindLineResults | undefined { 162 | for (let lineIndex = startLine; lineIndex < lines.length; lineIndex++) { 163 | const line = lines[lineIndex]; 164 | if (predicate(line)) { 165 | return { lineIndex, line }; 166 | } 167 | } 168 | 169 | return undefined; 170 | } 171 | -------------------------------------------------------------------------------- /src/helpers/line-ending.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { LineEnding, getEscapeSequence, splitContent, findEscapeSequence } from './line-ending.helper'; 2 | import { EOL } from 'os'; 3 | 4 | describe('line-ending.helper', () => { 5 | const tests: { ending: LineEnding; escape: string }[] = [ 6 | { ending: 'CR', escape: '\r' }, 7 | { ending: 'CRLF', escape: '\r\n' }, 8 | { ending: 'LF', escape: '\n' }, 9 | { ending: 'LFCR', escape: '\n\r' }, 10 | ]; 11 | 12 | let lines: string[]; 13 | 14 | beforeEach(() => { 15 | lines = ['line one', 'line two', 'line three', 'line four', 'line five']; 16 | }); 17 | 18 | describe('getEscapeSequence', () => { 19 | tests.forEach((test) => { 20 | it(`should return correct escape sequence for ${test.ending}`, () => { 21 | expect(getEscapeSequence(test.ending)).toEqual(test.escape); 22 | }); 23 | }); 24 | 25 | it('should throw error for unknown line ending', () => { 26 | expect(() => getEscapeSequence('notEnding' as any)).toThrowError( 27 | `Unknown line ending: 'notEnding'. Line Ending must be one of LF, CRLF, CR, LFCR`, 28 | ); 29 | }); 30 | }); 31 | 32 | describe('findEscapeSequence', () => { 33 | tests.forEach((test) => { 34 | it(`should return correct escape sequence for content using ${test.ending} endings`, () => { 35 | const content = lines.join(test.escape); 36 | expect(findEscapeSequence(content)).toEqual(test.escape); 37 | }); 38 | }); 39 | 40 | it('should return os default sequence when no line endings found', () => { 41 | expect(findEscapeSequence('')).toEqual(EOL); 42 | }); 43 | }); 44 | 45 | describe('splitContent', () => { 46 | tests.forEach((test) => { 47 | it(`should split file with ${test.ending} line endings correctly`, () => { 48 | const content = lines.join(test.escape); 49 | const result = splitContent(content); 50 | 51 | expect(result).toEqual(lines); 52 | }); 53 | }); 54 | 55 | it(`should split file with mixed line endings correctly`, () => { 56 | const content = `line one\nline two\n\rline three\rline four\r\nline five`; 57 | const result = splitContent(content); 58 | 59 | expect(result).toEqual(lines); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/helpers/line-ending.helper.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | 3 | export type LineEnding = 'LF' | 'CRLF' | 'CR' | 'LFCR'; 4 | export const lineEndings: LineEnding[] = ['LF', 'CRLF', 'CR', 'LFCR']; 5 | 6 | const multiCharRegExp = /(\r\n)|(\n\r)/g; 7 | const singleCharRegExp = /(\r)|(\n)/g; 8 | 9 | // https://en.wikipedia.org/wiki/Newline 10 | /** 11 | * returns escape sequence for given line ending 12 | * @param lineEnding 13 | */ 14 | export function getEscapeSequence(lineEnding: LineEnding): string { 15 | switch (lineEnding) { 16 | case 'CR': 17 | return '\r'; 18 | case 'CRLF': 19 | return '\r\n'; 20 | case 'LF': 21 | return '\n'; 22 | case 'LFCR': 23 | return '\n\r'; 24 | default: 25 | return handleNever(lineEnding); 26 | } 27 | } 28 | 29 | function handleNever(lineEnding: never): string { 30 | throw new Error(`Unknown line ending: '${lineEnding}'. Line Ending must be one of ${lineEndings.join(', ')}`); 31 | } 32 | 33 | export function findEscapeSequence(content: string): string { 34 | const multiCharMatch = multiCharRegExp.exec(content); 35 | if (multiCharMatch != null) { 36 | return multiCharMatch[0]; 37 | } 38 | const singleCharMatch = singleCharRegExp.exec(content); 39 | if (singleCharMatch != null) { 40 | return singleCharMatch[0]; 41 | } 42 | return EOL; 43 | } 44 | 45 | /** 46 | * Splits a string into an array of lines. 47 | * Handles any line endings including a file with a mixture of lineEndings 48 | * 49 | * @param content multi line string to be split 50 | */ 51 | export function splitContent(content: string): string[] { 52 | content = content.replace(multiCharRegExp, '\n'); 53 | content = content.replace(singleCharRegExp, '\n'); 54 | return content.split('\n'); 55 | } 56 | 57 | const nonWhitespaceRegExp = /[^ \t]/; 58 | 59 | export function filterDoubleBlankLines(line: string, index: number, lines: string[]): boolean { 60 | const previousLine = index > 0 ? lines[index - 1] : undefined; 61 | 62 | return nonWhitespaceRegExp.test(line) || previousLine == null || nonWhitespaceRegExp.test(previousLine); 63 | } 64 | -------------------------------------------------------------------------------- /src/helpers/markdown.helper.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | import { 3 | usageGuideInfo as exampleConfigGuideInfo, 4 | ICopyFilesArguments, 5 | typicalAppWithGroupsInfo, 6 | exampleSections, 7 | } from '../example/configs'; 8 | import { usageGuideInfo as writeMarkdownGuideInfo } from '../write-markdown.constants'; 9 | import { createUsageGuide } from './markdown.helper'; 10 | import { UsageGuideConfig } from '../contracts'; 11 | 12 | describe('markdown-helper', () => { 13 | it('should generate a simple usage guide with no additional sections and no alias column', () => { 14 | const info: UsageGuideConfig = { 15 | arguments: { ...exampleConfigGuideInfo.arguments, copyFiles: Boolean }, 16 | }; 17 | 18 | const usageGuide = createUsageGuide(info); 19 | 20 | expect(usageGuide).toEqual(` 21 | ## Options 22 | 23 | | Argument | Type | 24 | |-|-| 25 | | **sourcePath** | string | 26 | | **targetPath** | string | 27 | | **copyFiles** | boolean | 28 | | **resetPermissions** | boolean | 29 | | **filter** | string | 30 | | **excludePaths** | string[] | 31 | `); 32 | }); 33 | 34 | it('should generate a simple usage guide with typeLabel modifiers', () => { 35 | const info: UsageGuideConfig = { 36 | arguments: { ...exampleConfigGuideInfo.arguments, copyFiles: Boolean }, 37 | parseOptions: { displayOptionalAndDefault: true }, 38 | }; 39 | 40 | const usageGuide = createUsageGuide(info); 41 | 42 | expect(usageGuide).toEqual(` 43 | ## Options 44 | 45 | | Argument | Type | 46 | |-|-| 47 | | **sourcePath** | string (D) | 48 | | **targetPath** | string | 49 | | **copyFiles** | boolean | 50 | | **resetPermissions** | boolean | 51 | | **filter** | string (O) | 52 | | **excludePaths** | string[] (O) | 53 | `); 54 | }); 55 | 56 | it('should generate a simple usage guide with typeLabel modifiers and footer', () => { 57 | const info: UsageGuideConfig = { 58 | arguments: { ...exampleConfigGuideInfo.arguments, copyFiles: Boolean }, 59 | parseOptions: { addOptionalDefaultExplanatoryFooter: true, displayOptionalAndDefault: true }, 60 | }; 61 | 62 | const usageGuide = createUsageGuide(info); 63 | 64 | expect(usageGuide).toEqual(` 65 | ## Options 66 | 67 | | Argument | Type | 68 | |-|-| 69 | | **sourcePath** | string (D) | 70 | | **targetPath** | string | 71 | | **copyFiles** | boolean | 72 | | **resetPermissions** | boolean | 73 | | **filter** | string (O) | 74 | | **excludePaths** | string[] (O) | 75 | (O) = optional, (D) = default option 76 | `); 77 | }); 78 | 79 | it('should generate a simple usage guide with no additional sections', () => { 80 | const usageGuide = createUsageGuide(exampleConfigGuideInfo); 81 | 82 | expect(usageGuide).toEqual(` 83 | ## Options 84 | 85 | | Argument | Alias | Type | Description | 86 | |-|-|-|-| 87 | | **sourcePath** | | string | | 88 | | **targetPath** | | string | | 89 | | **copyFiles** | **c** | **file[]** | **bold text** *italic text* ***bold italic text*** | 90 | | **resetPermissions** | | boolean | | 91 | | **filter** | | string | | 92 | | **excludePaths** | | string[] | | 93 | `); 94 | }); 95 | 96 | it('should generate a usage guide with sections', () => { 97 | const usageGuide = createUsageGuide(writeMarkdownGuideInfo); 98 | 99 | expect(usageGuide).toEqual(` 100 | ## Markdown Generation 101 | 102 | A markdown version of the usage guide can be generated and inserted into an existing markdown document. 103 | Markers in the document describe where the content should be inserted, existing content betweeen the markers is overwritten. 104 | 105 | 106 | 107 | \`write-markdown -m README.MD -j usageGuideConstants.js\` 108 | 109 | 110 | ### write-markdown cli options 111 | 112 | | Argument | Alias | Type | Description | 113 | |-|-|-|-| 114 | | **markdownPath** | **m** | string | The file to write to. Without replacement markers the whole file content will be replaced. Path can be absolute or relative. | 115 | | **replaceBelow** | | string | A marker in the file to replace text below. | 116 | | **replaceAbove** | | string | A marker in the file to replace text above. | 117 | | **insertCodeBelow** | | string | A marker in the file to insert code below. File path to insert must be added at the end of the line and optionally codeComment flag: 'insertToken file="path/toFile.md" codeComment="ts"' | 118 | | **insertCodeAbove** | | string | A marker in the file to insert code above. | 119 | | **copyCodeBelow** | | string | A marker in the file being inserted to say only copy code below this line | 120 | | **copyCodeAbove** | | string | A marker in the file being inserted to say only copy code above this line | 121 | | **jsFile** | **j** | string[] | jsFile to 'require' that has an export with the 'UsageGuideConfig' export. Multiple files can be specified. | 122 | | **configImportName** | **c** | string[] | Export name of the 'UsageGuideConfig' object. Defaults to 'usageGuideInfo'. Multiple exports can be specified. | 123 | | **verify** | **v** | boolean | Verify the markdown file. Does not update the file but returns a non zero exit code if the markdown file is not correct. Useful for a pre-publish script. | 124 | | **configFile** | **f** | string | Optional config file to load config from. package.json can be used if jsonPath specified as well | 125 | | **jsonPath** | **p** | string | Used in conjunction with 'configFile'. The path within the config file to load the config from. For example: 'configs.writeMarkdown' | 126 | | **verifyMessage** | | string | Optional message that is printed when markdown verification fails. Use '{fileName}' to refer to the file being processed. | 127 | | **removeDoubleBlankLines** | | boolean | When replacing content removes any more than a single blank line | 128 | | **skipFooter** | | boolean | Does not add the 'Markdown Generated by...' footer to the end of the markdown | 129 | | **help** | **h** | boolean | Show this usage guide. | 130 | 131 | 132 | ### Default Replacement Markers 133 | 134 | replaceBelow defaults to: 135 | 136 | \`\`\` 137 | '[//]: ####ts-command-line-args_write-markdown_replaceBelow' 138 | \`\`\` 139 | 140 | replaceAbove defaults to: 141 | 142 | \`\`\` 143 | '[//]: ####ts-command-line-args_write-markdown_replaceAbove' 144 | \`\`\` 145 | 146 | insertCodeBelow defaults to: 147 | 148 | \`\`\` 149 | '[//]: # (ts-command-line-args_write-markdown_insertCodeBelow' 150 | \`\`\` 151 | 152 | insertCodeAbove defaults to: 153 | 154 | \`\`\` 155 | '[//]: # (ts-command-line-args_write-markdown_insertCodeAbove)' 156 | \`\`\` 157 | 158 | copyCodeBelow defaults to: 159 | 160 | \`\`\` 161 | '// ts-command-line-args_write-markdown_copyCodeBelow' 162 | \`\`\` 163 | 164 | copyCodeAbove defaults to: 165 | 166 | \`\`\` 167 | '// ts-command-line-args_write-markdown_copyCodeAbove' 168 | \`\`\` 169 | 170 | `); 171 | }); 172 | 173 | it('should generate a usage guide with option groups', () => { 174 | const usageGuide = createUsageGuide(typicalAppWithGroupsInfo); 175 | 176 | expect(usageGuide).toEqual(` 177 | # A typical app 178 | 179 | Generates something *very* important. 180 | 181 | 182 | ## Main options 183 | 184 | | Argument | Alias | Type | Description | 185 | |-|-|-|-| 186 | | **help** | **h** | boolean | Display this usage guide. | 187 | | **src** | | file ... | Default Option. The input files to process | 188 | | **timeout** | **t** | ms | Defaults to "1000". Timeout value in ms | 189 | 190 | 191 | ## Misc 192 | 193 | | Argument | Type | Description | 194 | |-|-|-| 195 | | **plugin** | string | Optional. A plugin path | 196 | `); 197 | }); 198 | 199 | it('should generate a usage guide with table of examples', () => { 200 | const usageGuide = createUsageGuide(exampleSections); 201 | 202 | expect(usageGuide).toEqual(` 203 | # A typical app 204 | 205 | Generates something *very* important. 206 | 207 | 208 | # both 209 | 210 | 211 | 212 | 213 | # markdown 214 | 215 | 216 | 217 | 218 | # Synopsis 219 | 220 | $ example [**--timeout** ms] **--src** file ... 221 | $ example **--help** 222 | 223 | 224 | ## Options 225 | 226 | | Argument | Alias | Type | Description | 227 | |-|-|-|-| 228 | | **help** | **h** | boolean | Display this usage guide. | 229 | | **src** | | file ... | The input files to process | 230 | | **timeout** | **t** | ms | Timeout value in ms | 231 | | **plugin** | | string | A plugin path | 232 | 233 | 234 | # Examples 235 | 236 | 237 | | Description | Example | 238 | |-|-| 239 | | 1. A concise example. | $ example -t 100 lib/*.js | 240 | | 2. A long example. | $ example --timeout 100 --src lib/*.js | 241 | | 3. This example will scan space for unknown things. Take cure when scanning space, it could take some time. | $ example --src galaxy1.facts galaxy1.facts galaxy2.facts galaxy3.facts galaxy4.facts galaxy5.facts | 242 | 243 | 244 | # both 245 | 246 | 247 | 248 | 249 | # markdown 250 | 251 | 252 | 253 | 254 | 255 | Project home: https://github.com/me/example 256 | `); 257 | }); 258 | 259 | it('should generate a usage guide with json example', () => { 260 | const typicalAppWithJSON: UsageGuideConfig> = { 261 | arguments: {}, 262 | parseOptions: { 263 | headerContentSections: [ 264 | { 265 | header: 'A typical app', 266 | content: `Generates something {italic very} important. 267 | Some Json: 268 | 269 | {code.json 270 | \\{ 271 | "dependencies": \\{ 272 | "someDependency: "0.2.1", 273 | \\}, 274 | "peerDependencies": \\{ 275 | "someDependency: "0.2.1", 276 | \\} 277 | \\} 278 | }`, 279 | }, 280 | ], 281 | }, 282 | }; 283 | const usageGuide = createUsageGuide(typicalAppWithJSON); 284 | 285 | expect(usageGuide).toEqual(` 286 | # A typical app 287 | 288 | Generates something *very* important. 289 | Some Json: 290 | 291 | 292 | \`\`\`json 293 | { 294 | "dependencies": { 295 | "someDependency: "0.2.1", 296 | }, 297 | "peerDependencies": { 298 | "someDependency: "0.2.1", 299 | } 300 | } 301 | 302 | \`\`\` 303 | 304 | `); 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /src/helpers/markdown.helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UsageGuideConfig, 3 | JsImport, 4 | IWriteMarkDown, 5 | ArgumentConfig, 6 | CommandLineOption, 7 | ParseOptions, 8 | Content, 9 | HeaderLevel, 10 | OptionContent, 11 | SectionHeader, 12 | } from '../contracts'; 13 | import { join } from 'path'; 14 | import { normaliseConfig, createCommandLineConfig } from './command-line.helper.js'; 15 | import { generateTableFooter, getOptionSections, mapDefinitionDetails } from './options.helper.js'; 16 | import { convertChalkStringToMarkdown } from './string.helper.js'; 17 | import { pathToFileURL } from 'url'; 18 | 19 | export function createUsageGuide(config: UsageGuideConfig): string { 20 | const options = config.parseOptions || {}; 21 | const headerSections = options.headerContentSections || []; 22 | const footerSections = options.footerContentSections || []; 23 | 24 | return [ 25 | ...headerSections.filter(filterMarkdownSections).map((section) => createSection(section, config)), 26 | ...createOptionsSections(config.arguments, options), 27 | ...footerSections.filter(filterMarkdownSections).map((section) => createSection(section, config)), 28 | ].join('\n'); 29 | } 30 | 31 | function filterMarkdownSections(section: Content): boolean { 32 | return section.includeIn == null || section.includeIn === 'both' || section.includeIn === 'markdown'; 33 | } 34 | 35 | export function createSection(section: Content, config: UsageGuideConfig): string { 36 | return ` 37 | ${createHeading(section, config.parseOptions?.defaultSectionHeaderLevel || 1)} 38 | ${createSectionContent(section)} 39 | `; 40 | } 41 | 42 | export function createSectionContent(section: Content): string { 43 | if (typeof section.content === 'string') { 44 | return convertChalkStringToMarkdown(section.content); 45 | } 46 | 47 | if (Array.isArray(section.content)) { 48 | if (section.content.every((content) => typeof content === 'string')) { 49 | return (section.content as string[]).map(convertChalkStringToMarkdown).join('\n'); 50 | } else if (section.content.every((content) => typeof content === 'object')) { 51 | return createSectionTable(section.content); 52 | } 53 | } 54 | 55 | return ''; 56 | } 57 | 58 | export function createSectionTable(rows: any[]): string { 59 | if (rows.length === 0) { 60 | return ``; 61 | } 62 | const cellKeys = Object.keys(rows[0]); 63 | 64 | return ` 65 | |${cellKeys.map((key) => ` ${key} `).join('|')}| 66 | |${cellKeys.map(() => '-').join('|')}| 67 | ${rows.map((row) => `| ${cellKeys.map((key) => convertChalkStringToMarkdown(row[key])).join(' | ')} |`).join('\n')}`; 68 | } 69 | 70 | export function createOptionsSections(cliArguments: ArgumentConfig, options: ParseOptions): string[] { 71 | const normalisedConfig = normaliseConfig(cliArguments); 72 | const optionList = createCommandLineConfig(normalisedConfig); 73 | 74 | if (optionList.length === 0) { 75 | return []; 76 | } 77 | 78 | return getOptionSections(options).map((section) => createOptionsSection(optionList, section, options)); 79 | } 80 | 81 | export function createOptionsSection( 82 | optionList: CommandLineOption[], 83 | content: OptionContent, 84 | options: ParseOptions, 85 | ): string { 86 | optionList = optionList.filter((option) => filterOptions(option, content.group)); 87 | const anyAlias = optionList.some((option) => option.alias != null); 88 | const anyDescription = optionList.some((option) => option.description != null); 89 | 90 | const footer = generateTableFooter(optionList, options); 91 | 92 | return ` 93 | ${createHeading(content, 2)} 94 | | Argument |${anyAlias ? ' Alias |' : ''} Type |${anyDescription ? ' Description |' : ''} 95 | |-|${anyAlias ? '-|' : ''}-|${anyDescription ? '-|' : ''} 96 | ${optionList 97 | .map((option) => mapDefinitionDetails(option, options)) 98 | .map((option) => createOptionRow(option, anyAlias, anyDescription)) 99 | .join('\n')} 100 | ${footer != null ? footer + '\n' : ''}`; 101 | } 102 | 103 | function filterOptions(option: CommandLineOption, groups?: string | string[]): boolean { 104 | return ( 105 | groups == null || 106 | (typeof groups === 'string' && (groups === option.group || (groups === '_none' && option.group == null))) || 107 | (Array.isArray(groups) && 108 | (groups.some((group) => group === option.group) || 109 | (groups.some((group) => group === '_none') && option.group == null))) 110 | ); 111 | } 112 | 113 | export function createHeading(section: SectionHeader, defaultLevel: HeaderLevel): string { 114 | if (section.header == null) { 115 | return ''; 116 | } 117 | 118 | const headingLevel = Array.from({ length: section.headerLevel || defaultLevel }) 119 | .map(() => `#`) 120 | .join(''); 121 | 122 | return `${headingLevel} ${section.header} 123 | `; 124 | } 125 | 126 | export function createOptionRow(option: CommandLineOption, includeAlias = true, includeDescription = true): string { 127 | const alias = includeAlias ? ` ${option.alias == null ? '' : '**' + option.alias + '** '}|` : ``; 128 | const description = includeDescription 129 | ? ` ${option.description == null ? '' : convertChalkStringToMarkdown(option.description) + ' '}|` 130 | : ``; 131 | return `| **${option.name}** |${alias} ${getType(option)}|${description}`; 132 | } 133 | 134 | export function getType(option: CommandLineOption): string { 135 | if (option.typeLabel) { 136 | return `${convertChalkStringToMarkdown(option.typeLabel)} `; 137 | } 138 | 139 | //TODO: add modifiers 140 | 141 | const type = option.type ? option.type.name.toLowerCase() : 'string'; 142 | const multiple = option.multiple || option.lazyMultiple ? '[]' : ''; 143 | 144 | return `${type}${multiple} `; 145 | } 146 | 147 | export async function generateUsageGuides(args: IWriteMarkDown): Promise { 148 | if (args.jsFile == null) { 149 | console.log( 150 | `No jsFile defined for usage guide generation. See 'write-markdown -h' for details on generating usage guides.`, 151 | ); 152 | return undefined; 153 | } 154 | 155 | function mapJsImports(imports: JsImport[], jsFile: string) { 156 | return [...imports, ...args.configImportName.map((importName) => ({ jsFile, importName }))]; 157 | } 158 | 159 | const importPromises = args.jsFile 160 | .reduce(mapJsImports, new Array()) 161 | .map(({ jsFile, importName }) => loadArgConfig(jsFile, importName)); 162 | 163 | const resolvedImports = await Promise.all(importPromises); 164 | 165 | return resolvedImports.filter(isDefined).map(createUsageGuide); 166 | } 167 | 168 | export async function loadArgConfig(jsFile: string, importName: string): Promise { 169 | const jsPath = join(process.cwd(), jsFile); 170 | // eslint-disable-next-line @typescript-eslint/no-var-requires 171 | const jsExports = await import(pathToFileURL(jsPath).href); 172 | 173 | const argConfig: UsageGuideConfig = jsExports[importName]; 174 | 175 | if (argConfig == null) { 176 | console.warn(`Could not import ArgumentConfig named '${importName}' from jsFile '${jsFile}'`); 177 | return undefined; 178 | } 179 | 180 | return argConfig; 181 | } 182 | 183 | function isDefined(value: T | undefined | null): value is T { 184 | return value != null; 185 | } 186 | -------------------------------------------------------------------------------- /src/helpers/options.helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ParseOptions, 3 | OptionContent, 4 | CommandLineOption, 5 | OptionalProperty, 6 | PropertyOptions, 7 | Content, 8 | OptionList, 9 | } from '../contracts'; 10 | 11 | export function getOptionSections(options: ParseOptions): OptionContent[] { 12 | return ( 13 | options.optionSections || [ 14 | { header: options.optionsHeaderText || 'Options', headerLevel: options.optionsHeaderLevel || 2 }, 15 | ] 16 | ); 17 | } 18 | 19 | export function getOptionFooterSection(optionList: CommandLineOption[], options: ParseOptions): Content[] { 20 | const optionsFooter = generateTableFooter(optionList, options); 21 | 22 | if (optionsFooter != null) { 23 | console.log(`Adding footer: ${optionsFooter}`); 24 | return [{ content: optionsFooter }]; 25 | } 26 | 27 | return []; 28 | } 29 | 30 | export function generateTableFooter( 31 | optionList: CommandLineOption[], 32 | options: ParseOptions, 33 | ): string | undefined { 34 | if (options.addOptionalDefaultExplanatoryFooter != true || options.displayOptionalAndDefault != true) { 35 | return undefined; 36 | } 37 | 38 | const optionalProps = optionList.some((option) => (option as unknown as OptionalProperty).optional === true); 39 | const defaultProps = optionList.some((option) => option.defaultOption === true); 40 | 41 | if (optionalProps || defaultProps) { 42 | const footerValues = [ 43 | optionalProps != null ? '(O) = optional' : undefined, 44 | defaultProps != null ? '(D) = default option' : null, 45 | ]; 46 | return footerValues.filter((v) => v != null).join(', '); 47 | } 48 | 49 | return undefined; 50 | } 51 | 52 | export function addOptions( 53 | content: OptionContent, 54 | optionList: CommandLineOption[], 55 | options: ParseOptions, 56 | ): OptionList { 57 | optionList = optionList.map((option) => mapDefinitionDetails(option, options)); 58 | 59 | return { ...content, optionList }; 60 | } 61 | 62 | /** 63 | * adds default or optional modifiers to type label or description 64 | * @param option 65 | */ 66 | export function mapDefinitionDetails( 67 | definition: CommandLineOption, 68 | options: ParseOptions, 69 | ): CommandLineOption { 70 | definition = mapOptionTypeLabel(definition, options); 71 | definition = mapOptionDescription(definition, options); 72 | 73 | return definition; 74 | } 75 | 76 | function mapOptionDescription(definition: CommandLineOption, options: ParseOptions): CommandLineOption { 77 | if (options.prependParamOptionsToDescription !== true || isBoolean(definition)) { 78 | return definition; 79 | } 80 | 81 | definition.description = definition.description || ''; 82 | 83 | if (definition.defaultOption) { 84 | definition.description = `Default Option. ${definition.description}`; 85 | } 86 | 87 | if ((definition as unknown as OptionalProperty).optional === true) { 88 | definition.description = `Optional. ${definition.description}`; 89 | } 90 | 91 | if (definition.defaultValue != null) { 92 | definition.description = `Defaults to ${JSON.stringify(definition.defaultValue)}. ${definition.description}`; 93 | } 94 | 95 | return definition; 96 | } 97 | 98 | function mapOptionTypeLabel(definition: CommandLineOption, options: ParseOptions): CommandLineOption { 99 | if (options.displayOptionalAndDefault !== true || isBoolean(definition)) { 100 | return definition; 101 | } 102 | 103 | definition.typeLabel = definition.typeLabel || getTypeLabel(definition); 104 | 105 | if (definition.defaultOption) { 106 | definition.typeLabel = `${definition.typeLabel} (D)`; 107 | } 108 | 109 | if ((definition as unknown as OptionalProperty).optional === true) { 110 | definition.typeLabel = `${definition.typeLabel} (O)`; 111 | } 112 | 113 | return definition; 114 | } 115 | 116 | function getTypeLabel(definition: CommandLineOption) { 117 | let typeLabel = definition.type ? definition.type.name.toLowerCase() : 'string'; 118 | const multiple = definition.multiple || definition.lazyMultiple ? '[]' : ''; 119 | if (typeLabel) { 120 | typeLabel = typeLabel === 'boolean' ? '' : `{underline ${typeLabel}${multiple}}`; 121 | } 122 | 123 | return typeLabel; 124 | } 125 | 126 | export function isBoolean(option: PropertyOptions): boolean { 127 | return option.type.name === 'Boolean'; 128 | } 129 | -------------------------------------------------------------------------------- /src/helpers/string.helper.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | import { convertChalkStringToMarkdown, removeAdditionalFormatting } from './string.helper'; 3 | 4 | describe('string.helper', () => { 5 | describe('convertChalkStringToMarkdown', () => { 6 | it('should remove unsupported chalk formatting', () => { 7 | expect(convertChalkStringToMarkdown(`some {underline modified underlined} text`)).toEqual( 8 | `some modified underlined text`, 9 | ); 10 | }); 11 | 12 | it('should replace bold formatting', () => { 13 | expect(convertChalkStringToMarkdown(`some {bold modified bold} text`)).toEqual( 14 | `some **modified bold** text`, 15 | ); 16 | }); 17 | 18 | it('should replace italic formatting', () => { 19 | expect(convertChalkStringToMarkdown(`some {italic modified italic} text`)).toEqual( 20 | `some *modified italic* text`, 21 | ); 22 | }); 23 | 24 | it('should replace bold italic formatting', () => { 25 | expect(convertChalkStringToMarkdown(`some {bold.italic modified bold italic} text`)).toEqual( 26 | `some ***modified bold italic*** text`, 27 | ); 28 | }); 29 | 30 | it('should replace italic bold formatting', () => { 31 | expect(convertChalkStringToMarkdown(`some {italic.bold modified italic bold} text`)).toEqual( 32 | `some ***modified italic bold*** text`, 33 | ); 34 | }); 35 | 36 | it('should replace highlight formatting', () => { 37 | expect(convertChalkStringToMarkdown(`some {highlight modified highlighted} text`)).toEqual( 38 | `some \`modified highlighted\` text`, 39 | ); 40 | }); 41 | 42 | it('should replace code formatting', () => { 43 | expect(convertChalkStringToMarkdown(`some {code modified code} text`)).toEqual(`some 44 | \`\`\` 45 | modified code 46 | \`\`\` 47 | text`); 48 | }); 49 | 50 | it('should replace code formatting with language', () => { 51 | expect(convertChalkStringToMarkdown(`some {code.typescript modified code} text`)).toEqual(`some 52 | \`\`\`typescript 53 | modified code 54 | \`\`\` 55 | text`); 56 | }); 57 | 58 | it('should add 2 blank spaces to new lines', () => { 59 | expect( 60 | convertChalkStringToMarkdown(`some text 61 | over 2 lines`), 62 | ).toEqual( 63 | `some text 64 | over 2 lines`, 65 | ); 66 | }); 67 | }); 68 | 69 | describe('removeAdditionalFormatting', () => { 70 | it('should leave existing chalk formatting', () => { 71 | expect(removeAdditionalFormatting(`some {underline modified underlined} text`)).toEqual( 72 | `some {underline modified underlined} text`, 73 | ); 74 | }); 75 | 76 | it('should replace highlight modifier', () => { 77 | expect(removeAdditionalFormatting(`some {highlight modified highlighted} and {bold bold} text`)).toEqual( 78 | `some modified highlighted and {bold bold} text`, 79 | ); 80 | }); 81 | 82 | it('should replace code modifier with curly braces', () => { 83 | expect(removeAdditionalFormatting(`some {code function()\{doSomething();\}} text`)).toEqual( 84 | `some function()\{doSomething();\} text`, 85 | ); 86 | }); 87 | 88 | it('should replace code modifier with curly braces and new lines', () => { 89 | expect( 90 | removeAdditionalFormatting( 91 | `some {code function logMessage(message: string) \\{console.log(message);\\}} text`, 92 | ), 93 | ).toEqual(`some function logMessage(message: string) \\{console.log(message);\\} text`); 94 | }); 95 | 96 | it('should replace code modifier with language with curly braces and new lines', () => { 97 | expect( 98 | removeAdditionalFormatting( 99 | `some {code.typescript function logMessage(message: string) \\{console.log(message);\\}} text`, 100 | ), 101 | ).toEqual(`some function logMessage(message: string) \\{console.log(message);\\} text`); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/helpers/string.helper.ts: -------------------------------------------------------------------------------- 1 | const chalkStringStyleRegExp = /(?= 0) { 26 | modifier = '`'; 27 | } else if (matches[0].indexOf(codeModifier) >= 0) { 28 | const codeOptions = matches[0].split('.'); 29 | modifier = '\n```'; 30 | if (codeOptions[1] != null) { 31 | return `${modifier}${codeOptions[1]}\n${matches[1]}${modifier}\n`; 32 | } else { 33 | return `${modifier}\n${matches[1]}${modifier}\n`; 34 | } 35 | } else { 36 | if (matches[0].indexOf('bold') >= 0) { 37 | modifier += '**'; 38 | } 39 | if (matches[0].indexOf('italic') >= 0) { 40 | modifier += '*'; 41 | } 42 | } 43 | return `${modifier}${matches[1]}${modifier}`; 44 | } 45 | 46 | /** 47 | * Removes formatting not supported by chalk 48 | * 49 | * @param input 50 | */ 51 | export function removeAdditionalFormatting(input: string): string { 52 | return input.replace(chalkStringStyleRegExp, removeNonChalkFormatting); 53 | } 54 | 55 | function removeNonChalkFormatting(substring: string, ...matches: string[]): string { 56 | const nonChalkFormats = [highlightModifier, codeModifier]; 57 | 58 | if (nonChalkFormats.some((format) => matches[0].indexOf(format) >= 0)) { 59 | return matches[1]; 60 | } 61 | 62 | return substring; 63 | } 64 | -------------------------------------------------------------------------------- /src/helpers/visitor.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | 3 | type Callback = (value: T, key: string, parent: any) => T; 4 | 5 | /** 6 | * visits all values in a complex object. 7 | * allows us to perform trasformations on values 8 | */ 9 | export function visit(value: T, callback: Callback): T { 10 | if (Array.isArray(value)) { 11 | value.forEach((_, index) => visitKey(index, value, callback)); 12 | } else { 13 | Object.keys(value).forEach((key) => visitKey(key as keyof T, value, callback)); 14 | } 15 | 16 | return value; 17 | } 18 | 19 | function visitKey(key: K, parent: T, callback: Callback) { 20 | const keyValue = parent[key]; 21 | 22 | parent[key] = callback(keyValue, key as string, parent); 23 | 24 | if (typeof keyValue === 'object') { 25 | visit(keyValue as any, callback); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parse'; 2 | export * from './contracts'; 3 | export * from './helpers'; 4 | -------------------------------------------------------------------------------- /src/parse.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { ArgumentConfig, ProcessExitCodeFunction } from './contracts'; 3 | import { 4 | IMocked, 5 | Mock, 6 | setupFunction, 7 | replacePropertiesBeforeEach, 8 | addMatchers, 9 | registerMock, 10 | reset, 11 | any, 12 | } from '@morgan-stanley/ts-mocking-bird'; 13 | import { parse } from './parse'; 14 | import * as fsImport from 'fs'; 15 | import * as pathImport from 'path'; 16 | import * as helpersImport from './helpers'; 17 | 18 | jest.mock('fs', () => require('@morgan-stanley/ts-mocking-bird').proxyJestModule(require.resolve('fs'))); 19 | jest.mock('path', () => require('@morgan-stanley/ts-mocking-bird').proxyJestModule(require.resolve('path'))); 20 | jest.mock('./helpers', () => require('@morgan-stanley/ts-mocking-bird').proxyJestModule(require.resolve('./helpers'))); 21 | 22 | describe('parse', () => { 23 | let mockConsole: IMocked; 24 | let mockProcess: IMocked; 25 | let mockFs: IMocked; 26 | let mockPath: IMocked; 27 | let mockHelper: IMocked; 28 | 29 | interface ComplexProperties { 30 | requiredString: string; 31 | defaultedString: string; 32 | optionalString?: string; 33 | requiredBoolean: boolean; 34 | optionalBoolean?: boolean; 35 | requiredArray: string[]; 36 | optionalArray?: string[]; 37 | } 38 | 39 | interface PropertiesWithFileConfig extends ComplexProperties { 40 | optionalFileArg?: string; 41 | optionalPathArg?: string; 42 | } 43 | 44 | interface PropertiesWithHelp extends ComplexProperties { 45 | optionalHelpArg?: boolean; 46 | } 47 | 48 | function getConfig(): ArgumentConfig { 49 | return { 50 | requiredString: String, 51 | defaultedString: { type: String, defaultValue: defaultFromOption }, 52 | optionalString: { type: String, optional: true }, 53 | requiredBoolean: { type: Boolean, alias: 'b' }, 54 | optionalBoolean: { type: Boolean, optional: true }, 55 | requiredArray: { type: String, alias: 'o', multiple: true }, 56 | optionalArray: { type: String, lazyMultiple: true, optional: true }, 57 | }; 58 | } 59 | 60 | function getHelpConfig(): ArgumentConfig { 61 | return { 62 | ...getConfig(), 63 | optionalHelpArg: { type: Boolean, optional: true, alias: 'h', description: 'This help guide' }, 64 | }; 65 | } 66 | 67 | interface IOptionalArgs { 68 | path?: string; 69 | optionalHelpArg?: boolean; 70 | } 71 | 72 | function getAllOptionalHelpConfig(): ArgumentConfig { 73 | return { 74 | path: { type: String, optional: true }, 75 | optionalHelpArg: { type: Boolean, optional: true, alias: 'h', description: 'This help guide' }, 76 | }; 77 | } 78 | 79 | function getFileConfig(): ArgumentConfig { 80 | return { 81 | ...getConfig(), 82 | optionalFileArg: { type: String, optional: true }, 83 | optionalPathArg: { type: String, optional: true }, 84 | }; 85 | } 86 | 87 | const requiredStringValue = 'requiredStringValue'; 88 | const requiredString = ['--requiredString', requiredStringValue]; 89 | const defaultedStringValue = 'defaultedStringValue'; 90 | const defaultFromOption = 'defaultFromOption'; 91 | const defaultedString = ['--defaultedString', defaultedStringValue]; 92 | const optionalStringValue = 'optionalStringValue'; 93 | const optionalString = ['--optionalString', optionalStringValue]; 94 | const requiredBoolean = ['--requiredBoolean']; 95 | const optionalBoolean = ['--optionalBoolean']; 96 | const requiredArrayValue = ['requiredArray']; 97 | const requiredArray = ['--requiredArray', ...requiredArrayValue]; 98 | const optionalArrayValue = ['optionalArrayValueOne', 'optionalArrayValueTwo']; 99 | const optionalArray = ['--optionalArray', optionalArrayValue[0], '--optionalArray', optionalArrayValue[1]]; 100 | const optionalHelpArg = ['--optionalHelpArg']; 101 | const optionalFileArg = ['--optionalFileArg=configFilePath']; 102 | const optionalPathArg = ['--optionalPathArg=configPath']; 103 | const unknownStringValue = 'unknownStringValue'; 104 | const unknownString = ['--unknownOption', unknownStringValue]; 105 | 106 | let jsonFromFile: Record; 107 | 108 | replacePropertiesBeforeEach(() => { 109 | jsonFromFile = { 110 | requiredString: 'requiredStringFromFile', 111 | defaultedString: 'defaultedStringFromFile', 112 | }; 113 | const configFromFile = Mock.create().setup( 114 | setupFunction('toString', () => JSON.stringify(jsonFromFile)), 115 | ).mock; 116 | addMatchers(); 117 | mockConsole = Mock.create().setup(setupFunction('error'), setupFunction('log')); 118 | mockProcess = Mock.create().setup(setupFunction('exit')); 119 | mockFs = Mock.create().setup(setupFunction('readFileSync', () => configFromFile as any)); 120 | mockPath = Mock.create().setup(setupFunction('resolve', (path) => `${path}_resolved`)); 121 | mockHelper = Mock.create().setup(setupFunction('mergeConfig')); 122 | 123 | registerMock(fsImport, mockFs.mock); 124 | registerMock(pathImport, mockPath.mock); 125 | registerMock(helpersImport, mockHelper.mock); 126 | 127 | return [{ package: process, mocks: mockProcess.mock }]; 128 | }); 129 | 130 | afterEach(() => { 131 | reset(fsImport); 132 | reset(pathImport); 133 | reset(helpersImport); 134 | }); 135 | 136 | describe('should create the expected argument value object', () => { 137 | it('when all options are populated', () => { 138 | const result = parse(getConfig(), { 139 | logger: mockConsole.mock, 140 | argv: [ 141 | ...requiredString, 142 | ...defaultedString, 143 | ...optionalString, 144 | ...requiredBoolean, 145 | ...optionalBoolean, 146 | ...requiredArray, 147 | ...optionalArray, 148 | ], 149 | }); 150 | 151 | expect(result).toEqual({ 152 | requiredString: requiredStringValue, 153 | defaultedString: defaultedStringValue, 154 | optionalString: optionalStringValue, 155 | requiredArray: requiredArrayValue, 156 | optionalArray: optionalArrayValue, 157 | requiredBoolean: true, 158 | optionalBoolean: true, 159 | }); 160 | }); 161 | 162 | it('when optional values are ommitted', () => { 163 | const result = parse(getHelpConfig(), { 164 | logger: mockConsole.mock, 165 | argv: [...requiredString, ...requiredArray], 166 | helpArg: 'optionalHelpArg', 167 | }); 168 | 169 | expect(result).toEqual({ 170 | requiredString: requiredStringValue, 171 | defaultedString: defaultFromOption, 172 | requiredArray: requiredArrayValue, 173 | requiredBoolean: false, 174 | }); 175 | 176 | expect(mockConsole.withFunction('log')).wasNotCalled(); 177 | expect(mockConsole.withFunction('error')).wasNotCalled(); 178 | }); 179 | 180 | it('should not load config from file when not specified', () => { 181 | const result = parse(getFileConfig(), { 182 | logger: mockConsole.mock, 183 | argv: [...requiredString, ...requiredArray], 184 | loadFromFileArg: 'optionalFileArg', 185 | }); 186 | 187 | expect(result).toEqual({ 188 | requiredString: requiredStringValue, 189 | defaultedString: defaultFromOption, 190 | requiredArray: requiredArrayValue, 191 | requiredBoolean: false, 192 | }); 193 | 194 | expect(mockPath.withFunction('resolve')).wasNotCalled(); 195 | expect(mockFs.withFunction('readFileSync')).wasNotCalled(); 196 | }); 197 | 198 | it('should load config from file when specified', () => { 199 | const mergedConfig = { 200 | requiredString: 'requiredStringFromFile', 201 | defaultedString: 'defaultedStringFromFile', 202 | requiredArray: requiredArrayValue, 203 | requiredBoolean: false, 204 | }; 205 | mockHelper.setupFunction('mergeConfig', () => mergedConfig as any); 206 | 207 | const argv = [...requiredString, ...requiredArray, ...optionalFileArg, ...optionalPathArg]; 208 | 209 | const result = parse(getFileConfig(), { 210 | logger: mockConsole.mock, 211 | argv, 212 | loadFromFileArg: 'optionalFileArg', 213 | loadFromFileJsonPathArg: 'optionalPathArg', 214 | }); 215 | 216 | expect(result).toEqual(mergedConfig); 217 | 218 | const expectedParsedArgs = { 219 | defaultedString: 'defaultFromOption', 220 | requiredString: 'requiredStringValue', 221 | requiredArray: ['requiredArray'], 222 | optionalFileArg: 'configFilePath', 223 | optionalPathArg: 'configPath', 224 | }; 225 | 226 | const expectedParsedArgsWithoutDefaults = { 227 | requiredString: 'requiredStringValue', 228 | requiredArray: ['requiredArray'], 229 | optionalFileArg: 'configFilePath', 230 | optionalPathArg: 'configPath', 231 | }; 232 | 233 | expect(mockPath.withFunction('resolve').withParameters('configFilePath')).wasCalledOnce(); 234 | expect(mockFs.withFunction('readFileSync').withParameters('configFilePath_resolved')).wasCalledOnce(); 235 | expect( 236 | mockHelper 237 | .withFunction('mergeConfig') 238 | .withParametersEqualTo( 239 | expectedParsedArgs, 240 | expectedParsedArgsWithoutDefaults, 241 | jsonFromFile, 242 | any(), 243 | 'optionalPathArg' as any, 244 | ), 245 | ).wasCalledOnce(); 246 | }); 247 | 248 | type OveriddeBooleanTest = { 249 | args: string[]; 250 | configFromFile: Partial; 251 | expected: Partial; 252 | }; 253 | 254 | const overrideBooleanTests: OveriddeBooleanTest[] = [ 255 | { 256 | args: ['--requiredBoolean'], 257 | configFromFile: { requiredBoolean: false }, 258 | expected: { requiredBoolean: true }, 259 | }, 260 | { 261 | args: ['--requiredBoolean', '--optionalPathArg=optionalPath'], 262 | configFromFile: { requiredBoolean: false }, 263 | expected: { requiredBoolean: true, optionalPathArg: 'optionalPath' }, 264 | }, 265 | { 266 | args: ['--requiredBoolean=false'], 267 | configFromFile: { requiredBoolean: true }, 268 | expected: { requiredBoolean: false }, 269 | }, 270 | { 271 | args: ['--requiredBoolean=true'], 272 | configFromFile: { requiredBoolean: false }, 273 | expected: { requiredBoolean: true }, 274 | }, 275 | { 276 | args: ['--requiredBoolean', 'false'], 277 | configFromFile: { requiredBoolean: true }, 278 | expected: { requiredBoolean: false }, 279 | }, 280 | { 281 | args: ['--requiredBoolean', 'true'], 282 | configFromFile: { requiredBoolean: false }, 283 | expected: { requiredBoolean: true }, 284 | }, 285 | { args: ['-b'], configFromFile: { requiredBoolean: false }, expected: { requiredBoolean: true } }, 286 | { args: ['-b=false'], configFromFile: { requiredBoolean: true }, expected: { requiredBoolean: false } }, 287 | { args: ['-b=true'], configFromFile: { requiredBoolean: false }, expected: { requiredBoolean: true } }, 288 | { args: ['-b', 'false'], configFromFile: { requiredBoolean: true }, expected: { requiredBoolean: false } }, 289 | { args: ['-b', 'true'], configFromFile: { requiredBoolean: false }, expected: { requiredBoolean: true } }, 290 | ]; 291 | 292 | overrideBooleanTests.forEach((test) => { 293 | it(`should correctly override boolean value in config file when ${test.args.join()} passed on command line`, () => { 294 | jsonFromFile = test.configFromFile; 295 | 296 | const mergedConfig = { 297 | requiredString: 'requiredStringFromFile', 298 | defaultedString: 'defaultedStringFromFile', 299 | requiredArray: requiredArrayValue, 300 | ...test.expected, 301 | }; 302 | mockHelper.setupFunction('mergeConfig', () => mergedConfig as any); 303 | 304 | const argv = [...optionalFileArg, ...test.args]; 305 | 306 | parse(getFileConfig(), { 307 | logger: mockConsole.mock, 308 | argv, 309 | loadFromFileArg: 'optionalFileArg', 310 | loadFromFileJsonPathArg: 'optionalPathArg', 311 | }); 312 | 313 | const expectedParsedArgs = { 314 | defaultedString: 'defaultFromOption', 315 | optionalFileArg: 'configFilePath', 316 | ...test.expected, 317 | }; 318 | 319 | const expectedParsedArgsWithoutDefaults = { 320 | optionalFileArg: 'configFilePath', 321 | ...test.expected, 322 | }; 323 | 324 | expect( 325 | mockHelper 326 | .withFunction('mergeConfig') 327 | .withParametersEqualTo( 328 | expectedParsedArgs, 329 | expectedParsedArgsWithoutDefaults, 330 | jsonFromFile, 331 | any(), 332 | 'optionalPathArg' as any, 333 | ), 334 | ).wasCalledOnce(); 335 | }); 336 | }); 337 | }); 338 | 339 | it(`should print errors and exit process when required arguments are missing and no baseCommand or help arg are passed`, () => { 340 | const result = parse(getConfig(), { 341 | logger: mockConsole.mock, 342 | argv: [...defaultedString], 343 | }); 344 | 345 | expect( 346 | mockConsole 347 | .withFunction('error') 348 | .withParameters( 349 | `Required parameter 'requiredString' was not passed. Please provide a value by passing '--requiredString=passedValue' in command line arguments`, 350 | ), 351 | ).wasCalledOnce(); 352 | expect( 353 | mockConsole 354 | .withFunction('error') 355 | .withParameters( 356 | `Required parameter 'requiredArray' was not passed. Please provide a value by passing '--requiredArray=passedValue' or '-o passedValue' in command line arguments`, 357 | ), 358 | ).wasCalledOnce(); 359 | expect(mockConsole.withFunction('log')).wasNotCalled(); 360 | 361 | expect(mockProcess.withFunction('exit')).wasCalledOnce(); 362 | 363 | expect(result).toBeUndefined(); 364 | }); 365 | 366 | it(`should print errors and exit process when required arguments are missing and baseCommand is present`, () => { 367 | const result = parse(getConfig(), { 368 | logger: mockConsole.mock, 369 | argv: [...defaultedString], 370 | baseCommand: 'runMyScript', 371 | }); 372 | 373 | expect( 374 | mockConsole 375 | .withFunction('error') 376 | .withParameters( 377 | `Required parameter 'requiredString' was not passed. Please provide a value by running 'runMyScript --requiredString=passedValue'`, 378 | ), 379 | ).wasCalledOnce(); 380 | expect( 381 | mockConsole 382 | .withFunction('error') 383 | .withParameters( 384 | `Required parameter 'requiredArray' was not passed. Please provide a value by running 'runMyScript --requiredArray=passedValue' or 'runMyScript -o passedValue'`, 385 | ), 386 | ).wasCalledOnce(); 387 | expect(mockConsole.withFunction('log')).wasNotCalled(); 388 | 389 | expect(mockProcess.withFunction('exit')).wasCalledOnce(); 390 | 391 | expect(result).toBeUndefined(); 392 | }); 393 | 394 | it(`should print errors and exit process when required arguments are missing and help arg is present`, () => { 395 | const mockExitFunction = Mock.create<{ processExitCode: ProcessExitCodeFunction }>().setup( 396 | setupFunction('processExitCode', () => 5), 397 | ); 398 | const result = parse(getHelpConfig(), { 399 | logger: mockConsole.mock, 400 | argv: [...defaultedString], 401 | helpArg: 'optionalHelpArg', 402 | processExitCode: mockExitFunction.mock.processExitCode, 403 | }); 404 | 405 | expect( 406 | mockConsole 407 | .withFunction('error') 408 | .withParameters( 409 | `Required parameter 'requiredString' was not passed. Please provide a value by passing '--requiredString=passedValue' in command line arguments`, 410 | ), 411 | ).wasCalledOnce(); 412 | expect( 413 | mockConsole 414 | .withFunction('error') 415 | .withParameters( 416 | `Required parameter 'requiredArray' was not passed. Please provide a value by passing '--requiredArray=passedValue' or '-o passedValue' in command line arguments`, 417 | ), 418 | ).wasCalledOnce(); 419 | expect(mockConsole.withFunction('log').withParameters(`To view the help guide pass '-h'`)).wasCalledOnce(); 420 | expect( 421 | mockExitFunction 422 | .withFunction('processExitCode') 423 | .withParametersEqualTo( 424 | 'missingArgs', 425 | { defaultedString: 'defaultedStringValue', requiredBoolean: false }, 426 | [ 427 | { name: 'requiredString', type: String }, 428 | { name: 'requiredArray', type: String, alias: 'o', multiple: true }, 429 | ] as any, 430 | ), 431 | ).wasCalledOnce(); 432 | expect(mockProcess.withFunction('exit').withParameters(5)).wasCalledOnce(); 433 | 434 | expect(result).toBeUndefined(); 435 | }); 436 | 437 | it(`should print errors and exit process when required arguments are missing and help arg and baseCommand are present`, () => { 438 | const result = parse(getHelpConfig(), { 439 | logger: mockConsole.mock, 440 | argv: [...defaultedString], 441 | baseCommand: 'runMyScript', 442 | helpArg: 'optionalHelpArg', 443 | }); 444 | 445 | expect( 446 | mockConsole 447 | .withFunction('error') 448 | .withParameters( 449 | `Required parameter 'requiredString' was not passed. Please provide a value by running 'runMyScript --requiredString=passedValue'`, 450 | ), 451 | ).wasCalledOnce(); 452 | expect( 453 | mockConsole 454 | .withFunction('error') 455 | .withParameters( 456 | `Required parameter 'requiredArray' was not passed. Please provide a value by running 'runMyScript --requiredArray=passedValue' or 'runMyScript -o passedValue'`, 457 | ), 458 | ).wasCalledOnce(); 459 | expect( 460 | mockConsole.withFunction('log').withParameters(`To view the help guide run 'runMyScript -h'`), 461 | ).wasCalledOnce(); 462 | 463 | expect(mockProcess.withFunction('exit').withParameters()).wasCalledOnce(); 464 | 465 | expect(result).toBeUndefined(); 466 | }); 467 | 468 | it(`should print warnings, return an incomplete result when arguments are missing and exitProcess is false`, () => { 469 | const result = parse( 470 | getConfig(), 471 | { 472 | logger: mockConsole.mock, 473 | argv: [...defaultedString], 474 | }, 475 | false, 476 | ); 477 | expect( 478 | mockConsole 479 | .withFunction('error') 480 | .withParameters( 481 | `Required parameter 'requiredString' was not passed. Please provide a value by passing '--requiredString=passedValue' in command line arguments`, 482 | ), 483 | ).wasCalledOnce(); 484 | expect( 485 | mockConsole 486 | .withFunction('error') 487 | .withParameters( 488 | `Required parameter 'requiredArray' was not passed. Please provide a value by passing '--requiredArray=passedValue' or '-o passedValue' in command line arguments`, 489 | ), 490 | ).wasCalledOnce(); 491 | 492 | expect(mockProcess.withFunction('exit')).wasNotCalled(); 493 | 494 | expect(result).toEqual({ 495 | defaultedString: defaultedStringValue, 496 | requiredBoolean: false, 497 | }); 498 | }); 499 | 500 | describe(`should print help messages`, () => { 501 | it(`and exit when help arg is passed`, () => { 502 | const mockExitFunction = Mock.create<{ processExitCode: ProcessExitCodeFunction }>().setup( 503 | setupFunction('processExitCode', () => 5), 504 | ); 505 | const result = parse(getAllOptionalHelpConfig(), { 506 | logger: mockConsole.mock, 507 | argv: [...optionalHelpArg], 508 | helpArg: 'optionalHelpArg', 509 | headerContentSections: [ 510 | { header: 'Header', content: 'Header Content' }, 511 | { header: 'Header both', content: 'Header Content both', includeIn: 'both' }, 512 | { header: 'Header cli', content: 'Header Content cli', includeIn: 'cli' }, 513 | { header: 'Header markdown', content: 'Header markdown markdown', includeIn: 'markdown' }, 514 | ], 515 | footerContentSections: [ 516 | { header: 'Footer', content: 'Footer Content' }, 517 | { header: 'Footer both', content: 'Footer Content both', includeIn: 'both' }, 518 | { header: 'Footer cli', content: 'Footer Content cli', includeIn: 'cli' }, 519 | { header: 'Footer markdown', content: 'Footer markdown markdown', includeIn: 'markdown' }, 520 | ], 521 | processExitCode: mockExitFunction.mock.processExitCode, 522 | }); 523 | 524 | function verifyHelpContent(content: string): string | boolean { 525 | let currentIndex = 0; 526 | 527 | function verifyNextContent(segment: string) { 528 | const segmentIndex = content.indexOf(segment); 529 | if (segmentIndex < 0) { 530 | return `Expected help content '${segment}' not found`; 531 | } 532 | if (segmentIndex < currentIndex) { 533 | return `Help content '${segment}' not in expected place`; 534 | } 535 | 536 | currentIndex = segmentIndex; 537 | return true; 538 | } 539 | 540 | const helpContentSegments = [ 541 | 'Header', 542 | 'Header Content', 543 | 'Header both', 544 | 'Header Content both', 545 | 'Header cli', 546 | 'Header Content cli', 547 | '-h', 548 | '--optionalHelpArg', 549 | 'This help guide', 550 | 'Footer', 551 | 'Footer Content', 552 | 'Footer both', 553 | 'Footer Content both', 554 | 'Footer cli', 555 | 'Footer Content cli', 556 | ]; 557 | 558 | const failures = helpContentSegments.map(verifyNextContent).filter((result) => result !== true); 559 | 560 | if (content.indexOf('markdown') >= 0) { 561 | failures.push(`'markdown' found in usage guide`); 562 | } 563 | 564 | if (failures.length > 0) { 565 | return failures[0]; 566 | } 567 | 568 | return true; 569 | } 570 | 571 | expect(result).toBeUndefined(); 572 | expect( 573 | mockExitFunction 574 | .withFunction('processExitCode') 575 | .withParametersEqualTo('usageGuide', { optionalHelpArg: true }, []), 576 | ).wasCalledOnce(); 577 | expect(mockProcess.withFunction('exit').withParameters(5)).wasCalledOnce(); 578 | expect(mockConsole.withFunction('error')).wasNotCalled(); 579 | expect(mockConsole.withFunction('log').withParameters(verifyHelpContent)).wasCalledOnce(); 580 | }); 581 | }); 582 | 583 | it(`it should print help messages and return partial arguments when help arg passed and exitProcess is false`, () => { 584 | const result = parse( 585 | getHelpConfig(), 586 | { 587 | logger: mockConsole.mock, 588 | argv: [...defaultedString, ...optionalHelpArg], 589 | helpArg: 'optionalHelpArg', 590 | headerContentSections: [{ header: 'Header', content: 'Header Content' }], 591 | footerContentSections: [{ header: 'Footer', content: 'Footer Content' }], 592 | }, 593 | false, 594 | ); 595 | 596 | expect(result).toEqual({ 597 | defaultedString: defaultedStringValue, 598 | optionalHelpArg: true, 599 | requiredBoolean: false, 600 | }); 601 | expect(mockProcess.withFunction('exit')).wasNotCalled(); 602 | expect(mockConsole.withFunction('log')).wasCalledOnce(); 603 | }); 604 | 605 | it(`it should not print help messages and return unknown arguments when stopAtFirstUnknown is true`, () => { 606 | const result = parse(getConfig(), { 607 | logger: mockConsole.mock, 608 | argv: [...requiredString, ...requiredArray, ...unknownString], 609 | stopAtFirstUnknown: true, 610 | }); 611 | 612 | expect(result).toEqual({ 613 | requiredString: requiredStringValue, 614 | defaultedString: defaultFromOption, 615 | requiredBoolean: false, 616 | requiredArray: requiredArrayValue, 617 | _unknown: [...unknownString], 618 | }); 619 | expect(mockProcess.withFunction('exit')).wasNotCalled(); 620 | expect(mockConsole.withFunction('log')).wasNotCalled(); 621 | }); 622 | 623 | it(`it should not print help messages and return unknown arguments when stopAtFirstUnknown is true and using option groups`, () => { 624 | interface GroupedArguments { 625 | group1Arg: string; 626 | group2Arg: string; 627 | } 628 | 629 | const result = parse( 630 | { 631 | group1Arg: { type: String, group: 'Group1' }, 632 | group2Arg: { type: String, group: 'Group2' }, 633 | }, 634 | { 635 | logger: mockConsole.mock, 636 | argv: ['--group1Arg', 'group1', '--group2Arg', 'group2', ...unknownString], 637 | stopAtFirstUnknown: true, 638 | }, 639 | ); 640 | 641 | expect(result).toEqual({ 642 | group1Arg: 'group1', 643 | group2Arg: 'group2', 644 | _unknown: [...unknownString], 645 | }); 646 | expect(mockProcess.withFunction('exit')).wasNotCalled(); 647 | expect(mockConsole.withFunction('log')).wasNotCalled(); 648 | }); 649 | }); 650 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentConfig, 3 | ParseOptions, 4 | UnknownProperties, 5 | CommandLineOption, 6 | UsageGuideOptions, 7 | Content, 8 | CommandLineResults, 9 | ExitReason, 10 | } from './contracts'; 11 | import commandLineArgs from 'command-line-args'; 12 | import commandLineUsage from 'command-line-usage'; 13 | import { 14 | createCommandLineConfig, 15 | getBooleanValues, 16 | mergeConfig, 17 | normaliseConfig, 18 | removeBooleanValues, 19 | visit, 20 | } from './helpers/index.js'; 21 | import { addOptions, getOptionFooterSection, getOptionSections } from './helpers/options.helper.js'; 22 | import { removeAdditionalFormatting } from './helpers/string.helper.js'; 23 | import { readFileSync } from 'fs'; 24 | import { resolve } from 'path'; 25 | 26 | /** 27 | * parses command line arguments and returns an object with all the arguments in IF all required options passed 28 | * @param config the argument config. Required, used to determine what arguments are expected 29 | * @param options 30 | * @param exitProcess defaults to true. The process will exit if any required arguments are omitted 31 | * @param addCommandLineResults defaults to false. If passed an additional _commandLineResults object will be returned in the result 32 | * @returns 33 | */ 34 | export function parse = ParseOptions, R extends boolean = false>( 35 | config: ArgumentConfig, 36 | options: P = {} as any, 37 | exitProcess = true, 38 | addCommandLineResults?: R, 39 | ): T & UnknownProperties

& CommandLineResults { 40 | options = options || {}; 41 | const argsWithBooleanValues = options.argv || process.argv.slice(2); 42 | const logger = options.logger || console; 43 | const normalisedConfig = normaliseConfig(config); 44 | options.argv = removeBooleanValues(argsWithBooleanValues, normalisedConfig); 45 | const optionList = createCommandLineConfig(normalisedConfig); 46 | let parsedArgs = commandLineArgs(optionList, options) as any; 47 | 48 | if (parsedArgs['_all'] != null) { 49 | const unknown = parsedArgs['_unknown']; 50 | parsedArgs = parsedArgs['_all']; 51 | if (unknown) { 52 | parsedArgs['_unknown'] = unknown; 53 | } 54 | } 55 | 56 | const booleanValues = getBooleanValues(argsWithBooleanValues, normalisedConfig); 57 | parsedArgs = { ...parsedArgs, ...booleanValues }; 58 | 59 | if (options.loadFromFileArg != null && parsedArgs[options.loadFromFileArg] != null) { 60 | const configFromFile: Partial> = JSON.parse( 61 | readFileSync(resolve(parsedArgs[options.loadFromFileArg])).toString(), 62 | ); 63 | const parsedArgsWithoutDefaults = commandLineArgs( 64 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 65 | optionList.map(({ defaultValue, ...option }) => ({ ...option })), 66 | options, 67 | ) as any; 68 | 69 | parsedArgs = mergeConfig( 70 | parsedArgs, 71 | { ...parsedArgsWithoutDefaults, ...booleanValues }, 72 | configFromFile, 73 | normalisedConfig, 74 | options.loadFromFileJsonPathArg as keyof T | undefined, 75 | ); 76 | } 77 | 78 | const missingArgs = listMissingArgs(optionList, parsedArgs); 79 | 80 | if (options.helpArg != null && (parsedArgs as any)[options.helpArg]) { 81 | printHelpGuide(options, optionList, logger); 82 | 83 | if (exitProcess) { 84 | return process.exit(resolveExitCode(options, 'usageGuide', parsedArgs, missingArgs)); 85 | } 86 | } else if (missingArgs.length > 0) { 87 | if (options.showHelpWhenArgsMissing) { 88 | const missingArgsHeader = 89 | typeof options.helpWhenArgMissingHeader === 'function' 90 | ? options.helpWhenArgMissingHeader(missingArgs) 91 | : options.helpWhenArgMissingHeader; 92 | const additionalHeaderSections: Content[] = missingArgsHeader != null ? [missingArgsHeader] : []; 93 | printHelpGuide(options, optionList, logger, additionalHeaderSections); 94 | } else if (options.hideMissingArgMessages !== true) { 95 | printMissingArgErrors(missingArgs, logger, options.baseCommand); 96 | printUsageGuideMessage( 97 | { ...options, logger }, 98 | options.helpArg != null ? optionList.filter((option) => option.name === options.helpArg)[0] : undefined, 99 | ); 100 | } 101 | } 102 | 103 | const _commandLineResults = { 104 | missingArgs: missingArgs, 105 | printHelp: () => printHelpGuide(options, optionList, logger), 106 | }; 107 | 108 | if (missingArgs.length > 0 && exitProcess) { 109 | process.exit(resolveExitCode(options, 'missingArgs', parsedArgs, missingArgs)); 110 | } else { 111 | if (addCommandLineResults) { 112 | parsedArgs = { ...parsedArgs, _commandLineResults }; 113 | } 114 | 115 | return parsedArgs as T & UnknownProperties

& CommandLineResults; 116 | } 117 | } 118 | 119 | function resolveExitCode( 120 | options: ParseOptions, 121 | reason: ExitReason, 122 | passedArgs: Partial, 123 | missingArgs: CommandLineOption[], 124 | ): number { 125 | switch (typeof options.processExitCode) { 126 | case 'number': 127 | return options.processExitCode; 128 | case 'function': 129 | return options.processExitCode(reason, passedArgs, missingArgs as any); 130 | default: 131 | return 0; 132 | } 133 | } 134 | 135 | function printHelpGuide( 136 | options: ParseOptions, 137 | optionList: CommandLineOption[], 138 | logger: Console, 139 | additionalHeaderSections: Content[] = [], 140 | ) { 141 | const sections = [ 142 | ...additionalHeaderSections, 143 | ...(options.headerContentSections?.filter(filterCliSections) || []), 144 | ...getOptionSections(options).map((option) => addOptions(option, optionList, options)), 145 | ...getOptionFooterSection(optionList, options), 146 | ...(options.footerContentSections?.filter(filterCliSections) || []), 147 | ]; 148 | 149 | visit(sections, (value) => { 150 | switch (typeof value) { 151 | case 'string': 152 | return removeAdditionalFormatting(value); 153 | default: 154 | return value; 155 | } 156 | }); 157 | 158 | const usageGuide = commandLineUsage(sections); 159 | 160 | logger.log(usageGuide); 161 | } 162 | 163 | function filterCliSections(section: Content): boolean { 164 | return section.includeIn == null || section.includeIn === 'both' || section.includeIn === 'cli'; 165 | } 166 | 167 | function printMissingArgErrors(missingArgs: CommandLineOption[], logger: Console, baseCommand?: string) { 168 | baseCommand = baseCommand ? `${baseCommand} ` : ``; 169 | missingArgs.forEach((config) => { 170 | const aliasMessage = config.alias != null ? ` or '${baseCommand}-${config.alias} passedValue'` : ``; 171 | const runCommand = 172 | baseCommand !== '' 173 | ? `running '${baseCommand}--${config.name}=passedValue'${aliasMessage}` 174 | : `passing '--${config.name}=passedValue'${aliasMessage} in command line arguments`; 175 | logger.error(`Required parameter '${config.name}' was not passed. Please provide a value by ${runCommand}`); 176 | }); 177 | } 178 | 179 | function printUsageGuideMessage(options: UsageGuideOptions & { logger: Console }, helpParam?: CommandLineOption) { 180 | if (helpParam != null) { 181 | const helpArg = helpParam.alias != null ? `-${helpParam.alias}` : `--${helpParam.name}`; 182 | const command = options.baseCommand != null ? `run '${options.baseCommand} ${helpArg}'` : `pass '${helpArg}'`; 183 | 184 | options.logger.log(`To view the help guide ${command}`); 185 | } 186 | } 187 | 188 | function listMissingArgs(commandLineConfig: CommandLineOption[], parsedArgs: commandLineArgs.CommandLineOptions) { 189 | return commandLineConfig 190 | .filter((config) => config.optional == null && parsedArgs[config.name] == null) 191 | .filter((config) => { 192 | if (config.type.name === 'Boolean') { 193 | parsedArgs[config.name] = false; 194 | return false; 195 | } 196 | 197 | return true; 198 | }); 199 | } 200 | -------------------------------------------------------------------------------- /src/write-markdown.constants.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentConfig, IWriteMarkDown, ParseOptions, UsageGuideConfig } from './contracts'; 2 | 3 | export const replaceBelowDefault = `[//]: ####ts-command-line-args_write-markdown_replaceBelow`; 4 | export const replaceAboveDefault = `[//]: ####ts-command-line-args_write-markdown_replaceAbove`; 5 | export const insertCodeBelowDefault = `[//]: # (ts-command-line-args_write-markdown_insertCodeBelow`; 6 | export const insertCodeAboveDefault = `[//]: # (ts-command-line-args_write-markdown_insertCodeAbove)`; 7 | export const copyCodeBelowDefault = `// ts-command-line-args_write-markdown_copyCodeBelow`; 8 | export const copyCodeAboveDefault = `// ts-command-line-args_write-markdown_copyCodeAbove`; 9 | export const configImportNameDefault = `usageGuideInfo`; 10 | export const footerReplaceBelowMarker = `[//]: ####ts-command-line-args_generated-by-footer`; 11 | 12 | export const argumentConfig: ArgumentConfig = { 13 | markdownPath: { 14 | type: String, 15 | alias: 'm', 16 | defaultOption: true, 17 | description: 18 | 'The file to write to. Without replacement markers the whole file content will be replaced. Path can be absolute or relative.', 19 | }, 20 | replaceBelow: { 21 | type: String, 22 | defaultValue: replaceBelowDefault, 23 | description: `A marker in the file to replace text below.`, 24 | optional: true, 25 | }, 26 | replaceAbove: { 27 | type: String, 28 | defaultValue: replaceAboveDefault, 29 | description: `A marker in the file to replace text above.`, 30 | optional: true, 31 | }, 32 | insertCodeBelow: { 33 | type: String, 34 | defaultValue: insertCodeBelowDefault, 35 | description: `A marker in the file to insert code below. File path to insert must be added at the end of the line and optionally codeComment flag: 'insertToken file="path/toFile.md" codeComment="ts"'`, 36 | optional: true, 37 | }, 38 | insertCodeAbove: { 39 | type: String, 40 | defaultValue: insertCodeAboveDefault, 41 | description: `A marker in the file to insert code above.`, 42 | optional: true, 43 | }, 44 | copyCodeBelow: { 45 | type: String, 46 | defaultValue: copyCodeBelowDefault, 47 | description: `A marker in the file being inserted to say only copy code below this line`, 48 | optional: true, 49 | }, 50 | copyCodeAbove: { 51 | type: String, 52 | defaultValue: copyCodeAboveDefault, 53 | description: `A marker in the file being inserted to say only copy code above this line`, 54 | optional: true, 55 | }, 56 | jsFile: { 57 | type: String, 58 | optional: true, 59 | alias: 'j', 60 | description: `jsFile to 'require' that has an export with the 'UsageGuideConfig' export. Multiple files can be specified.`, 61 | multiple: true, 62 | }, 63 | configImportName: { 64 | type: String, 65 | alias: 'c', 66 | defaultValue: [configImportNameDefault], 67 | description: `Export name of the 'UsageGuideConfig' object. Defaults to '${configImportNameDefault}'. Multiple exports can be specified.`, 68 | multiple: true, 69 | }, 70 | verify: { 71 | type: Boolean, 72 | alias: 'v', 73 | description: `Verify the markdown file. Does not update the file but returns a non zero exit code if the markdown file is not correct. Useful for a pre-publish script.`, 74 | }, 75 | configFile: { 76 | type: String, 77 | alias: 'f', 78 | optional: true, 79 | description: `Optional config file to load config from. package.json can be used if jsonPath specified as well`, 80 | }, 81 | jsonPath: { 82 | type: String, 83 | alias: 'p', 84 | optional: true, 85 | description: `Used in conjunction with 'configFile'. The path within the config file to load the config from. For example: 'configs.writeMarkdown'`, 86 | }, 87 | verifyMessage: { 88 | type: String, 89 | optional: true, 90 | description: `Optional message that is printed when markdown verification fails. Use '\\{fileName\\}' to refer to the file being processed.`, 91 | }, 92 | removeDoubleBlankLines: { 93 | type: Boolean, 94 | description: 'When replacing content removes any more than a single blank line', 95 | }, 96 | skipFooter: { 97 | type: Boolean, 98 | description: `Does not add the 'Markdown Generated by...' footer to the end of the markdown`, 99 | }, 100 | help: { type: Boolean, alias: 'h', description: `Show this usage guide.` }, 101 | }; 102 | 103 | export const parseOptions: ParseOptions = { 104 | helpArg: 'help', 105 | loadFromFileArg: 'configFile', 106 | loadFromFileJsonPathArg: 'jsonPath', 107 | baseCommand: `write-markdown`, 108 | optionsHeaderLevel: 3, 109 | defaultSectionHeaderLevel: 3, 110 | optionsHeaderText: `write-markdown cli options`, 111 | headerContentSections: [ 112 | { 113 | header: 'Markdown Generation', 114 | headerLevel: 2, 115 | content: `A markdown version of the usage guide can be generated and inserted into an existing markdown document. 116 | Markers in the document describe where the content should be inserted, existing content betweeen the markers is overwritten.`, 117 | }, 118 | { 119 | content: `{highlight write-markdown -m README.MD -j usageGuideConstants.js}`, 120 | }, 121 | ], 122 | footerContentSections: [ 123 | { 124 | header: 'Default Replacement Markers', 125 | content: `replaceBelow defaults to: 126 | {code '${replaceBelowDefault}'} 127 | replaceAbove defaults to: 128 | {code '${replaceAboveDefault}'} 129 | insertCodeBelow defaults to: 130 | {code '${insertCodeBelowDefault}'} 131 | insertCodeAbove defaults to: 132 | {code '${insertCodeAboveDefault}'} 133 | copyCodeBelow defaults to: 134 | {code '${copyCodeBelowDefault}'} 135 | copyCodeAbove defaults to: 136 | {code '${copyCodeAboveDefault}'}`, 137 | }, 138 | ], 139 | }; 140 | 141 | export const usageGuideInfo: UsageGuideConfig = { 142 | arguments: argumentConfig, 143 | parseOptions, 144 | }; 145 | -------------------------------------------------------------------------------- /src/write-markdown.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { parse } from './parse.js'; 4 | import { IWriteMarkDown } from './contracts'; 5 | import { resolve, relative } from 'path'; 6 | import { readFileSync, writeFileSync } from 'fs'; 7 | import { addCommandLineArgsFooter, addContent, generateUsageGuides, insertCode } from './helpers/index.js'; 8 | import { argumentConfig, parseOptions } from './write-markdown.constants.js'; 9 | import format from 'string-format'; 10 | import chalk from 'chalk'; 11 | 12 | async function writeMarkdown() { 13 | const args = parse(argumentConfig, parseOptions); 14 | 15 | const markdownPath = resolve(args.markdownPath); 16 | 17 | console.log(`Loading existing file from '${chalk.blue(markdownPath)}'`); 18 | const markdownFileContent = readFileSync(markdownPath).toString(); 19 | 20 | const usageGuides = await generateUsageGuides(args); 21 | 22 | let modifiedFileContent = markdownFileContent; 23 | 24 | if (usageGuides != null) { 25 | modifiedFileContent = addContent(markdownFileContent, usageGuides, args); 26 | if (!args.skipFooter) { 27 | modifiedFileContent = addCommandLineArgsFooter(modifiedFileContent); 28 | } 29 | } 30 | 31 | modifiedFileContent = await insertCode({ fileContent: modifiedFileContent, filePath: markdownPath }, args); 32 | 33 | const action = args.verify === true ? `verify` : `write`; 34 | const contentMatch = markdownFileContent === modifiedFileContent ? `match` : `nonMatch`; 35 | 36 | const relativePath = relative(process.cwd(), markdownPath); 37 | 38 | switch (`${action}_${contentMatch}`) { 39 | case 'verify_match': 40 | console.log(chalk.green(`'${relativePath}' content as expected. No update required.`)); 41 | break; 42 | case 'verify_nonMatch': 43 | console.warn( 44 | chalk.yellow( 45 | format( 46 | args.verifyMessage || `'${relativePath}' file out of date. Rerun write-markdown to update.`, 47 | { 48 | fileName: relativePath, 49 | }, 50 | ), 51 | ), 52 | ); 53 | return process.exit(1); 54 | case 'write_match': 55 | console.log(chalk.blue(`'${relativePath}' content not modified, not writing to file.`)); 56 | break; 57 | case 'write_nonMatch': 58 | console.log(`Writing modified file to '${chalk.blue(relativePath)}'`); 59 | writeFileSync(relativePath, modifiedFileContent); 60 | break; 61 | } 62 | } 63 | 64 | writeMarkdown(); 65 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "src/**/*.spec.ts", 5 | "dist", 6 | "src/example/**/*" 7 | ] 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist", /* Redirect output structure to the directory. */ 15 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | /* Strict Type-Checking Options */ 23 | "strict": true, /* Enable all strict type-checking options. */ 24 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 25 | // "strictNullChecks": true, /* Enable strict null checks. */ 26 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 27 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | /* Additional Checks */ 32 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 33 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 34 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 35 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 36 | /* Module Resolution Options */ 37 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 38 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 39 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 40 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 41 | // "typeRoots": [], /* List of folders to include type definitions from. */ 42 | // "types": [], /* Type declaration files to be included in compilation. */ 43 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 44 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 45 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 46 | /* Source Map Options */ 47 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 48 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 49 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 50 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 51 | /* Experimental Options */ 52 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 53 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 54 | "skipLibCheck": true 55 | } 56 | } --------------------------------------------------------------------------------