@mertdogar/prompt-craft
v0.3.0
Published
Tiny, immutable, chainable Markdown builder for LLM prompts.
Maintainers
Readme
prompt-craft
Tiny, immutable, chainable Markdown builder for LLM prompts. Compose safe Markdown using a small fluent API.
Install
npm install @mertdogar/prompt-craftQuick start
import { P } from '@mertdogar/prompt-craft';
const doc = P.heading(2, 'Ticket Summary').append(
P.paragraph(
P.bold('Issue: ').append('Checkout fails on mobile.'),
P.bold('Owner: ').append('Mert Doğar'),
),
P.unorderedList([
'Repro on iOS 18 / Safari',
{ content: 'Only happens with campaign code', children: [
'Code applied via query param',
'Not reproducible with manual input',
P.bold('Tested on: ').append('iOS 18 / Safari')
]},
'No errors in Sentry',
P.codeBlock("console.log('Hello, world!')", 'ts'),
])
);
console.log(doc.render());CommonJS usage
const { P } = require('@mertdogar/prompt-craft');
const doc = P.heading(1, 'Hello').append(' world');
console.log(doc.render());API overview
- Creation:
P.from(x),P.empty(),P.concat(...),P.join(items, sep),P.t\template`` - Inline:
P.text(s),P.raw(s),P.safe(s),P.untrusted(s),P.space(),P.lineBreak(),P.newline(n),P.bold(x),P.italic(x),P.strike(x),P.codeInline(x),P.link(text, href) - Blocks:
P.heading(level, x),P.paragraph(...parts),P.blockquote(x),P.codeBlock(code, lang),P.horizontalRule(),P.xml({ tag, attrs?, content? }),P.section(name, body, { level? })— switches between Markdown heading and XML tag based onrender({ format }) - Lists:
P.unorderedList(items, opts),P.orderedList(items, opts) - Tables:
P.table(headers, rows, align) - Conditionals:
P.If({ condition, whenTrue, whenFalse }),P.Switch(value, branches) - Collections:
P.Map(items, (item, index) => node),P.fewShot(items, { render, n?, dedupe? }) - Messages & caching:
P.system(body),P.user(body),P.assistant(body),P.conversation(...messages),message.cache(),conversation.toMessages({ provider }) - Typed templates:
P.template('Hello {{name}}').with({ name })— slot names inferred at the type level; missing slot is a compile error - Strict template tag:
P.tStrict\Hello ${P.untrusted(input)}`` — refuses bare strings, forcing explicit trust on every interpolation - Schema rendering:
P.schema(zodSchema, { style?, name? })— renders a Zod schema as a TS type or JSON Schema in a fenced code block - Tool definitions:
P.tool({ name, description?, parameters? })andP.tools([...tools])— XML-tagged tool docs for instruction-following models - Fingerprinting:
P.fingerprint(p)/p.fingerprint()— stable 8-char hex hash of the rendered output, useful as a cache key or snapshot identifier - Strict whitespace:
P.strict(p)/p.strict()— strips trailing spaces, normalizes CRLF, collapses 3+ blank lines to 2 - Token budgeting:
P.priority(level, content)andP.budget({ maxTokens, tokenizer? }, ...items)— drops lowest-priority items until under the token budget
Messages and caching: P.system / P.user / P.assistant / P.conversation
Build a multi-message conversation and emit it in a provider-specific shape. Supports Anthropic (with prompt-cache breakpoints), OpenAI, Google Gemini, and the Vercel AI SDK CoreMessage shape.
import { P } from '@mertdogar/prompt-craft';
const convo = P.conversation(
P.system('You are a senior staff engineer.').cache(),
P.user('Please review this PR.'),
P.assistant('Sure — paste the diff.'),
);
// Anthropic: { system: [...], messages: [...] } with cache_control on cached blocks
const anthropic = convo.toMessages({ provider: 'anthropic' });
// OpenAI: flat messages array, no manual cache markers (server-side caching)
const openai = convo.toMessages({ provider: 'openai' });
// Gemini: { systemInstruction, contents } with assistant -> model
const gemini = convo.toMessages({ provider: 'gemini' });
// Vercel AI SDK CoreMessage: flat with providerOptions.anthropic.cacheControl
const core = convo.toMessages({ provider: 'core' });
// Debug-friendly XML serialization for snapshot tests
console.log(convo.render());message.cache() marks the end of a stable prefix. The renderer enforces Anthropic's 4-breakpoint cap (throws on more) and warns once when a cached prefix is below the 1024-token minimum. The default tokenizer is a chars/4 heuristic; pass { tokenizer: fn } to plug in your own.
The core provider returns ModelMessage[] directly from the Vercel AI SDK — ai is an optional peer dependency (>=5 <7). Install it only if you use toMessages({ provider: 'core' }); the rest of the library has zero runtime dependencies. The output is drop-in for generateText / streamText:
import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
const result = await generateText({
model: anthropic('claude-sonnet-4-5'),
messages: convo.toMessages({ provider: 'core' }),
});Schema rendering: P.schema
P.schema(zodSchema, opts?) renders a Zod schema as a prompt-friendly type description so the same schema can drive both validation and the model's output instructions — no drift. zod is an optional peer dependency (>=4 <5); install only if you use this helper.
import * as z from 'zod';
import { P } from '@mertdogar/prompt-craft';
const Output = z.object({
summary: z.string(),
sentiment: z.enum(['positive', 'neutral', 'negative']),
tags: z.array(z.string()),
});
P.schema(Output, { name: 'Output' }).render();
// => ```ts
// type Output = {
// summary: string;
// sentiment: "positive" | "neutral" | "negative";
// tags: Array<string>;
// }
// ```Pass { style: 'json' } for a JSON-Schema rendering instead. The walker is pure duck-typing on _zod.def, so zod is never imported at runtime — only the parameter type is borrowed via import type.
Untrusted input: P.untrusted and P.tStrict
For user-supplied input, P.safe is not enough — attackers can use zero-width characters, BiDi overrides, ANSI escapes, or Markdown image-exfil patterns () that survive a plain Markdown escape. P.untrusted(s) strips those vectors and escapes inline specials.
import { P } from '@mertdogar/prompt-craft';
const evil = '*pwn* \x1B[31mred\x1B[0m';
P.untrusted(evil).render();
// => *pwn* \red
// Recommended pattern: wrap in a logical fence so the LLM treats it as data.
P.xml({ tag: 'user_input', content: P.untrusted(evil) }).render();P.tStrict is a stricter variant of P.t that refuses bare string / number / boolean interpolations at compile time — every slot must be an explicit MDRenderable (P.text, P.safe, P.untrusted, etc.). Catches the recurring footgun of forgetting to wrap untrusted content.
const userInput: string = '*hi*';
P.tStrict`Body: ${P.untrusted(userInput)}`; // OK
// @ts-expect-error — bare string is rejected
P.tStrict`Body: ${userInput}`;Typed templates: P.template
P.template(source) returns a Template with slot names inferred from {{name}} markers in the source string. .with(values) substitutes each slot and returns a Prompt. Missing or unknown slots are compile-time errors.
import { P } from '@mertdogar/prompt-craft';
const t = P.template('Hello {{name}}, you have {{count}} messages.');
t.with({ name: 'Mert', count: 3 }).render();
// => Hello Mert, you have 3 messages.
// Slot values follow CLAUDE.md trust-by-default: strings pass through verbatim,
// Prompt nodes render through their own .render(), and P.safe() opts in to escaping.
t.with({ name: 'Mert', count: P.bold(3) }).render();
// => Hello Mert, you have **3** messages.const t = P.template('Hi {{name}}');
// @ts-expect-error — 'unknown' is not a declared slot
t.with({ name: 'a', unknown: 'b' });Tool definitions: P.tool / P.tools
For instruction-following models that need to reason about their tools (or any model where you want tool docs in-prompt), inject tool definitions with P.tool and bundle multiple with P.tools. The parameter schema flows through P.schema, so the same Zod object feeds both your real registry and the in-prompt docs.
import * as z from 'zod';
import { P } from '@mertdogar/prompt-craft';
const getWeather = P.tool({
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: z.object({
city: z.string(),
units: z.enum(['celsius', 'fahrenheit']).optional(),
}),
});
P.tools([getWeather, sendEmail]).render();
// <tools>
// <tool name="get_weather">
// <description>...</description>
// <parameters>
// ```ts
// { city: string; units?: "celsius" | "fahrenheit"; }
// ```
// </parameters>
// </tool>
// <tool name="send_email"> ... </tool>
// </tools>P.tools(...) self-closes when given an empty list and accepts both varargs and an array.
Few-shot examples: P.fewShot
A single render function enforces consistency across all examples — no drift between the formatting of example 1 and example 7. dedupe(item) collapses duplicates by key (first wins); n caps the count after deduping; items whose render returns empty are skipped silently.
import { P } from '@mertdogar/prompt-craft';
const examples = [
{ id: 'a', q: 'add 2+2', a: '4' },
{ id: 'b', q: 'sub 5-3', a: '2' },
{ id: 'a', q: 'add 2+2', a: '4' }, // duplicate — deduped
];
P.xml({
tag: 'examples',
content: P.fewShot(examples, {
render: (ex, i) => P.xml({
tag: 'example', attrs: { index: i + 1 },
content: P.concat(
P.paragraph(P.bold('Q: '), ex.q),
P.paragraph(P.bold('A: '), ex.a),
),
}),
dedupe: (ex) => ex.id,
n: 5,
}),
}).render();Semantic sections + render-mode switch: P.section
Most multi-section prompts can target Anthropic (XML-tagged) and OpenAI (Markdown-styled) from a single source if you use P.section(name, body) instead of bare P.heading + body. The active format is chosen at the top-level render({ format }) call and propagates to every nested section.
import { P } from '@mertdogar/prompt-craft';
const tree = P.concat(
P.section('Role', P.paragraph('You are a senior staff engineer.')),
P.section('Style', P.unorderedList(['Be concise.', 'Cite line numbers.'])),
);
tree.render();
// ## Role
//
// You are a senior staff engineer.
//
// ## Style
//
// - Be concise.
// - Cite line numbers.
tree.render({ format: 'xml' });
// <Role>
// You are a senior staff engineer.
// </Role>
//
// <Style>
// - Be concise.
// - Cite line numbers.
// </Style>Section names with whitespace become tag names with _ separators (e.g. "Style guide" → <Style_guide>).
Token budgeting: P.priority + P.budget
P.priority(level, content) tags content with a numeric priority that's invisible to readers and other helpers but visible to P.budget. P.budget({ maxTokens, tokenizer? }, ...items) renders all items, and if the total exceeds maxTokens it drops the lowest-priority items first until the budget is met. Bare items (no priority wrapper) are required and never dropped — if they alone exceed the budget, you get one console.warn.
import { P } from '@mertdogar/prompt-craft';
const ragChunks = [
{ score: 0.92, text: 'Foxes are in the dog family Canidae.' },
{ score: 0.71, text: 'The red fox is the largest true fox.' },
{ score: 0.55, text: 'Foxes live on every continent except Antarctica.' },
{ score: 0.34, text: 'Vulpine means anything fox-like.' },
];
P.budget(
{ maxTokens: 1000 /* default tokenizer is chars/4; pass tokenizer: for accuracy */ },
P.section('Question', userQuestion), // required
...ragChunks.map(c =>
P.priority(Math.round(c.score * 100), P.paragraph(c.text)) // droppable in score order
),
).render();The classic use case is a RAG prompt where the question is mandatory and retrieved chunks are tagged with their relevance score — chunks with low scores fall out first as the budget tightens.
Whitespace normalization: P.strict
Defensive cleanup for parsers and SSE consumers (where trailing whitespace measurably hurts model performance and \n\n collides with stream delimiters). Strips trailing spaces/tabs per line, normalizes CRLF to LF, and collapses 3+ blank lines to 2.
import { P } from '@mertdogar/prompt-craft';
P.strict('## Title \r\n\r\nbody \r\n\r\n\r\n\r\nfooter\t').render();
// "## Title\n\nbody\n\nfooter"
// Or fluently:
P.heading(2, 'Title').append(P.paragraph('body ')).strict().render();Fingerprinting: P.fingerprint
Stable 8-char hex hash (32-bit FNV-1a) of a prompt's rendered output. Useful as a cache key, snapshot identifier, or change-detection signal. Sync, dependency-free, and the same input always produces the same output.
import { P } from '@mertdogar/prompt-craft';
const a = P.heading(2, 'Hello').append(P.paragraph('World'));
a.fingerprint(); // "483a3c95"
P.fingerprint(a); // same
P.fingerprint('Hello world'); // also accepts pre-rendered stringsConditional rendering: P.If
Choose between two branches with a boolean or a function predicate. undefined is treated as false. whenTrue and whenFalse can be values, P nodes, or thunks that return them (evaluated lazily). then/else are still accepted for backward compatibility.
import { P } from '@mertdogar/prompt-craft';
const doc = P.If({
condition: true, // or () => boolean
whenTrue: P.heading(1, 'Hello'),
whenFalse: P.heading(2, 'World'),
});
console.log(doc.render());If whenFalse/else is omitted and the condition is false (or undefined), it yields empty output.
Switch statements: P.Switch
Choose between multiple branches based on a value. Returns the first matching case's content, or empty output if none match.
import { P } from '@mertdogar/prompt-craft';
const userType = 'admin';
const doc = P.Switch(userType, [
{ case: 'admin', content: P.heading(2, 'Admin Dashboard') },
{ case: 'user', content: P.heading(2, 'User Profile') },
{ case: 'guest', content: P.heading(2, 'Welcome') },
]);
console.log(doc.render());
// => ## Admin DashboardYou can also use function predicates for more complex matching:
const score = 85;
const doc = P.Switch(score, [
{ case: (n: number) => n >= 90, content: P.paragraph('Grade: A') },
{ case: (n: number) => n >= 80, content: P.paragraph('Grade: B') },
{ case: (n: number) => n >= 70, content: P.paragraph('Grade: C') },
{ case: (n: number) => n >= 60, content: P.paragraph('Grade: D') },
]);
console.log(doc.render());
// => Grade: BContent can be values, P nodes, or lazy functions that return them (evaluated only when matched).
Collections: P.Map
Map arrays into nodes and concatenate them.
import { P } from '@mertdogar/prompt-craft';
const array = [
{ title: 'Hello', description: 'World' },
{ title: 'Hello', description: 'World' },
];
const doc = P.Map(array, (item) => P.heading(3, item.title));
console.log(doc.render());
// =>
// ### Hello
//
// ### Hello
//- Instance chaining:
p.append(...),p.bold(),p.italic(),p.strike(),p.codeInline(),p.link(href),p.render()
Notes:
P.textescapes inline Markdown specials while allowing existing Markdown viaP.raw.- Headings, paragraphs, code blocks, lists, and quotes end with two newlines by default for LLM-friendly spacing.
XML blocks: P.xml
Block-level XML element with smart-trim layout, designed for structuring prompts to LLMs (especially Claude, where <instructions>, <context>, <example> are idiomatic). Attribute values are auto-escaped; body content is not.
import { P } from '@mertdogar/prompt-craft';
const doc = P.concat(
P.xml({ tag: 'instructions', content: 'Be concise.' }),
P.xml({
tag: 'examples',
content: P.concat(
P.xml({ tag: 'example', attrs: { index: 1 }, content: 'foo' }),
P.xml({ tag: 'example', attrs: { index: 2 }, content: 'bar' }),
),
}),
P.xml({ tag: 'scratchpad' }), // self-closes when content is missing
);
console.log(doc.render());Renders as:
<instructions>
Be concise.
</instructions>
<examples>
<example index="1">
foo
</example>
<example index="2">
bar
</example>
</examples>
<scratchpad />
Attribute values are escaped with the full XML 1.0 set (&, <, >, ", '). Pass null or undefined for an attribute value to skip it entirely; empty string is kept.
Custom blocks (extension)
Create your own helpers with P.extend(...):
import { P } from '@mertdogar/prompt-craft';
const MyP = P.extend({
callout(title: any, body: any) {
return this.concat(
this.heading(3, title),
this.blockquote(body)
);
},
});
const doc = MyP.callout('Note', 'Use responsibly.');
console.log(doc.render());You can also use a builder function if you prefer capturing the base:
const MyP = P.extend(Base => ({
warn(msg: any) {
return Base.paragraph(Base.bold('Warning: ').append(msg));
}
}));Examples
See files under examples/:
simple.tsheadings.tslists.tsblocks.tstemplate-tag.tstable.tsextend.tsmap.tsif.tsswitch.tsmessages-anthropic.tsmessages-openai.tsmessages-multi-provider.tstemplate.tssafety.tsschema.tstools.tsfewshot.tssection.tsbudget.tsfingerprint.tsstrict.ts
Run all examples:
npm run examplesTesting
npm i
npm testTypeScript
Ships with TypeScript types. P is an alias for Prompt.
License
GPL-3.0. See LICENSE. Repository: https://github.com/mertdogar/prompt-craft
