@edgedev/template-engine
v0.2.52
Published
Lightweight template renderer for Edge CMS content.
Readme
@edgedev/template-engine
Utility function with rendering logic. Feed it content, values, and meta data and receive HTML you can drop into any Vue (or non-Vue) surface.
Install
npm install @edgedev/template-engineUsage
import renderTemplate from '@edgedev/template-engine'
const html = renderTemplate(contentString, valueMap, metaDefinition)renderTemplate remains synchronous for backward compatibility.
import { renderTemplateAsync } from '@edgedev/template-engine'
const html = await renderTemplateAsync(contentString, valueMap, metaDefinition, {
maxDepth: 2, // optional, default is 2
})Treat valueMap and metaDefinition as override objects: they selectively replace values/meta defined within the template, and any omitted parameter (or key) falls back to the template's embedded defaults.
Full Page Rendering
Use pageRender when you need to hydrate multiple blocks, render their HTML, and collect the UnoCSS used across the entire page.
import { pageRender } from '@edgedev/template-engine'
const theme = {
extend: {
colors: { brand: '#2563eb' },
fontFamily: { brand: ['Inter', 'sans-serif'] },
},
}
const { blocks, css } = await pageRender(
[
{
name: 'hero',
content: '<section class="bg-brand text-white">{{{#text {"field":"headline"} }}}</section>',
values: { headline: 'Launch in days, not months.' },
},
{
name: 'listing',
content: `
<ul>
{{{#array {"field":"items","as":"item"} }}}
<li class="flex gap-2">
<span class="font-semibold">{{ item.title }}</span>
<span class="text-sm text-gray-500">{{ item.subtitle }}</span>
</li>
{{{/array}}}
</ul>
`,
meta: {
items: { schema: { title: 'text', subtitle: 'text' } },
},
},
],
theme,
'<div class="hidden md:block fixed inset-0 pointer-events-none radial-gradient-mask"></div>',
{
uniqueKey: 'org-123:site-456',
clientOptions: {
binding: env?.MY_INDEX_KV,
accountId: process.env.CF_ACCOUNT_ID!,
namespaceId: process.env.CF_NAMESPACE_ID!,
apiToken: process.env.CF_API_TOKEN!,
},
},
)
// blocks => [{ name: 'hero', html: '<section ...>...</section>' }, ...]
// css => aggregated UnoCSS needed for all rendered HTML + extraHtml- Each block is hydrated first using the provided hydrate options, then rendered via
renderTemplate. extraHtmlis optional helper markup that should also contribute to UnoCSS generation (e.g. layout shells).- The returned
cssstring is ready to inline or serve as critical CSS. Rendered blocks remain accessible individually if you need to target specific components.
The renderer supports:
- Root-level and nested
#array/#subarrayblocks with aliases - Conditional
#if/#elseblocks - Simple blocks (
#text,#image,#textarea,#richtext) - Async nested page rendering with
#renderBlocks - Schema-aware formatting for
number,integer,money, andrichtexttypes using the providedmeta - Automatic
tel:href normalization (for both single and double-quotedhrefattributes). Example:href="tel:{{item.contactPhone}}"is rendered as a dial-safe E.164-style value when possible. - Inline value formatters directly in
{{ ... }}placeholders (for dates and common string transforms).
See src/index.ts for the full list of helpers that mimic the original Vue component.
Inline Formatters
You can now format values directly where they are used:
{{ date(post.publishDate) }}
{{ datetime(post.publishDate, "short") }}
{{ money(post.budget) }}
{{ lower(menuItem.menuTitle) }}
{{ trim(site.tagline) }}
{{ slug(post.title) }}
{{ title(post.slug) }}
{{ default(post.summary, "Summary coming soon") }}Supported formatter names:
date(value, options?)datetime(value, options?)money(value, options?)lower(value)upper(value)trim(value)slug(value)title(value)(slug/text to Title Case)deslug(value)(alias oftitle)default(value, fallback)
Notes:
- Existing schema/meta formatting (
number,money,richtext, etc.) still works unchanged. - Inline formatter output is HTML-escaped by default (same safety behavior as normal text placeholders).
Basic Examples
<h2>{{ upper(post.title) }}</h2>
<p>Published {{ date(post.publishDate, "long") }}</p>
<p>Author handle: {{ slug(post.authorName) }}</p>
<p>Slug label: {{ title(post.slug) }}</p>
<p>Budget: {{ money(post.budget) }}</p>
<p>{{ default(post.summary, "No summary available.") }}</p>Complex Examples
date(...), datetime(...), and money(...) accept options objects:
<time datetime="{{ post.publishDate }}">
{{ datetime(post.publishDate, {
locale: "en-US",
dateStyle: "full",
timeStyle: "short"
}) }}
</time>
<p>
{{ money(post.budget, {
locale: "en-US",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2
}) }}
</p>Works in array/subarray scopes too:
{{{#array {"field":"events","as":"event"} }}}
<article>
<h3>{{ trim(event.title) }}</h3>
<p>{{ date(event.startAt, { locale: "en-US", month: "long", day: "numeric", year: "numeric" }) }}</p>
<a href="/events/{{ slug(event.title) }}">Read more</a>
</article>
{{{/array}}}Nested Blocks with #renderBlocks
Use #renderBlocks with renderTemplateAsync to render nested page-like payloads.
import { renderTemplateAsync } from '@edgedev/template-engine'
const content = `
{{{#array {"field":"posts","as":"post"} }}}
{{{#renderBlocks {"field":"post.page"} }}}
{{{/array}}}
`
const values = {
posts: [
{
page: {
content: [
{
content: '<article><h2>{{{#text {"field":"title"} }}}</h2></article>',
values: { title: 'Nested Post' },
},
],
structure: [],
},
},
],
}
const html = await renderTemplateAsync(content, values)Behavior:
fieldresolves from the current scope first, then falls back to root values.- In array scope, all of these are supported:
item,item.somekey,post,post.somekey. - Nested
renderBlocksrenders inject the selected target item into each nested block's values:- Item keys are available as top-level fields for helpers like
{{{#text {"field":"title"} }}} - The full item is also available as
renderBlocksandrenderItem, so templates can use{{ renderBlocks.title }}or{{ renderItem.slug }}
- Item keys are available as top-level fields for helpers like
- Default target fields are
contentandstructure. Override withcontentFieldandstructureField:{{{#renderBlocks {"field":"item.pageData","contentField":"blocks","structureField":"layout"} }}}
- If the resolved target is an array, each target entry is rendered and concatenated.
- Missing/invalid targets return an empty string.
- Recursion is guarded with
maxDepth(default2).
Comprehensive Example
const content = `
<section class="playbook">
<header>
<h1>{{{#text {"field":"title"} }}}</h1>
<p>{{{#textarea {"field":"summary"} }}}</p>
<img :src="{{{#image {"field":"heroImage"} }}}" alt="Hero" />
{{{#richtext {"field":"body"} }}}
</header>
<div class="cta">
<a href="{{{ {"field":"heroHref","value":"https://edge.co"} }}}">{{{ {"field":"heroCta","type":"text"} }}}</a>
</div>
<ul class="stats">
{{{#array {"field":"stats","as":"stat"} }}}
<li>
<span class="label">{{ stat.label }}</span>
<span class="value">{{ stat.value }}</span>
</li>
{{{/array}}}
</ul>
<section class="team">
{{{#array {"field":"team","as":"member","schema":{"budget":"money"}} }}}
<article>
<h3>{{ member.name }}</h3>
<p>{{ member.role }}</p>
{{{#if {"cond":"item.isLead == true"} }}}
<span class="badge">Lead</span>
{{{#else}}}
<span class="badge muted">Contributor</span>
{{{/if}}}
<p class="bio">{{ member.bio }}</p>
<div>Annual budget: {{ member.budget }}</div>
<ul>
{{{#subarray:project {"field":"item.projects","limit":2} }}}
<li>
<strong>{{ project.name }}</strong>
<span>{{ project.status }}</span>
</li>
{{{/subarray}}}
</ul>
</article>
{{{/array}}}
</section>
</section>
`
const values = {
title: 'Q4 Launch Playbook',
summary: 'Tactics and timelines for the winter product drop.',
heroImage: 'https://cdn.edge.co/playbook-hero.png',
heroHref: 'https://edge.co/campaigns/playbook',
heroCta: 'View Campaign Timeline',
body: '<p><strong>Note:</strong> This block accepts raw HTML.</p>',
stats: [
{ label: 'Net-new leads', value: 3400 },
{ label: 'Pipeline ($)', value: 1750000 },
],
team: [
{
name: 'Mia Chen',
role: 'Program Manager',
isLead: true,
budget: 125000,
bio: 'Owns launch roadmap & GTM alignment.',
projects: [
{ name: 'Hubble', status: 'Active' },
{ name: 'Lumen', status: 'Planning' },
],
},
{
name: 'Jonas Patel',
role: 'Growth Engineer',
isLead: false,
budget: 60000,
bio: '<em>Automates activation experiments.</em>',
projects: [
{ name: 'Arcade', status: 'QA' },
],
},
],
}
const meta = {
stats: {
// object-based schema (field -> type)
schema: {
value: 'number',
},
},
team: {
// array-based schema (common on older Edge configs)
schema: [
{ field: 'budget', type: 'money' },
{ field: 'bio', type: 'richtext' },
],
},
}
const html = renderTemplate(content, values, meta)Key behaviors demonstrated:
{{{#text}}},{{{#textarea}}},{{{#image}}}, and{{{#richtext}}}show the simple block helpers.{{ {"field":"...","type":"text"} }}illustrates double-brace placeholders with type-aware escaping.#arrayhandles the rootstatsandteamlists, while#subarrayreaches into each member’s nestedprojects.- The
schemaonstats(object form) andteam(array form) ensuresvalue,budget, andbiopick up number/money/richtext formatting. - The conditional
#if/#elseblock switches the badge copy based onitem.isLead. limiton#subarraytrims the nested list to the first two entries.
Cloudflare KV Index Client
The package also exposes a helper for working with Cloudflare KV index namespaces. Initialize it once with your credentials (and optional Worker KV binding) and reuse the client to run prefix queries.
import { createKvIndexClient } from '@edgedev/template-engine'
const kvClient = createKvIndexClient({
// Optional: the Workers KV binding when running inside a Worker / Pages Function.
binding: env?.MY_INDEX_KV,
// Required: account + namespace identifiers for the REST fallback.
accountId: process.env.CF_ACCOUNT_ID!,
namespaceId: process.env.CF_NAMESPACE_ID!,
apiToken: process.env.CF_API_TOKEN!,
// Optional: custom fetch (defaults to globalThis.fetch).
fetch: globalThis.fetch,
})
// Example 1: expect at most one canonical match.
const [post] = await kvClient.queryIndex({
baseKey: 'post', // the client automatically prefixes keys with "idx:"
searchKey: 'slug',
uniqueKey: 'user-34329473094',
searchValue: 'blog:dasfadsfasd',
})
// Example 2: gather multiple raw index hits (no canonical pointer needed).
const relatedPosts = await kvClient.queryIndex({
baseKey: 'post',
searchKey: 'tag',
uniqueKey: 'user-34329473094',
searchValue: ['launch', 'beta'], // arrays fan out to multiple prefix searches
})
// `post` is the canonical object (when exactly one match is found).
// `relatedPosts` is an array of parsed index meta objects (no manual JSON.parse needed).
// Fetch an exact key if you already know the canonical identifier.
const canonical = await kvClient.getKey('post:canonical:user-34329473094')
// -> returns an object (or null) and emits console.warn indicating whether the binding or API was used.Hydrating Collections from KV
Templates can declare #array blocks that describe KV-backed collections. Use hydrateValues to resolve those collections before rendering.
import {
hydrateValues,
renderTemplate,
type HydrateValuesOptions,
} from '@edgedev/template-engine'
const options: HydrateValuesOptions = {
content: contentString,
values: initialValueOverrides,
meta: metaDefinitionOverride,
uniqueKey: 'workspace-1234', // the namespace segment used when indexing
clientOptions: {
// Optional binding (Workers runtime)
binding: env?.MY_INDEX_KV,
// REST fallback credentials (e.g. during SSR/build)
accountId: process.env.CF_ACCOUNT_ID!,
namespaceId: process.env.CF_NAMESPACE_ID!,
apiToken: process.env.CF_API_TOKEN!,
fetch: globalThis.fetch,
},
}
const hydratedValues = await hydrateValues(options)
const html = renderTemplate(contentString, hydratedValues, metaDefinitionOverride)Key details:
hydrateValuesreads anycollection,queryItems,queryOptions,order, andlimitsettings from the template markup or meta overrides.queryItemsfan out tokvClient.queryIndexcalls, combining responses and deduplicating bycanonical.- When multiple
queryItemsare present, their index results are combined as a union (OR-style retrieval). - Runtime filtering and ordering are then applied in JS via
collection.queryandcollection.orderbefore writing the final array tovalues[field]. - If the collection cannot be fetched, the resolver falls back to any inline
valueyou provided or leaves an empty array.
Query Strategy (queryItems vs collection.query vs queryOptions)
Use this pattern for predictable behavior and good performance:
- Put your most selective indexed constraints in
queryItems(the ones that get candidate count down fastest). - Use
collection.queryfor required final constraints (AND-style narrowing). - Use
collection.orderfor final sorting. - Treat
queryOptionsas editor/config metadata for selectable filters; in current runtime, operators are applied throughcollection.query.
Example:
{
"field": "eventsList",
"collection": {
"path": "posts",
"uniqueKey": "{orgId}:{siteId}",
"query": [
{ "field": "type", "operator": "==", "value": "event" },
{ "field": "event.isPast", "operator": "==", "value": true }
],
"order": [{ "field": "event.startAt", "direction": "asc" }]
},
"queryItems": {
"tags": ["program-spotlight"]
}
}In that setup:
queryItems.tagsdoes indexed candidate retrieval.collection.uniqueKeyscopes KV lookup for that array field.collection.querynarrows candidates to exactly the records you require.collection.ordersorts the narrowed set.
If you already know the canonical KV key, you can bypass indexed lookup entirely:
{
"field": "eventsList",
"collection": {
"canonicalLookup": {
"key": "posts:org:site:post-7"
}
}
}When collection.canonicalLookup.key is present:
- the engine fetches that KV key directly
queryItems,collection.query,collection.order, andcollection.uniqueKeyare ignored- the result is still written back as an array containing the single resolved record
UnoCSS SSR Helpers
When your rendered HTML relies on UnoCSS utilities, the package exposes helpers to generate the exact CSS during SSR or at build time.
import {
unoCssFromHtml,
normalizeTheme,
buildCssVarsBlock,
} from '@edgedev/template-engine'
const theme = {
extend: {
colors: {
brand: '#2563eb',
},
fontFamily: {
brand: ['Inter', 'sans-serif'],
},
},
variants: {
dark: {
extend: {
colors: {
brand: '#3b82f6',
},
},
},
},
}
const { css, hash } = await unoCssFromHtml(renderedHtml, theme)
// Inject the CSS into your HTML shell, or cache it under the returned hash.
const cssVarsBlock = buildCssVarsBlock(theme) // optional: inline preflight variables yourself
const unoTheme = normalizeTheme(theme) // optional: feed into other Uno-powered toolingDetails:
unoCssFromHtml(html, theme)returns{ css, hash }, producing minified output that already includes preflights and the custom font-family rules bundled here.normalizeThemestrips theextendwrapper so your theme matches Uno’s expected shape if you need to share it elsewhere.buildCssVarsBlockmirrors the default preflight while letting you embed the CSS variables manually if desired.- The generator is cached per theme hash, and transformer dependencies are loaded lazily; ensure your runtime supports dynamic
import()(Node 18+, modern bundlers, Workers, etc.).
