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

@thtml/core

v0.1.1

Published

Core HTML template engine for thtml

Readme

@thtml/core

npm version license tests

Core compiler and runtime for the thtml HTML template engine.

Compiles .thtml source strings (or pre-parsed ASTs) into reusable, type-safe render functions. Zero runtime dependencies.

Installation

npm install @thtml/core
# or
pnpm add @thtml/core
# or
yarn add @thtml/core

Requires Node.js 18+. Ships both ESM (dist/index.js) and CJS (dist/index.cjs).

Quick Start

import { compile } from '@thtml/core';

interface Context {
  title: string;
  user: { name: string; role: string };
  items: string[];
}

const template = compile<Context>(`
  <h1>{{ title }}</h1>
  <p>Logged in as <strong>{{ user.name }}</strong> ({{ user.role }})</p>
  {% if items.length %}
    <ul>
      {% for item of items %}
        <li>{{ item }}</li>
      {% endfor %}
    </ul>
  {% else %}
    <p>No items found.</p>
  {% endif %}
`);

const html = template.render({
  title: 'Dashboard',
  user: { name: 'Alice', role: 'admin' },
  items: ['Reports', 'Settings', 'Users'],
});

console.log(html);
// <h1>Dashboard</h1>
// <p>Logged in as <strong>Alice</strong> (admin)</p>
// <ul><li>Reports</li><li>Settings</li><li>Users</li></ul>

Template Syntax

Frontmatter

Declare the TypeScript type of your template context between --- delimiters at the top of the file. The VS Code extension uses this block to provide autocomplete and type checking.

---
interface Context {
  pageTitle: string;
  user: {
    name: string;
    isAdmin: boolean;
  };
  tags: string[];
}
---
<title>{{ pageTitle }}</title>
<p>Hello, {{ user.name }}!</p>

Frontmatter is TypeScript — you can define multiple interfaces, type aliases, or import types (when supported by your loader).

When you compile with compile<Context>(source) in your application code, TypeScript validates the render() call against your declared type. When you open the .thtml file in VS Code, the extension reads the same frontmatter to power IntelliSense.

Expressions: {{ expr }}

Output any JavaScript expression. Values are HTML-escaped by default.

{{ title }}
{{ user.name }}
{{ count + 1 }}
{{ isAdmin ? "Admin" : "User" }}
{{ items.length }}

Null and undefined values render as an empty string.

{{ missingProperty }}   {# renders "" #}
{{ null }}              {# renders "" #}

Raw Output: {{ !expr }}

Prefix the expression with ! to output the value without HTML escaping. Use this when you trust the content and need to embed raw HTML.

{# Safe: HTML-escaped #}
{{ userInput }}

{# Unsafe: raw HTML output — only use with trusted content #}
{{ !trustedHtmlContent }}
{{ !article.bodyHtml }}

Escaping table for {{ expr }}:

| Input | Output | |-------|--------| | & | &amp; | | < | &lt; | | > | &gt; | | " | &quot; | | ' | &#39; | | ` | &#96; |

Conditionals: {% if %} / {% else %} / {% endif %}

Render content conditionally. Any falsy value (false, null, undefined, 0, "") is treated as false.

{% if user.isAdmin %}
  <a href="/admin">Admin Panel</a>
{% endif %}

With an else branch:

{% if items.length %}
  <ul>
    {% for item of items %}<li>{{ item }}</li>{% endfor %}
  </ul>
{% else %}
  <p>No items yet.</p>
{% endif %}

Complex conditions are supported:

{% if user.age >= 18 %}
  <p>Access granted.</p>
{% endif %}

{% if status === "active" %}
  <span class="badge badge-green">Active</span>
{% else %}
  <span class="badge badge-gray">Inactive</span>
{% endif %}

For Loops: {% for %} / {% endfor %}

Iterate over any array or iterable. Renders nothing if the iterable is empty or undefined.

<ul>
  {% for product of products %}
    <li>{{ product.name }} — ${{ product.price }}</li>
  {% endfor %}
</ul>

Loop Meta Variable

Capture loop metadata by providing a second variable name after the item:

{% for item, loop of items %}
  <div class="{{ loop.first ? 'first' : '' }} {{ loop.last ? 'last' : '' }}">
    {{ loop.index1 }}. {{ item }}
  </div>
{% endfor %}

The meta object has these properties:

| Property | Type | Description | |----------|------|-------------| | index | number | 0-based iteration index | | index1 | number | 1-based iteration index | | first | boolean | true on the first iteration | | last | boolean | true on the last iteration | | length | number | Total number of items |

Nested Loops

{% for row of table %}
  <tr>
    {% for cell of row %}
      <td>{{ cell }}</td>
    {% endfor %}
  </tr>
{% endfor %}

Set: {% set %}

Assign a local variable. The variable is available throughout the rest of the template, including after {% endfor %} and {% endif %} blocks.

{% set greeting = "Hello" %}
{% set total = items.length * price %}
{% set isEven = index % 2 === 0 %}

<p>{{ greeting }}, {{ user.name }}!</p>
<p>Total: {{ total }}</p>

Inside a loop, use set to build derived values:

{% for product of products %}
  {% set discounted = product.price * 0.9 %}
  <li>{{ product.name }} — ${{ discounted }}</li>
{% endfor %}

Include: {% include %}

Include a partial template file. The included template receives the same context as the parent.

{% include "partials/header.thtml" %}

<main>{{ content }}</main>

{% include "partials/footer.thtml" %}

Include with a custom context expression:

{% include "partials/user-card.thtml" with user %}
{% include "partials/product.thtml" with { name: item.name, price: item.price } %}

Include resolution requires a TemplateLoader — see the Advanced section.

Comments: {# comment #}

Template comments are stripped at compile time and never appear in the rendered HTML output.

{# This comment will not appear in the output #}

{#
  Multi-line template comments are supported.
  Use them to annotate template sections.
#}

<p>{{ visibleContent }}</p>

API Reference

compile<TContext>(source, options?)

Compile a template source string into a reusable Template object.

function compile<TContext extends Record<string, unknown>>(
  source: string,
  options?: CompileOptions
): Template<TContext>

Parameters:

  • source — The raw template string.
  • options — Optional CompileOptions object.

Returns: A Template<TContext> with render() and renderAsync() methods.

Example:

import { compile } from '@thtml/core';

const tmpl = compile<{ name: string }>('<h1>Hello, {{ name }}!</h1>');
const html = tmpl.render({ name: 'World' });
// '<h1>Hello, World!</h1>'

defineTemplate<TContext>(source, options?)

Type-safe alias for compile. Prefer this form when declaring templates as module-level constants — the name communicates intent clearly.

function defineTemplate<TContext extends Record<string, unknown>>(
  source: string,
  options?: CompileOptions
): Template<TContext>

Example:

import { defineTemplate } from '@thtml/core';

interface EmailContext {
  recipientName: string;
  subject: string;
  body: string;
}

export const emailTemplate = defineTemplate<EmailContext>(`
  <h2>{{ subject }}</h2>
  <p>Dear {{ recipientName }},</p>
  <div>{{ !body }}</div>
`);

parse(source)

Parse a template source string into an AST without compiling it.

function parse(source: string): RootNode

Returns a RootNode — the root of the abstract syntax tree. Throws ParseError on invalid syntax.

Example:

import { parse } from '@thtml/core';

const ast = parse('<h1>{{ title }}</h1>{% if show %}yes{% endif %}');
console.log(ast.type);       // "Root"
console.log(ast.children);   // [TextNode, ExpressionNode, TextNode, IfNode]

tokenize(source)

Tokenize a template source string into a flat array of Token objects.

function tokenize(source: string): Token[]

Throws LexerError on unrecognised input.

Example:

import { tokenize } from '@thtml/core';

const tokens = tokenize('Hello, {{ name }}!');
// [
//   { type: TokenType.Text, value: 'Hello, ', ... },
//   { type: TokenType.ExprOpen, ... },
//   { type: TokenType.Text, value: ' name ', ... },
//   { type: TokenType.ExprClose, ... },
//   { type: TokenType.Text, value: '!', ... },
// ]

Interface: Template<TContext>

The object returned by compile() and defineTemplate().

interface Template<TContext extends Record<string, unknown>> {
  render(context: TContext): string;
  renderAsync(context: TContext): Promise<string>;
  readonly contextType: TContext;  // type-only, throws at runtime
  readonly source?: string;        // generated JS source (for debugging)
  readonly frontmatter?: string | null;
}

render(context)

Synchronously render the template. Returns a complete HTML string. Throws a thtml render error on runtime failures.

const html = tmpl.render({ title: 'Home', items: [] });

renderAsync(context)

Asynchronously render the template. Resolves to the same result as render(). Use this when your pipeline includes async include loaders.

const html = await tmpl.renderAsync({ title: 'Home', items: [] });

source

The generated JavaScript source code as a string, available for debugging and inspection.

console.log(tmpl.source);
// (function(__ctx, __rt) {
//   with (__ctx) {
//     const __buf = new __rt.StringBuffer();
//     __buf.append("<h1>");
//     __buf.append(__rt.escape(title));
//     ...
//   }
// })

frontmatter

The raw TypeScript source extracted from the --- frontmatter block, or null if no frontmatter was present.

Interface: CompileOptions

Options passed as the second argument to compile() and defineTemplate().

interface CompileOptions {
  escape?: boolean;  // default: true
  strict?: boolean;  // default: false
}

escape (default: true)

When true, all {{ expr }} output is HTML-escaped. Set to false to disable escaping globally (for trusted content pipelines). The {{ !expr }} syntax always bypasses escaping regardless of this setting.

// Disable escaping for all expressions (raw pipeline)
const tmpl = compile<{ html: string }>('{{ html }}', { escape: false });
tmpl.render({ html: '<b>bold</b>' }); // '<b>bold</b>'

strict (default: false)

When true, the renderer throws a ReferenceError at runtime if any required context property resolves to undefined. Useful for catching missing data early in development.

const tmpl = compile<{ title: string }>('{{ title }}', { strict: true });
tmpl.render({} as any); // throws: thtml strict mode: "title" resolved to undefined

Interface: TemplateLoader

Implement this interface to resolve and load {% include %} directives from any file system or in-memory store.

interface TemplateLoader {
  load(path: string): string | Promise<string>;
  resolve(fromPath: string, includePath: string): string;
}

Example — Node.js file system loader:

import { readFile } from 'fs/promises';
import { resolve, dirname } from 'path';
import type { TemplateLoader } from '@thtml/core';

const fsLoader: TemplateLoader = {
  async load(path) {
    return readFile(path, 'utf8');
  },
  resolve(fromPath, includePath) {
    return resolve(dirname(fromPath), includePath);
  },
};

Interface: CompileOptions — full reference

interface CompileOptions {
  /**
   * HTML-escape {{ expr }} output by default.
   * Use {{ !expr }} to skip escaping per-expression.
   * @default true
   */
  escape?: boolean;

  /**
   * Throw at runtime when a context property resolves to undefined.
   * @default false
   */
  strict?: boolean;
}

Type Safety

When you pass a type parameter to compile<TContext>() or defineTemplate<TContext>(), TypeScript enforces the shape of the data object at every render() call site.

import { compile } from '@thtml/core';

interface PageContext {
  title: string;
  user: { name: string; email: string };
  isPublished: boolean;
}

const page = compile<PageContext>(`<h1>{{ title }}</h1>`);

// Correct usage — no TypeScript errors
page.render({
  title: 'Hello',
  user: { name: 'Alice', email: '[email protected]' },
  isPublished: true,
});

// TypeScript error: Property 'title' is missing
page.render({
  user: { name: 'Alice', email: '[email protected]' },
  isPublished: true,
});

// TypeScript error: Type 'number' is not assignable to type 'string'
page.render({
  title: 42,
  user: { name: 'Alice', email: '[email protected]' },
  isPublished: true,
});

Frontmatter and VS Code

When working with .thtml files in VS Code, declare the interface in frontmatter:

---
interface Context {
  title: string;
  user: { name: string; email: string };
}
---
<h1>{{ title }}</h1>
<p>{{ user.name }} — {{ user.email }}</p>

The VS Code extension reads the frontmatter, synthesizes a virtual TypeScript file, and provides:

  • Autocomplete when typing {{ user. — shows name, email
  • Hover over user.name — shows (property) name: string
  • Red underline under {{ user.phone }} — property does not exist

XSS Protection

HTML escaping is enabled by default for all {{ expr }} expressions. This means user-supplied data is always safe to render directly.

const tmpl = compile<{ comment: string }>('<p>{{ comment }}</p>');

tmpl.render({ comment: '<script>alert("xss")</script>' });
// '<p>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</p>'

To output trusted HTML (for example, pre-rendered Markdown content), use the ! prefix:

{# Safe — user input is escaped #}
<p>{{ userComment }}</p>

{# Trusted — pre-rendered Markdown from your own pipeline #}
<article>{{ !article.renderedHtml }}</article>

You can also disable escaping globally for a specific template:

const emailTemplate = compile<{ body: string }>('{{ body }}', { escape: false });

Advanced

compileAST(ast, options?)

Compile a pre-parsed AST into a Template. Use this when you want to parse and compile in separate steps, or when you need to inspect or transform the AST before compilation.

import { parse, compileAST, isExpressionNode } from '@thtml/core';

const source = '<h1>{{ title }}</h1>';
const ast = parse(source);

// Inspect the AST
for (const node of ast.children) {
  if (isExpressionNode(node)) {
    console.log('Expression:', node.expression);  // "title"
    console.log('Escaped:', node.escape);          // true
  }
}

// Compile the (possibly modified) AST
const tmpl = compileAST<{ title: string }>(ast);
tmpl.render({ title: 'Hello' }); // '<h1>Hello</h1>'

AST Type Guards

All AST node types are exported along with type-guard functions for safe narrowing:

import {
  isTextNode,
  isExpressionNode,
  isRawExpressionNode,
  isIfNode,
  isForNode,
  isSetNode,
  isIncludeNode,
  isCommentNode,
} from '@thtml/core';

Integration with Build Tools

For production builds, compile templates at build time and export the compiled Template objects:

// templates/page.ts
import { defineTemplate } from '@thtml/core';

interface PageContext {
  title: string;
  content: string;
}

export const pageTemplate = defineTemplate<PageContext>(`
  <!DOCTYPE html>
  <html>
    <head><title>{{ title }}</title></head>
    <body>{{ !content }}</body>
  </html>
`);
// routes/page.ts
import { pageTemplate } from '../templates/page.js';

export function renderPage(title: string, content: string): string {
  return pageTemplate.render({ title, content });
}

Runtime Helpers (Advanced)

The following runtime helpers are exported for advanced use cases such as custom code generators or testing:

import {
  escapeHtml,      // HTML-escape a single value
  toRaw,           // coerce a value to string without escaping
  forEach,         // iterate with LoopMeta
  safeGet,         // safe nested property access
  StringBuffer,    // mutable string accumulator
  createRuntimeContext,
} from '@thtml/core';

License

MIT