Skip to content

Generating files from templates with a CLI command

Overview

This post breaks down the steps needed to create a command line, which when executed will create a markdown file from a template.

Demo code: https://github.com/huyenltnguyen/blog-demos/tree/main/generating-files-from-templates-with-a-cli-command.

Dependencies

npm i --save-dev ts-node vitest memfs

Folder structure

my-app/
├─ cli/
│  ├─ gen-doc.ts
├─ docs/
├─ scripts/
│  ├─ gen-doc.ts
├─ templates/
│  ├─ gen-doc.ts
├─ package.json

Note: The separation of cli and scripts enables us to extract and write unit tests for the core logic, without having to worry about the execution context (via a command line or via an outer function call).

Implementation

Creating a template

First of, we need to create a new template in the templates folder:

// templates/gen-doc.ts
export const genDocTemplate = (title: string) => `# ${title}
 
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
`

Adding a command to package.json

We add the gen-doc command to package.json:

// package.json
{
  "scripts": {
    "gen-doc": "ts-node ./cli/gen-doc.ts"
  }
}

When the user runs npm run gen-doc, the ./cli/gen-doc.ts file will be executed.

Adding a script for the file gen operation

Within scripts, we create a gen-doc.ts file and define a genDoc function that handles the file generation. The function accepts fileName as an argument, then transforms and passes it to the template.

// scripts/gen-doc.ts
import fs from 'fs'
import path from 'path'
 
import { genDocTemplate } from '../templates/gen-doc'
 
const DOCS_DIR = path.join(process.cwd(), 'docs')
 
export const genDoc = (fileName?: string) => {
  // `fileName` can be `undefined` if the user runs
  // the `gen-doc` command without specifying a file name
  if (!fileName) {
    throw new Error('You must include a file name. Example: my-doc.')
  }
 
  // Throw an error if the file already exists
  if (fs.existsSync(`${DOCS_DIR}/${fileName}.md`)) {
    throw new Error('A file with that name already exists.')
  }
 
  // Transform `fileName` to a title,
  // with hyphens removed and the first character capitalized
  const title = `${fileName.charAt(0).toUpperCase() + fileName.slice(1)}`
    .split('-')
    .join(' ')
 
  // Create a new doc file
  fs.writeFileSync(`${DOCS_DIR}/${fileName}.md`, genDocTemplate(title))
 
  console.log(`${fileName}.md created successfully.`)
}

Handling the gen-doc command

The last step is to create a genDoc.ts file in the cli folder. When the user runs npm run gen-doc [fileName], this file is executed, extracts the provided fileName, and calls the genDoc function from the script:

// cli/gen-doc.ts
import { genDoc } from '../scripts/gen-doc'
 
// Grab file name from terminal argument
const fileName = process.argv[2]
 
genDoc(fileName)

Testing

Manual testing

We can test the CLI by running the following command in the terminal:

npm run gen-doc my-doc

A new my-doc.md file should be created in the docs folder.

Unit testing

As previously mentioned, we are going to write tests for the core logic, which is the genDoc function in script/gen-doc.ts. The fs module is mocked with memfs to prevent the tests from writing to the real file system.

Within the scripts folder, create a gen-doc.test.ts file as follows:

// scripts/gen-doc.test.ts
import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest'
import fs from 'fs'
import { vol } from 'memfs'
import path from 'path'
 
import { genDoc } from './gen-doc'
 
const removeWhitespace = (str: string) => str.replace(/\s+/g, ' ')
 
vi.mock('fs', async () => {
  const memfs: { fs: typeof fs } = await vi.importActual('memfs')
 
  return {
    default: memfs.fs,
  }
})
 
describe('genDoc', () => {
  beforeEach(() => {
    // Create a `docs` folder with a `first-doc.md` in the virtual file system
    vol.fromJSON({
      'docs/first-doc.md': 'Hello world',
    })
  })
 
  afterEach(() => {
    vol.reset()
  })
 
  it('should create a new doc file if the provided file name is valid', () => {
    const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
 
    genDoc('second-doc')
 
    expect(logSpy).toHaveBeenCalledWith('second-doc.md created successfully.')
 
    const filePath = path.join(process.cwd(), 'docs/second-doc.md')
    expect(fs.existsSync(filePath)).toBe(true)
 
    const fileContent = fs.readFileSync(filePath, 'utf-8')
 
    expect(removeWhitespace(fileContent)).toContain(
      removeWhitespace(
        `# Second doc
        
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`
      )
    )
  })
})

In the terminal, run the following command:

npm test -t scripts/gen-doc.test.ts

The test should pass successfully.


Some notes on the test: