build-md
v0.4.2
Published
Markdown builder for JS/TS
Downloads
3,342
Readme
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.
- Builder pattern used for creating Markdown documents.
- Tagged template literal used for inline Markdown formatting and nesting Markdown blocks.
- ✅ Has comprehensive support for most commonly used Markdown elements.
- All elements from Markdown's basic syntax are included.
- Also supports many elements from extended syntax (e.g. from GitHub Flavored Markdown).
- 📖 See List of all supported 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 orfor
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)
| |
| 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
ornpm 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
.