@edgedev/template-engine
v0.1.12
Published
Lightweight template renderer for Edge CMS content.
Downloads
928
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)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) - Schema-aware formatting for
number,integer,money, andrichtexttypes using the providedmeta
See src/index.ts for the full list of helpers that mimic the original Vue component.
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.- Each query item fans out to
kvClient.queryIndexcalls, combining responses and deduplicating bycanonical. - Filters (
queryOptions) and ordering rules execute in JS after the index lookups, and the final array is written back to the corresponding field in the returnedvalues. - If the collection cannot be fetched, the resolver falls back to any inline
valueyou provided or leaves an empty array.
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.).
