@edgedev/template-engine

Lightweight template renderer for Edge CMS content.
13,152
Install
npm install @edgedev/template-engine
Latest Version:26.10.2
License:MIT
Last Updated:Oct 8, 2026
Links: npm
Maintainer: retrogrademt

@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-engine

Usage

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.

Publication markers

Use {{{#publication {"field":"publicationPages"} }}} with the page-image object in the owning block's values.publicationPages. Other field names remain supported. Keep PDF download URLs in a separate value, such as publicationPdfUrl.

The engine preserves the marker for the consumer's publication viewer and adds a URI-encoded JSON context containing { pages, effect? }. Viewers should decode it with JSON.parse(decodeURIComponent(config.context)) and use the bound values. Missing pages are explicitly null; they must not fall back to a parent post's images. Effects come from the owning field's metadata, or its schema for Template v2.

Nested renderBlocks, placeholder rendering, hydration, and repeated rendering preserve this context. Encoded values are data for the viewer, not HTML to insert directly.

For release rollout, publish the engine package, update each selected consumer's dependency and lockfile, and verify its viewer reads this context before deploying. An engine update alone does not implement the consumer viewer or migrate saved publication values. Verify direct, multiple nested, empty, and PDF-download cases in the consuming application.

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.
  • extraHtml is optional helper markup that should also contribute to UnoCSS generation (e.g. layout shells).
  • The returned css string is ready to inline or serve as critical CSS. Rendered blocks remain accessible individually if you need to target specific components.

To reuse data resolved while rendering a Template v2 block, enable includeHydratedValues. Source records are grouped by source name and returned with that block's hydration result:

const result = await pageRender([agentBlock], theme, '', {
  uniqueKey: 'org-123:site-456',
  includeHydratedValues: true,
  clientOptions,
})

const agents = result.hydratedValues?.[0]?.resolvedSources?.agents ?? []
const offices = result.hydratedValues?.[0]?.resolvedSources?.offices ?? []

Repeated and nested source() results are deduplicated within each block. The existing hydratedValues[].values field remains unchanged for legacy template values, and resolved sources are omitted unless includeHydratedValues is set.

Multi-result indexed Template v2 sources retain their legacy index-metadata behavior by default. Add canonical: true when the loop needs every full canonical record. The engine queries the index first, collects and deduplicates the matched canonical keys, loads those records through KV bulk reads, and preserves the index result order:

{{#for agent in source("agents", {
  queryItems: { hide: false },
  canonical: true
})}}
  <h2>{{ agent.name }}</h2>
{{/for}}

canonical: true resolves canonical objects after an indexed query. canonicalLookup: { key: ... } remains the direct-fetch form for templates that already have one or more canonical identifiers. A blank key or empty key array resolves to no records and never falls through to an unfiltered collection query. Nested canonical sources are prefetched recursively into the same render request cache.

The renderer supports:

  • Root-level and nested #array / #subarray blocks with aliases
  • Conditional #if / #else blocks
  • Simple blocks (#text, #image, #textarea, #richtext)
  • Async nested page rendering with #renderBlocks
  • Schema-aware formatting for number, integer, money, and richtext types using the provided meta
  • Automatic tel: href normalization (for both single and double-quoted href attributes). 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 of title)
  • 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:

  • field resolves 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 renderBlocks renders 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 renderBlocks and renderItem, so templates can use {{ renderBlocks.title }} or {{ renderItem.slug }}
  • Default target fields are content and structure. Override with contentField and structureField:
    • {{{#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 (default 2).
  • Applications that need to prepare blocks discovered through renderBlocks can provide hydrateOptions.prepareBlock to pageRender. The engine invokes that callback before hydrating each nested block and does not interpret application-specific route tokens itself.

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.
  • #array handles the root stats and team lists, while #subarray reaches into each member’s nested projects.
  • The schema on stats (object form) and team (array form) ensures value, budget, and bio pick up number/money/richtext formatting.
  • The conditional #if/#else block switches the badge copy based on item.isLead.
  • limit on #subarray trims 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:

  • hydrateValues reads any collection, queryItems, queryOptions, order, and limit settings from the template markup or meta overrides.
  • queryItems fan out to kvClient.queryIndex calls, combining responses and deduplicating by canonical.
  • When multiple queryItems are present, their index results are combined as a union (OR-style retrieval).
  • Runtime filtering and ordering are then applied in JS via collection.query and collection.order before writing the final array to values[field].
  • If the collection cannot be fetched, the resolver falls back to any inline value you provided or leaves an empty array.

Query Strategy (queryItems vs collection.query vs queryOptions)

Use this pattern for predictable behavior and good performance:

  1. Put your most selective indexed constraints in queryItems (the ones that get candidate count down fastest).
  2. Use collection.query for required final constraints (AND-style narrowing).
  3. Use collection.order for final sorting.
  4. Treat queryOptions as editor/config metadata for selectable filters; in current runtime, operators are applied through collection.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.tags does indexed candidate retrieval.
  • collection.uniqueKey scopes KV lookup for that array field.
  • collection.query narrows candidates to exactly the records you require.
  • collection.order sorts 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, and collection.uniqueKey are 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'],
    },
    borderRadius: { card: '16px', panel: '24px', button: '12px' },
    boxShadow: { card: '0 8px 24px rgba(0,0,0,0.12)' },
    fontSize: { body: ['18px', { lineHeight: '28px' }], label: '14px' },
  },
  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 tooling

Details:

  • unoCssFromHtml(html, theme) returns { css, hash }, producing minified output that already includes preflights and the custom font-family rules bundled here.
  • normalizeTheme preserves colors and the legacy fontFamily scale and maps CMS extend.fontFamily, extend.borderRadius, extend.boxShadow, and extend.fontSize to Wind4's font, radius, shadow, and text scales. Font families accept arrays or individual family names, with names containing spaces quoted consistently with the CSS variables. Font sizes accept a size string or [size, { lineHeight, letterSpacing? }]; [size, lineHeight] is also supported.
  • Every theme family supports font-<key> utilities, including responsive and hover variants such as md:font-display and hover:font-accent. Themes override built-in sans, serif, and mono families. Wind4's weight names (thin, extralight, light, normal, medium, semibold, bold, extrabold, black) are reserved for font weights; use ff-<key> for families with those names. Existing ff-<key> and font-brand utilities remain supported.
  • Theme tokens generate rounded-card (including side and corner variants such as rounded-t-card), shadow-card, and text-body utilities. Built-in scales retain their defaults unless the theme overrides a matching key.
  • buildCssVarsBlock mirrors the default preflight while letting you embed the CSS variables manually if desired. Alongside colors and font families it emits --radius-*, --shadow-*, --font-size-*, and optional --line-height-* variables. Wind4 also emits its own typography variables for text-* utilities.
  • 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.).

Related Packages

ey-template-vue3

vue3代码生成器

3,045
@ibiz-template/vue3

vue3代码生成器

2,984
vite-plugin-vue-meta-layouts

vite 的 vue-router 的元信息布局系统

314,627 65

Version History

Version Released License
26.10.2 MIT
26.10.1 MIT
26.9.3 MIT
26.9.2 MIT
26.9.1 MIT