Markdown Structured Language — define typed schemas for markdown documents, then parse, validate, serialize, and generate LLM guidance from them.
import { z } from "zod";
import { document, frontmatter, section, prose, list } from "@rghenderson/mdsl";
const RecipeDoc = document({
meta: frontmatter(
z.object({
title: z.string(),
servings: z.number(),
}),
),
intro: section("Introduction", { body: prose() }),
ingredients: section("Ingredients", {
items: list(z.string()),
}),
});
const result = RecipeDoc.parse(markdownString);
// result.data.meta.title ← fully typed
// result.diagnostics ← errors with markdown line:col + JSON pathnpm install @rghenderson/mdsl zodmdsl lets you describe the structure of a markdown document using a schema, then use that schema to:
- Parse markdown → validated, fully-typed JSON model
- Serialize a JSON model → canonical markdown
- Validate a raw JSON object against the schema (no markdown needed)
- Generate JSON Schema for LLM structured output
- Generate authoring guidance, example documents, and fill-in templates for LLMs
Root builder. Returns an MdslDocument<T> with parse/serialize/LLM methods.
const Doc = document({ field: <node> });
type DocModel = InferDocument<typeof Doc>;Maps the YAML frontmatter block at the top of the document.
frontmatter(z.object({ title: z.string(), tags: z.array(z.string()).optional() }));Maps a heading + its content. Default depth is 2 (##).
section("Overview", { body: prose(), example: codeBlock("ts") });
section("Details", { notes: prose() }, 3); // ### DetailsCaptures free-form paragraph text within the current section.
Captures the document's primary # heading text.
title(); // # My Document → "My Document"Captures the first fenced code block, optionally filtered by language.
codeBlock("typescript"); // matches ```typescript blocks
codeBlock(); // matches any code blockCaptures all fenced code blocks in the section as a string[], optionally filtered by language. Useful for sections containing multiple examples.
codeBlocks("ts"); // ["const a = 1;", "const b = 2;"]
codeBlocks(); // all code blocks regardless of languageCaptures a markdown image () as { alt, url, title? }. The ImageValue type is exported for use in annotations.
diagram: image();
//  → { alt: "Architecture", url: "arch.png", title: "Figure 1" }Captures the first > … blockquote in the section as a markdown string.
Captures a list. Each item is validated against itemSchema. Pass { ordered: true } to match only numbered lists, or { ordered: false } to match only bullet lists.
list(z.string());
list(z.string(), { ordered: true }); // only matches 1. 2. 3. lists
list(z.object({ name: z.string(), qty: z.number() }));Shorthand for list(itemSchema, { ordered: true }).
orderedList(z.string()); // 1. First step\n2. Second stepCaptures a GFM table. Each row is validated against rowSchema using column headers as keys.
table(z.object({ nutrient: z.string(), amount: z.string() }));Collects every occurrence of a heading as an array of objects. Useful for repeating sections like changelog entries or sprint reports.
repeat("Sprint", { goal: prose(), tasks: list(z.string()) });
// ## Sprint … ## Sprint … → [{ goal, tasks }, { goal, tasks }]Options:
nameField— stores each section's heading text as a field on the item (required whenheadingis aRegExp)minItems— minimum occurrences required (default1); set to0for zero-or-more
// Named dynamic headings
repeat(/^Step \d+/, { body: prose() }, 2, { nameField: "title" });
// Zero-or-more optional sections
repeat("Note", { body: prose() }, 2, { minItems: 0 });Makes any node optional — returns undefined rather than emitting a diagnostic when not found.
optional(section("Notes", { body: prose() }));Like optional, but returns fallback when the node is missing.
withDefault(codeBlock("json"), "{}");Tries each node in order and returns the first that succeeds.
compose(codeBlock("typescript"), codeBlock("ts"), codeBlock());Note:
compose()always serializes using the first node in the list regardless of which branch matched during parsing. This is fine when all branches produce the same markdown structure (e.g. different language tags for the same code block), but may produce unexpected output when branches differ structurally.
Attaches a display name to any node for clearer diagnostics and LLM guidance.
rule("hero image", image());const result = Doc.parse(markdownString);
// result.data — validated model (typed), or null on failure
// result.diagnostics — Diagnostic[]
// result.raw — raw MDAST (escape hatch)
// Strict mode — throws MdslError if there are any errors
Doc.parse(markdownString, { strict: true });Each Diagnostic has:
{
severity: "error" | "warning",
message: string,
code: DiagnosticCode, // e.g. DiagnosticCodes.MISSING_SECTION
mdLocation: { line, column, offset },
jsonPath: string, // e.g. "ingredients.items[2]"
source?: "markdown" | "json",
mapping?: string,
hint?: string,
}import { formatDiagnostics } from "@rghenderson/mdsl";
console.log(formatDiagnostics(result.diagnostics));
// [ERROR] Line 14, col 0: Missing required section: "Ingredients" (json: ingredients, code: MISSING_SECTION)const markdown = Doc.serialize(result.data);Serialization is round-trip stable: parse(serialize(parse(md).data)) produces the same model.
Validate a plain JSON object without parsing markdown:
const result = Doc.validate(jsonObject);Doc.toJsonSchema(); // JSON Schema for structured output
Doc.toLlmJsonSchema(); // JSON Schema with mapping hints in descriptions
Doc.toGuidance(); // Markdown guide describing the document structure
Doc.toExampleMarkdown(); // A generated example document
Doc.toMarkdownTemplate(); // A fill-in-the-blanks template
Doc.toLlmGuide(); // Bundle: all of the above + mappingHints + instructionsnpm install -g @rghenderson/mdsl # or use npx
mdsl parse ./recipe.js ./doc.md
mdsl validate ./recipe.js ./doc.json
mdsl serialize ./recipe.js ./doc.json --out ./doc.mdDefinition modules must export a compiled .js MdslDocument. See docs/cli.md.
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import { remarkMdsl } from "@rghenderson/mdsl/remark";
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMdsl, { document: RecipeDoc, strict: true })
.process(markdown);
file.data.mdsl?.data; // ParseResult
file.data.mdsl?.diagnostics;Use a registry to detect which schema a document belongs to and parse it in one step.
import { createRegistry } from "@rghenderson/mdsl";
const registry = createRegistry();
registry.register(RecipeDoc, (ctx) => ctx.frontmatter?.["type"] === "recipe");
registry.register(BlogDoc, (ctx) => ctx.frontmatter?.["type"] === "blog");
const result = registry.detect(markdownString); // { schema, frontmatter } | null
const parsed = registry.parse(markdownString); // { schema, data, diagnostics, raw } | nullDetection order: frontmatter type field → frontmatter $schema field → your custom matcher → structural heuristics.
import type { InferDocument } from "@rghenderson/mdsl";
type Recipe = InferDocument<typeof RecipeDoc>;
// { meta: { title: string; servings: number }, intro: { body: string }, ingredients: { items: string[] } }MIT