@prompteng/core
v0.1.0
Published
Core package for PromptEng toolkit
Maintainers
Readme
@prompteng/core
PromptEng Core is a type-safe, testable prompt templating and rendering engine powered by LiquidJS. It is designed for safe user-authored templates, strong extensibility (custom tags/filters), and first-class testing with Vitest.
- Safe, logic-light templating via LiquidJS
- Custom tags/filters in TypeScript (e.g., sections, constraints)
- Multi-output rendering (e.g.,
{ system, prompt }) using{% section %} - Simple prompt tests (
.ptest) with contains/not_contains assertions - Type generation from template frontmatter
Install
npm i @prompteng/core
# or
pnpm add @prompteng/coreRequires Node 18+.
Cloudflare Workers / Edge Runtimes
Use the worker-friendly engine that reads .ptemplate files from Cloudflare Static Assets. The DX is the same as Node: you pass a directory path.
Minimal Wrangler setup
Place your templates under public/prompts/templates/ and configure assets in wrangler.toml:
# wrangler.toml
name = "your-worker"
main = "src/index.ts"
compatibility_date = "2024-06-01"
[assets]
directory = "public"Folder layout:
public/
prompts/
templates/
with-system.ptemplate
prompteng.manifest.jsonHono + Cloudflare Workers (recommended integration pattern)
import { Hono } from 'hono';
import { WorkerEngine, registerCloudflareAssets } from '@prompteng/core/worker';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
type Bindings = {
ASSETS: { fetch: (r: Request) => Promise<Response> };
OPENAI_API_KEY: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// Middleware: register assets/manifest and attach engine to context
app.use('*', async (c, next) => {
registerCloudflareAssets(c.env.ASSETS, (globalThis as any).__STATIC_CONTENT_MANIFEST);
const engine = new WorkerEngine('/prompts/templates');
c.set('prompteng', engine);
await next();
});
// Example endpoint: render sections and call AI SDK
app.get('/some-endpoint', async (c) => {
const engine = c.get('prompteng') as WorkerEngine;
const sections = await engine.renderMulti('with-system', { name: 'Bob' });
const openai = createOpenAI({ apiKey: c.env.OPENAI_API_KEY });
const { text } = await generateText({
model: openai('gpt-4o-mini'),
system: sections.system,
prompt: sections.prompt,
});
return c.json({ res: text });
});
export default app;Notes:
- Templates are NOT public by default. They’re accessible to your Worker code via
env.ASSETS. Don’t proxy user requests toASSETS.fetchunless you intend to expose assets. - If Wrangler doesn’t expose
__STATIC_CONTENT_MANIFEST(local dev), createprompteng.manifest.jsonas shown above so the engine can enumerate your.ptemplatefiles. - Optional: if you don’t use CF assets (tests, special runtimes), you can register a virtual FS:
import { WorkerEngine, registerVfs } from '@prompteng/core/worker';
registerVfs({
'/prompts/templates/greet.ptemplate': `---\nname: greet\nvariables: []\n---\nHello!`
});
const engine = new WorkerEngine('/prompts/templates');If you prefer to access __STATIC_CONTENT directly (e.g., in production workers), use registerStaticContent(env.__STATIC_CONTENT, __STATIC_CONTENT_MANIFEST) instead of registerCloudflareAssets.
Local Hono (Node) example
examples/hono-basic/shows how to usePromptEnginein a standard Hono app running under Node.examples/worker-basic/andexamples/worker-hono/demonstrate Cloudflare Workers setups.
Templates (.ptemplate)
Each template file uses YAML frontmatter for metadata and variables, followed by Liquid content. You can define multiple named outputs using the section block.
---
name: "email-welcome"
description: "Welcome email generator"
variables:
- name: recipientName
type: string
required: true
- name: productName
type: string
required: true
---
{% section 'system' %}
You are a helpful assistant writing concise, friendly emails.
{% endsection %}
{% section 'prompt' %}
Write a short welcome email for {{ recipientName }} to introduce {{ productName }}.
End with a friendly call-to-action.
{% endsection %}Tests (.ptest)
Prompt tests are YAML files that reference a template by name and define test cases. Assertions validate the final rendered output returned by the provider.
template: "email-welcome"
description: "Welcome email basic test"
providers:
- name: "mock"
model: "mock-1"
test_cases:
- name: "Simple welcome"
variables:
recipientName: "Ada"
productName: "Acme"
assertions:
- contains: "Acme"
- not_contains: "unsubscribe"Usage
Render a single output string:
import { PromptEngine } from '@prompteng/core';
const engine = new PromptEngine('prompts/templates');
const text = await engine.render('email-welcome', {
recipientName: 'Ada',
productName: 'Acme'
});Render multiple sections with metadata (e.g., { system, prompt }):
const { sections } = await engine.renderWithMeta('email-welcome', {
recipientName: 'Ada',
productName: 'Acme'
});
// sections.system, sections.promptOr directly get a name->text map:
const out = await engine.renderMulti('email-welcome', { recipientName: 'Ada', productName: 'Acme' });
// { system: string, prompt: string }Which API to use
- Use
engine.render(name, vars)when your template does not define sections or you only need the combined text output. - Use
engine.renderWithMeta(name, vars)when you need both the combined text and metadata:- returns
{ text, sections, constraints } sectionsis a map of named outputs captured via{% section %}...{% endsection %}constraintscontains structured info for testing/validation
- returns
- Use
engine.renderMulti(name, vars)when your caller expects a map of sections and you don't need constraints:- always returns a map and falls back to
{ prompt: text }if no sections are present.
- always returns a map and falls back to
Templating guide (self‑contained)
PromptEng uses a Liquid-compatible syntax, but you don’t need the Liquid docs to get started. This guide summarizes what you’ll use 90% of the time when writing .ptemplate files.
Cheatsheet
{{ variable }} # print a variable
{{ var | default: 'fallback' }} # filters mutate output
{%- assign x = 1 -%} # assign a value (whitespace-trimmed)
{%- capture buf -%}...{%- endcapture -%} # capture rendered text into a variable
{% if cond %}...{% elsif other %}...{% else %}...{% endif %}
{% case kind %}{% when 'a' %}...{% when 'b' %}...{% else %}...{% endcase %}
{% for item in list %}{{ forloop.index }}. {{ item }}{% endfor %}
{% section 'name' %}...{% endsection %} # capture a named output (e.g., system, prompt)
{% render 'partial.liquid' %} # include a partial file (optional)Whitespace control: use -{% and %}- (or {{-/-}}) to trim spaces/newlines around tags.
Variables and filters
Hello, {{ userName | default: 'friend' }}!
You picked: {{ items | uniq | join: ', ' }}
Count: {{ items | length }}
Lower: {{ brand | lower }} Upper: {{ brand | upper }}Built‑in helper filters (shipped by PromptEng):
join,uniq,default,length,lower,upper,compact,sort
Control flow
{% if plan == 'pro' %}
Thanks for being a Pro user!
{% elsif plan == 'free' %}
You’re on the free plan.
{% else %}
Welcome!
{% endif %}
{% case locale %}
{% when 'en' %}Hello
{% when 'es' %}Hola
{% else %}Hi
{% endcase %}assign and capture
{%- assign tone = tone | default: 'friendly' -%}
{%- capture style -%}
{% case tone %}
{% when 'friendly' %}Warm and concise.
{% when 'professional' %}Precise and respectful.
{% else %}Neutral.
{% endcase %}
{%- endcapture -%}
Style: {{ style | strip }}Loops and helpers
{% for w in words %}
{{ forloop.index }}. {{ w }}
{% endfor %}
{%- assign top3 = words | uniq | sort -%}
Top picks: {{ top3 | join: ', ' }}Partials (optional)
If you organize fragments into partial files, you can include them:
{% render 'partials/header.liquid' %}Sections (multi‑output)
Use sections to produce multiple named outputs in one render (e.g., system, prompt). They do not emit inline text; the engine captures their content.
{% section 'system' %}
System instruction here.
{% endsection %}
{% section 'prompt' %}
User‑facing prompt here.
{% endsection %}Read them back with renderWithMeta() or renderMulti().
Constraint tags
PromptEng includes an example constraint tag you can use and extend:
{% must_include_each words %}- Adds a clear instruction for the model.
- Records the requirement in metadata so test tools can validate later.
Example:
{% must_include_each primaryTerms %}Custom filters and tags
Register filters:
engine.registerFilter('truncate_words', (s: unknown, n = 12) => {
const words = String(s ?? '').split(/\s+/);
return words.length <= n ? words.join(' ') : words.slice(0, n).join(' ') + '…';
});Register class‑based tags (extends Tag):
import { Liquid, Tag, TagToken, TopLevelToken, Context, Value } from 'liquidjs';
class UpperTag extends Tag {
private value: Value;
constructor(token: TagToken, remain: TopLevelToken[], liquid: Liquid) {
super(token, remain, liquid);
this.value = new Value(token.args, liquid);
}
*render(ctx: Context) {
const v = yield this.value.value(ctx);
return String(v).toUpperCase();
}
}
engine.registerTag('upper', UpperTag);Quick Reference
Variables
- Print:
{{ name }} - With default:
{{ name | default: 'N/A' }} - Assign:
{% assign x = 1 %}(use{%-/-%}to trim whitespace) - Capture:
{% capture buf %}...{% endcapture %}
- Print:
Control flow
- If:
{% if cond %}...{% elsif other %}...{% else %}...{% endif %} - Case:
{% case kind %}{% when 'a' %}...{% else %}...{% endcase %} - Loop:
{% for it in list %}{{ forloop.index }}. {{ it }}{% endfor %}
- If:
Sections (multi-output)
{% section 'system' %}...{% endsection %}- Read via
engine.renderWithMeta()orengine.renderMulti().
Built-in helper filters
default,join,uniq,length,lower,upper,compact,sort
Constraints (example)
{% must_include_each words %}— instructs and records metadata for tests.
Partials (optional)
{% render 'partials/header.liquid' %}
Cookbook
1) Tone/style switch with assign + capture + case/when
{%- assign tone = tone | default: 'friendly' -%}
{%- capture style -%}
{% case tone %}
{% when 'friendly' %}Warm and concise.
{% when 'professional' %}Precise and respectful.
{% else %}Neutral.
{% endcase %}
{%- endcapture -%}
Style: {{ style | strip }}2) Multi-output prompts (system + prompt)
{% section 'system' %}
You are a helpful assistant.
{% endsection %}
{% section 'prompt' %}
Write a short note for {{ userName | default: 'friend' }}.
{% endsection %}const out = await engine.renderMulti('template-name', { userName: 'Ada' });
// out.system, out.prompt3) List processing (uniq, sort, join)
{%- assign picks = terms | default: [] | uniq | sort -%}
Use these: {{ picks | join: ', ' }}4) Partials
{% render 'partials/header.liquid' %}
Main content...
{% render 'partials/footer.liquid' %}5) Simple .ptest for contains/not_contains
template: "email-welcome"
description: "Smoke test"
providers:
- name: "mock"
model: "mock-1"
test_cases:
- name: "Basic"
variables: { recipientName: "Ada", productName: "Acme" }
assertions:
- contains: "Acme"
- not_contains: "unsubscribe"Generate Types
From template frontmatter, PromptEng can generate TypeScript types for variables:
import { PromptEngine } from '@prompteng/core';
const engine = new PromptEngine('prompts/templates');
const dts = engine.generateTypeDefinitions();Testing (Vitest)
pnpm -F @prompteng/core testInternally, tests use a TestRunner that:
- Loads
.ptestfiles - Renders templates with a provider (mock or real)
- Validates
containsandnot_containsassertions
Publishing
This repo uses a unified CI & Release workflow with Changesets (see .github/workflows/ci-release.yml). When a Changesets “Version Packages” PR is merged into main, the pipeline will:
- Install deps, build the workspace, and run tests (Vitest)
- Version packages and generate changelogs via Changesets
- Publish updated packages to npm (
@prompteng/core,@prompteng/cli, etc.)
Setup required:
- Add
NPM_TOKENas a repository secret for publishing.
Developer flow:
- Run
pnpm changesetto create a changeset describing your changes and bump type. - Commit and push; CI opens/updates a “Version Packages” PR.
- Merge that PR to
mainto publish to npm.
License
MIT
