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

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

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.

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).

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'],
    },
  },
  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 strips the extend wrapper so your theme matches Uno’s expected shape if you need to share it elsewhere.
  • buildCssVarsBlock mirrors 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.).