bbmd
v0.0.4
Published
The Markdown toolkit for writing complex, embeddable documents in Typescript
Maintainers
Readme
bbmd - block based markdown documents
The Markdown toolkit for writing complex, embeddable documents in Typescript
What is bbmd?
By using a block architecture, bbmd allows you to write complex Markdown documents that are context-agnostic and always render well. Your documents can easily interlace defaults, hide blocks conditionally, and adjust their structure and styling automatically when injected into other bbmd documents.
import { b } from "bbmd";
type User = { name?: string; email?: string; alternateEmail?: string };
const createUserDoc = (user: User): MarkdownDocument => {
const footnote = b
.footnote(`Alternate email: ${user.alternateEmail}`)
.if(user.alternateEmail);
return b
.doc(
b.heading("User details"),
b.p`The users name is ${b.p(user.name).default("unknown")}`,
b.p`${b.b`The users email is`}: ${user.email}${footnote}`.if(user.email),
)
.if(user.name || user.email);
};
const user: User = { email: "[email protected]", alternateEmail: "[email protected]" };
const userDoc = createUserDoc(user);
console.log(`${userDoc}`);
// # User details
// The users name is unknown
// **The users email is**: [email protected][^1]
//
// [^1]: Alternate email: [email protected]
const prompt = b
.doc(
b.heading("Instructions"),
"Greet the user and introduce yourself as a helpful AI assistant.",
userDoc,
"Once you have done this, call the `welcomeGiven` tool.",
)
.setRenderingOptions({
enforce: { bold: { style: "__" } },
newlineStrategy: "between_blocks",
});
console.log(String(prompt));
// # Instructions
//
// Greet the user and introduce yourself as a helpful AI assistant.
//
// ## User details
//
// The users name is unknown
//
// __The users email is__: [email protected][^1]
//
// Once you have done this, call the `welcomeGiven` tool.
//
// [^1]: Alternate email: [email protected]Notice that in this example:
- The user document reacts to the data using a simple, declarative syntax
- Embedded documents get structurally reorganized to make sense in their host context. In this example, the
userDocheading was shifted up its the footnote moved to the bottom of the document - You can enforce consistent rendering at the root document level, which applies recursively to sub-documents
- The document is readable by embedding it in template literals, passing to
String(), or calling the.toString()method directly from the document
This is the magic of a block-based architecture. By keeping a walkable block structure until the document needs to be rendered, documents are able to be context-agnostic and react to where they're embedded in their hosts.
console.log(userDoc.inspect());
// MarkdownDocument
// ├── MarkdownHeadingBlock
// │ └── "User details"
// ├── MarkdownLiteral [trimmed]
// │ ├── "The users name is "
// │ └── MarkdownParagraphBlock
// │ └── "unknown"
// └── MarkdownLiteral [trimmed]
// ├── MarkdownBoldBlock
// │ └── "The users email is"
// ├── ": "
// ├── "[email protected]"
// └── MarkdownFootnoteBlock
// └── footer
// └── "Alternate email: [email protected]"It also enables you to use a comfortable, chainable syntax to create your documents, which is perfect for building complex prompts programmatically.
type PullRequest = { title: string; reviewer: string; approved: boolean };
const createPrDoc = (pr: PullRequest): MarkdownDocument => {
const reviewer = b.b(pr.reviewer).change((block) => {
if (pr.approved) return block.strikethrough();
return block;
});
return b.doc(b.b(pr.title).h(), b.p("Reviewer: ", reviewer));
};
const pr: PullRequest = { title: "100", reviewer: "John", approved: true };
console.log(String(createPrDoc(pr)));
// # **100**
// Reviewer: ~~**John**~~
console.log(String(createPrDoc({ ...pr, approved: false })));
// # **100**
// Reviewer: **John**Because bbmd allows all primitive data types as inputs, and it's resulting documents are swappable to anywhere you currently use strings, it's very easy to incrementally adopt bbmd.
For near instant adoption anywhere you use template literals today, just prefix them with b.md. By default, b.md detects injected blocks and encapsulates them (see the first example above), but if you would prefer to write markdown as you normally would, you can combine b.md with the .parse() method.
// 👇 add `b.md` to existing template literals
const existingPrompt = b.md`
# About our company
We are a company that makes widgets.
## Our process
We follow a _rigorous_ process to make ==widgets==.
## Our customers
| Name | Email |
|------------|------------------------|
| John Doe | [email protected] |
| Jane Smith | [email protected] |
`.parse(); // 👈 and then `.parse()` it to convert it automatically
console.log(existingPrompt.inspect());
// MarkdownDocument
// ├── MarkdownHeadingBlock
// │ └── "About our company"
// ├── MarkdownParagraphBlock
// │ └── "We are a company that makes widgets."
// ├── MarkdownLineBreakBlock
// ├── MarkdownSectionBlock
// │ ├── MarkdownHeadingBlock
// │ │ └── "Our process"
// │ ├── MarkdownParagraphBlock
// │ │ ├── "We follow a "
// │ │ ├── MarkdownItalicBlock [style=_]
// │ │ │ └── "rigorous"
// │ │ ├── " process to make "
// │ │ ├── MarkdownHighlightBlock
// │ │ │ └── "widgets"
// │ │ └── "."
// │ └── MarkdownLineBreakBlock
// └── MarkdownSectionBlock
// ├── MarkdownHeadingBlock
// │ └── "Our customers"
// └── MarkdownTableBlock [columns=Name,Email, rows=2]
// ├── columns
// │ ├── "Name"
// │ └── "Email"
// └── rows
// ├── row 0
// │ ├── "John Doe"
// │ └── "[email protected]"
// └── row 1
// ├── "Jane Smith"
// └── "[email protected]"Did you notice the extra bit of magic in the example above? b.md also improves on standard template literals by automatically removing empty lines at the top and bottom of your document, and removes leading whitespace from each line, unless it's a code block or indented list, meaning you can forget about causing indentation issues.
Features
- Full support for standard, extended, and Github Flavored Markdown syntax specifications
- Additional support for common Markdown hacks like underlines, comments, details, and image captions
- Sub-document and section handling renders your blocks perfectly wherever they're injected
- Concise chaining API that focuses on terseness
- Simple return interfaces enable easy typing for document factories
- Automatic parsing using
b.md``.parse()enables quick adoption - Documents convert to strings automatically using
String(doc)or template literals using${doc}and support.toString() - Zero dependencies and minimal bundle size
Getting started
Run the install command using your package manger of choice:
npm install bbmdyarn add bbmdpnpm add bbmdThen import b anywhere in your application:
import { b } from "bbmd";Advice
Encode as much block data as possible
The most important thing to understand about bbmd is that the more metadata you encode in the block system, the more able it is to ensure that your documents are truly context-agnostic.
bbmd exposes many ways to achieve this, which allows you to pick whichever one suits your use case best. Parse encoding is best for quickly adopting existing template literals, functional encoding works best for constructing complex, conditional documents, and template encoding offers a mix of the conveniences of both syntaxes.
const templateEncoding = b.md`
${b.h("Example document").l(2).id("example-document")}
${b.b("Important text")}${b.fn("example footnote")}
`;
const functionalEncoding = b.doc(
b.h("Example document").l(2).id("example-document"),
b.p(b.b("Important text"), b.fn("example footnote")),
);
const parseEncoding = b.md`
## Example document {#example-document}
**Important text**[^1]
[^1]: example footnote
`.parse();
expect(String(templateEncoding)).toBe(String(functionalEncoding));
expect(String(functionalEncoding)).toBe(String(parseEncoding));Use chaining to express conditions
When creating complex documents that need to respond to state, use the provided methods to easily handle most scenarios. .if(), .default(), and .change() should cover most use cases.
const createUserTemplate = (
userName: string,
isWorking: boolean,
status: string,
): MarkdownInlineBlock => {
return b.p`${userName}`
.if(isWorking)
.default("Unknown")
.change((block) => {
if (status === "active") return block.bold();
if (status === "inactive") return block.strikethrough();
return block;
});
};
expect(String(createUserTemplate("", true, "unknown"))).toBe("Unknown");
expect(String(createUserTemplate("John", false, "active"))).toBe("**Unknown**");
expect(String(createUserTemplate("John", true, "active"))).toBe("**John**");
expect(String(createUserTemplate("John", true, "inactive"))).toBe("~~John~~");
expect(String(createUserTemplate("John", true, "unknown"))).toBe("John");Keep typing as simple as possible
Types in bbmd have been designed carefully to avoid complexity. There is a three-tier hierarchy of types which will help keep your I/O extremely lean when embedding/returning bbmd blocks.
Tier 1 (all blocks): MarkdownBlock
└── Tier 2 (structural blocks): MarkdownInlineBlock | MarkdownLineBlock | MarkdownMultilineBlock
└── Tier 3 (concrete implementations): Specific Markdown blocks (MarkdownBoldBlock etc)As a general rule, use the highest level of specificity that it is convenient for a function to accept/return. For the most part, bbmd should type to tier 3 for you automatically, however if you need to declare type signatures yourself, it can be more convenient to duck down to the next lowest tier.
const createUserTemplate = (
userName: string,
status: string,
): MarkdownInlineBlock => {
// 👆 the inferred return type is
// MarkdownParagraphBlock | MarkdownBoldBlock | MarkdownStrikethroughBlock
// however, it was more convenient to explicitly type the return as MarkdownInlineBlock
return b.p(userName).change((block) => {
if (status === "active") return block.bold();
if (status === "inactive") return block.strikethrough();
return block;
});
};Set rendering options at call time
Because rendering a document walks the entire block structure, documents can adjust both structurally and stylistically to ensure they look good however they're embedded/shared.
Structural adjustments (like keeping footers at the bottom of the document) occur automatically for you, however styling adjustments are left to the root document to define.
You can override a variety of styling options, which are enforced on the entire document at render time.
// You can define a re-usable set of rendering options
const defaultRenderingOptions = b.renderingOptions({
enforce: {
bold: { style: "__" },
horizontalRule: { style: "*" },
unorderedListItem: { style: "+" },
list: { indent: 2 },
},
newlineStrategy: "between_blocks",
});
// Your actual documents can use inconsistent styling
const exampleDoc = b.md`
# Heading 1
This paragraph has some **bold** text. This __bold__ text uses inconsistent styling.
- This list
* Has different bullet styles
- Nested list with 4 tab indent
+ For each point
---
The document feels crammed at first.
But then includes lot's of newlines
...between every line.
`
.parse()
.setRenderingOptions(defaultRenderingOptions);
// But you can always enforce consistency and newline strategies at your final call-sites
console.log(String(exampleDoc));
// # Heading 1
//
// This paragraph has some __bold__ text. This __bold__ text uses inconsistent styling.
//
// + This list
// + Has different bullet styles
// + Nested list with 4 tab indent
// + For each point
//
// ***
//
// The document feels crammed at first.
//
// But then includes lot's of newlines
//
// ...between every line.