Comprehensive Markdown builder for JavaScript/TypeScript.
๐ Full documentation is hosted at https://matejchalk.github.io/build-md/.
if
/else
branches or for
loops. But if you prefer this coding style, then its supported in mutable mode (immutable is default).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);
[^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.
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()
);
}
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();
}
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();
}
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'));
}
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();
npm install
.npm test
or npm run test:watch
(uses Vitest).npm run docs
(uses TypeDoc).npm run build
(uses tsup).npm run commit
.