build-md

Markdown builder for JS/TS

MIT License

Downloads
3.7K
Stars
2

build-md

Comprehensive Markdown builder for JavaScript/TypeScript.

๐Ÿ“– Full documentation is hosted at https://matejchalk.github.io/build-md/.

โญ Key features

  • โœ๏ธ Its intuitive syntax makes it convenient for generating Markdown from JavaScript/TypeScript code.
  • โœ… Has comprehensive support for most commonly used Markdown elements.
  • ๐Ÿ—‚๏ธ Enables logical nesting of Markdown elements and uses contextual rendering to ensure output will be rendered correctly.
    • Blocks may contain inline elements or even other blocks (e.g. nested lists), inline elements may contain other inline elements, etc.
    • Each element may be rendered as HTML instead of Markdown if needed. For example, block elements in Markdown tables will automatically render using equivalent HTML tags. And if a parent element is rendered as HTML, so will all its children.
  • ๐Ÿงฎ Document builder enables writing conditional and iterative logic in a declarative way.
    • Falsy values from regular JavaScript expressions are ignored.
    • Special methods provided for adding multiple related elements conditionally or in a loop.
    • Even for very complex dynamic documents, there should be no need to resort to imperative logic like if/else branches or for loops. But if you prefer this coding style, then its supported in mutable mode (immutable is default).
    • ๐Ÿ“– See Dynamic content.
  • ๐ŸŽ€ Markdown output is well-formatted.
    • Automatically inserts line breaks and indentation when appropriate. Even Markdown tables are aligned to be more readable.
    • No need to run additional tools like Prettier to have nicely formatted Markdown.
  • โ™ป๏ธ Is lightweight with zero dependencies, as well as being completely runtime agnostic with regards to browser vs Node, CJS vs ESM, etc.

๐Ÿš€ Quickstart

Install build-md with your package manager in the usual way. E.g. to install as a dev dependency using NPM:

npm install -D build-md

Import the MarkdownDocument class, add some basic Markdown blocks and render as string:

import { MarkdownDocument } from 'build-md';

new MarkdownDocument()
  .heading(1, 'Contributing')
  .heading(2, 'Setup')
  .paragraph('Install dependencies with:')
  .code('sh', 'npm install')
  .heading(2, 'Development')
  .list([
    'npm test - run unit tests with Vitest',
    'npm run docs - generate documenation with TypeDoc',
  ])
  .toString();

To add inline formatting, import the md tagged template literal:

import { MarkdownDocument, md } from 'build-md';

new MarkdownDocument()
  // ...
  .list([
    md`${md.code('npm test')} - run unit tests with ${md.link(
      'https://vitest.dev/',
      'Vitest'
    )}`,
    md`${md.code('npm run docs')} - generate documenation with ${md.link(
      'https://typedoc.org/',
      'TypeDoc'
    )}`,
  ])
  .toString();

To see it in action, copy/paste this complete example into a docs.mjs file and run node docs.mjs to generate a CONTRIBUTING.md file:

import { MarkdownDocument, md } from 'build-md';
import { writeFile } from 'node:fs/promises';

const markdown = new MarkdownDocument()
  .heading(1, 'Contributing')
  .heading(2, 'Setup')
  .paragraph('Install dependencies with:')
  .code('sh', 'npm install')
  .heading(2, 'Development')
  .list([
    md`${md.code('npm test')} - run unit tests with ${md.link(
      'https://vitest.dev/',
      'Vitest'
    )}`,
    md`${md.code('npm run docs')} - generate documenation with ${md.link(
      'https://typedoc.org/',
      'TypeDoc'
    )}`,
  ])
  .toString();

await writeFile('CONTRIBUTING.md', markdown);

๐Ÿ“‹ List of supported Markdown elements

Element Usage Example
Bold md.bold(text) important text
Italic md.italic(text) emphasized text
Link md.link(href, text?, title?) link
Image md.image(src, alt) image
Code md.code(text) source_code
Strikethrough [^1] md.strikethrough(text) crossed out
Footnote [^1] md.footnote(text, label?) [^2]
Heading MarkdownDocument#heading(level, text)md.heading(level, text) Title
Paragraph MarkdownDocument#paragraph(text)md.paragraph(text) Some long text spanning a few sentences.
Code block MarkdownDocument#code(lang?, text)md.codeBlock(lang?, text) sourceCode({ ย multiLine: true, ย syntaxHighlighting: true })
Horizontal rule MarkdownDocument#rule()md.rule()
Blockquote MarkdownDocument#quote(text)md.quote(text) interesting quote
Unordered list MarkdownDocument#list(items)md.list(items) list item 1list item 2
Ordered list MarkdownDocument#list('ordered', items)md.list('ordered', items) list item 1list item 2
Task list [^1] MarkdownDocument#list('task', items)md.list('task', items) โ˜‘ list item 1โ˜ list item 2
Table [^1] MarkdownDocument#table(columns, rows)md.table(columns, rows) heading 1heading 2row 1, col. 1row 1, col. 2row 2, col. 1row 2, col. 2
Details [^3] MarkdownDocument#details(summary?, text)md.details(summary?, text) expandable content

[^1]: Not part of basic Markdown syntax, but supported by some Markdown extensions like GFM. [^2]: Footnotes render a label in place of insertion, as well as appending a block to the end of the document with the content. [^3]: Always rendered as HTML.

๐Ÿฅฝ Diving in

๐Ÿงฉ Dynamic content

While the Quickstart example shows how to render static Markdown, the main purpose of a Markdown builder is to generate content dynamically. The MarkdownDocument class is designed for writing conditional or iterative logic in a simple and declarative way, without having to break out of the builder chain.

For starters, document blocks with empty content are automatically skipped. So if the expression you write for a top-level block's content evaluates to some empty value (falsy or empty array), then the block won't be appended to the document.

function createMarkdownComment(
  totalCount: number,
  passedCount: number,
  logsUrl: string | null,
  failedChecks?: string[]
): string {
  return (
    new MarkdownDocument()
      .heading(1, `๐Ÿ›ก๏ธ Quality gate - ${passedCount}/${totalCount}`)
      // ๐Ÿ‘‡ `false` will skip quote
      .quote(passedCount === totalCount && 'โœ… Everything in order!')
      // ๐Ÿ‘‡ `undefined` or `0` will skip heading
      .heading(2, failedChecks?.length && 'โŒ Failed checks')
      // ๐Ÿ‘‡ `undefined` or `[]` will skip list
      .list(failedChecks?.map(md.code))
      // ๐Ÿ‘‡ `""` or `null` will skip paragraph
      .paragraph(logsUrl && md.link(logsUrl, '๐Ÿ”— CI logs'))
      .toString()
  );
}

๐Ÿงฎ Control flow methods

The conditional expressions approach outlined above is convenient for toggling individual blocks. But if your logic affects multiple blocks at once, you may reach instead for one of the provided control flow methods โ€“ $if and $foreach.

The $if method is useful for subjecting multiple blocks to a single condition. Provide a callback function which returns the MarkdownDocument instance with added blocks. This callback will only be used if the condition is true.

new MarkdownDocument()
  .heading(1, `๐Ÿ›ก๏ธ Quality gate - ${passedCount}/${totalCount}`)
  .quote(passedCount === totalCount && 'โœ… Everything in order!')
  // ๐Ÿ‘‡ heading and list added if `passedCount < totalCount`, otherwise both skipped
  .$if(passedCount < totalCount, doc =>
    doc.heading(2, 'โŒ Failed checks').list(failedChecks?.map(md.code))
  )
  .paragraph(logsUrl && md.link(logsUrl, '๐Ÿ”— CI logs'))
  .toString();

Optionally, you may provide another callback which will be used if the condition is false (think of it as the else-branch).

new MarkdownDocument()
  .heading(1, `๐Ÿ›ก๏ธ Quality gate - ${passedCount}/${totalCount}`)
  .$if(
    passedCount === totalCount,
    // ๐Ÿ‘‡ quote added if `passedCount === totalCount` is true
    doc => doc.quote('โœ… Everything in order!'),
    // ๐Ÿ‘‡ heading and list added if `passedCount === totalCount` is false
    doc => doc.heading(2, 'โŒ Failed checks').list(failedChecks?.map(md.code))
  )
  .paragraph(logsUrl && md.link(logsUrl, '๐Ÿ”— CI logs'))
  .toString();

When it comes to iterative logic, then for individual blocks like lists and tables you can use the usual array methods (.map, .filter, etc.) to make the content dynamic. But if you need to generate multiple blocks per array item, the $foreach method comes in handy.

Provide an array for the 1st argument, and a callback for the 2nd. The callback function is called for each item in the array, and is expected to add blocks to the current MarkdownDocument instance.

function createMarkdownCommentForMonorepo(
  projects: {
    name: string;
    totalCount: number;
    passedCount: number;
    logsUrl: string | null;
    failedChecks?: string[];
  }[]
): string {
  return new MarkdownDocument()
    .heading(1, `๐Ÿ›ก๏ธ Quality gate (${projects.length} projects)`)
    .$foreach(
      projects,
      (doc, { name, totalCount, passedCount, logsUrl, failedChecks }) =>
        doc
          .heading(2, `๐Ÿ’ผ ${name} - ${passedCount}/${totalCount}`)
          .$if(
            passedCount === totalCount,
            doc => doc.quote('โœ… Everything in order!'),
            doc =>
              doc
                .heading(3, 'โŒ Failed checks')
                .list(failedChecks?.map(md.code))
          )
          .paragraph(logsUrl && md.link(logsUrl, '๐Ÿ”— CI logs'))
    )
    .toString();
}

๐ŸงŠ Immutable vs mutable

By default, instances of MarkdownDocument are immutable. Methods for appending document blocks return a new instance, leaving the original instance unaffected.

// ๐Ÿ‘‡ `extendedDocument` has additional blocks, `baseDocument` unmodified
const extendedDocument = baseDocument
  .rule()
  .paragraph(md`Made with โค๏ธ by ${md.link(OWNER_LINK, OWNER_NAME)}`);

This is an intentional design decision to encourage building Markdown documents declaratively, instead of an imperative approach using if/else branches, for loops, etc.

However, if you prefer to write your logic imperatively, then you have the option of setting mutable: true when instantiating a document.

function createMarkdownCommentForMonorepo(
  projects: {
    name: string;
    totalCount: number;
    passedCount: number;
    logsUrl: string | null;
    failedChecks?: string[];
  }[]
): string {
  // ๐Ÿ‘‡ all method calls will mutate document
  const doc = new MarkdownDocument({ mutable: true });

  // ๐Ÿ‘‡ ignoring return value would have no effect in immutable mode
  doc.heading(1, `๐Ÿ›ก๏ธ Quality gate (${projects.length} projects)`);

  // ๐Ÿ‘‡ imperative loops work because of side-effects
  for (const project of projects) {
    const { name, totalCount, passedCount, logsUrl, failedChecks } = project;

    doc.heading(2, `๐Ÿ’ผ ${name} - ${passedCount}/${totalCount}`);

    // ๐Ÿ‘‡ imperative conditions work because of side-effects
    if (passedCount === totalCount) {
      doc.quote('โœ… Everything in order!');
    } else {
      doc.heading(3, 'โŒ Failed checks').list(failedChecks?.map(md.code));
    }

    if (logsUrl) {
      doc.paragraph(md.link(logsUrl, '๐Ÿ”— CI logs'));
    }
  }

  return doc.toString();
}

๐Ÿช— Composing documents

When building complex documents, extracting some sections to other functions helps keep the code more mantainable. This is where the $concat method comes in useful. It accepts one or more other documents and appends their blocks to the current document. This makes it convenient to break up pieces of builder logic into functions, as well as making sections of documents easily reusable.

function createMarkdownComment(
  totalCount: number,
  passedCount: number,
  logsUrl: string | null,
  failedChecks?: string[]
): string {
  return new MarkdownDocument()
    .$concat(
      // ๐Ÿ‘‡ adds heading and quote from other document
      createMarkdownCommentSummary(totalCount, passedCount),
      // ๐Ÿ‘‡ adds heading, list and paragraph from other document
      createMarkdownCommentDetails(logsUrl, failedChecks)
    )
    .toString();
}

function createMarkdownCommentSummary(
  totalCount: number,
  passedCount: number
): MarkdownDocument {
  return new MarkdownDocument()
    .heading(1, `๐Ÿ›ก๏ธ Quality gate - ${passedCount}/${totalCount}`)
    .quote(passedCount === totalCount && 'โœ… Everything in order!');
}

function createMarkdownCommentDetails(
  logsUrl: string | null,
  failedChecks?: string[]
): MarkdownDocument {
  return new MarkdownDocument()
    .heading(2, failedChecks?.length && 'โŒ Failed checks')
    .list(failedChecks?.map(md.code))
    .paragraph(logsUrl && md.link(logsUrl, '๐Ÿ”— CI logs'));
}

๐Ÿ“ Inline formatting

The md tagged template literal is for composing text which includes Markdown elements. It provides an intuitive syntax for adding inline formatting, as well as embedding nested blocks within top-level document blocks. Its output is embeddable into all elements (with a few logical exceptions like code blocks), so it acts as the glue for building documents with a complex hierarchy.

It also comes in handy when you don't want to render a full document, but only need a one-line Markdown string. Just like for the MarkdownDocument class, calling .toString() returns the converted Markdown text.

md`${md.bold(severity)} severity vulnerability in ${md.code(name)}`.toString();

๐Ÿค Contributing

  • Prerequisite is having Node.js installed.
  • Install dev dependencies with npm install.
  • Run tests with npm test or npm run test:watch (uses Vitest).
  • Generate documentation with npm run docs (uses TypeDoc).
  • Compile TypeScript sources with npm run build (uses tsup).
  • Use Conventional Commits prompts with npm run commit.
Package Rankings
Top 26.41% on Npmjs.org
Badges
Extracted from project README
npm CI codecov
Related Projects