drip-templates
v0.2.0
Published
Reactive templates compiler and runtime for Liquid/Shopify themes
Maintainers
Readme
drip-templates
Reactive templates compiler and runtime for Shopify/Liquid themes. Write templates once in JSX-like syntax, compile to both server-rendered Liquid and client-side reactive JavaScript.
Features
- SSR + Hydration - Server-rendered Liquid for fast initial load, client reactivity after
- Single Source of Truth - One template compiles to both SSR and client code
- No XSS Vulnerabilities - Uses template cloning and
textContent, never innerHTML - Lightweight Runtime - ~21kb bundled, no framework dependencies
- Fine-Grained Updates - Only updates changed text/attributes, DOM nodes persist
- Keyed List Reconciliation - Efficient add/remove/reorder with stable DOM nodes
Installation
npm install drip-templatesQuick Start
1. Create a Template
Create src/templates/cart-item.jsx:
export default ({ item }) => (
<div $item={item.key} $scope="cart.items[key]">
<span $text={item.title} />
<span $text={money(item.price)} class="price" />
<button $action="cart.remove" $disabled={item.removing}>
<span $text={t('cart.remove')} />
</button>
</div>
);2. Compile Templates
npx drip build --input ./src/templates --output .This generates:
snippets/_cart-item.liquid- Server-rendered Liquid snippetassets/_cart-item-template.liquid- Client-side<template>elementassets/template-manifest.js- Binding metadata for the runtime
3. Use in Your Theme
In your Liquid template:
{% comment %} Render server-side {% endcomment %}
{% for item in cart.items %}
{% render '_cart-item', item: item %}
{% endfor %}
{% comment %} Include template element for client-side cloning {% endcomment %}
{% render '_cart-item-template' %}
{% comment %} Initialize state {% endcomment %}
<script id="cart-state" type="application/json">
{{ cart | json }}
</script>In your JavaScript:
import { Drip } from '/assets/drip-runtime.js';
import { templates } from '/assets/template-manifest.js';
const app = new Drip({
manifest: { templates },
translations: { 'cart.remove': 'Remove' },
initialState: JSON.parse(document.getElementById('cart-state').textContent)
});
app
.on('cart.remove', async (event, { key }) => {
app.set(`cart.items[${key}].removing`, true);
const response = await fetch(`/cart/change.js`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: key, quantity: 0 })
});
const cart = await response.json();
app.set('cart', cart);
})
.hydrate(document.querySelector('.cart-container'))
.mount('tmpl-cart-item', document.querySelector('.cart-items'), 'cart.items');Template Syntax
Templates use JSX-like syntax with $ prefixed binding markers.
Binding Markers
| Syntax | Purpose | Generated Attribute |
|--------|---------|---------------------|
| $text={expr} | Text content binding | data-bind-text="path" |
| $value={expr} | Input value binding | data-bind-value="path" |
| $checked={expr} | Checkbox binding | data-bind-checked="path" |
| $disabled={expr} | Disabled state | data-bind-disabled="path" |
| $hidden={expr} | Visibility toggle | data-bind-hidden="path" |
| $attr:name={expr} | Attribute binding | data-bind-attr-name="path" |
| $item={key} | List item key | data-item="{{ item.key }}" |
| $scope="path" | Scope for nested bindings | data-scope="path" |
| $action="handler" | Click handler | data-action="handler" |
| $action:event="handler" | Custom event handler | data-action-event="handler" |
SSR Condition Attributes
For server-side rendering, you often need Liquid conditions that differ from client-side state paths. Use $ssr: prefixed attributes to specify Liquid-specific conditions:
| Syntax | Purpose | Generated Output |
|--------|---------|------------------|
| $ssr:hidden="liquid_condition" | SSR hidden state | {% if condition %} hidden{% endif %} |
| $ssr:disabled="liquid_condition" | SSR disabled state | {% if condition %} disabled{% endif %} |
Example:
<p
$hidden={item.hide_variant}
$ssr:hidden="item.variant_title == blank or item.variant_title == 'Default Title'"
>
{item.variant_title}
</p>
<button
$disabled={item.at_min_quantity}
$ssr:disabled="item.quantity <= 1"
>
Decrease
</button>This generates:
<p data-bind-hidden="hide_variant"{% if item.variant_title == blank or item.variant_title == 'Default Title' %} hidden{% endif %}>
{{ item.variant_title }}
</p>
<button data-bind-disabled="at_min_quantity"{% if item.quantity <= 1 %} disabled{% endif %}>
Decrease
</button>The $ssr: conditions only affect the Liquid SSR output. The client-side runtime uses the regular $hidden/$disabled binding paths.
Helpers
Built-in helper functions for formatting:
{money(item.price)} // Format as currency: $12.34
{date(item.created)} // Format date
{plural(count, 'item')} // Pluralize: "1 item" or "2 items"
{t('cart.remove')} // Translation lookupScope Pattern
The $scope attribute uses [key] as a placeholder that gets replaced with the actual item key:
<div $item={item.key} $scope="cart.items[key]">
{/* Bindings inside resolve to cart.items[actualKey].path */}
<span $text={item.title} /> {/* -> cart.items[abc].title */}
</div>CLI Reference
npx drip <command> [options]
Commands:
build Compile all templates
watch Watch mode - recompile on changes
Options:
--input <path> Input directory for templates (default: ./src/templates)
--output <path> Output directory root (default: .)
--help, -h Show helpExamples
# Build templates
npx drip build
# Build with custom paths
npx drip build --input ./templates --output ./theme
# Watch mode
npx drip watch --input ./src/templatesRuntime API
new Drip(options)
Create a new Drip instance.
const app = new Drip({
manifest: { templates }, // Template manifest from compiler
translations: {}, // Translation strings
helpers: {}, // Additional helper functions
initialState: {} // Initial state object
});app.on(action, callback)
Register an action handler.
app.on('cart.remove', (event, context) => {
// event: DOM event
// context: { scope, key, target }
});app.hydrate(container)
Hydrate server-rendered content by wiring up bindings.
app.hydrate(document.querySelector('.cart-container'));app.mount(templateId, container, listPath)
Mount a list for dynamic updates. Handles add/remove/reorder.
app.mount('tmpl-cart-item', document.querySelector('.cart-items'), 'cart.items');app.set(path, value)
Set a value in the store. Triggers reactive updates.
app.set('cart.items[abc].title', 'New Title');
app.set('cart.items', [...newItems]);app.get(path)
Get a value from the store.
const title = app.get('cart.items[abc].title');
const items = app.get('cart.items');app.batch(fn)
Batch multiple updates into a single notification cycle.
app.batch(() => {
app.set('cart.items[abc].title', 'New Title');
app.set('cart.items[abc].price', 1999);
});app.subscribe(path, callback)
Subscribe to state changes. Returns unsubscribe function.
const unsubscribe = app.subscribe('cart.items', () => {
console.log('Cart items changed');
});Path Syntax
Paths support both dot notation and bracket notation:
// These are equivalent for arrays
app.get('cart.items[0].title') // Numeric index
app.get('cart.items[abc].title') // Keyed access (finds item where key='abc')
// Nested paths
app.set('user.profile.name', 'John')Keyed Array Access: When accessing an array with a non-numeric key like cart.items[abc], the runtime finds the item where item.key === 'abc'.
Compiler Output
For a template cart-item.jsx, the compiler generates:
snippets/_cart-item.liquid
Server-rendered Liquid with data attributes:
<div data-item="{{ item.key }}" data-scope="cart.items[{{ item.key }}]">
<span data-bind-text="title">{{ item.title }}</span>
...
</div>assets/_cart-item-template.liquid
Template element for client-side cloning:
<template id="tmpl-cart-item">
<div data-item="" data-scope="">
<span data-bind-text="title"></span>
...
</div>
</template>assets/template-manifest.js
Binding metadata:
export const templates = {
'cart-item': {
id: 'tmpl-cart-item',
bindings: [
{ selector: '[data-bind-text="title"]', type: 'text', path: 'title' },
{ selector: '[data-bind-text="price"]', type: 'text', path: 'price', helper: 'money' }
],
actions: [
{ selector: '[data-action="cart.remove"]', action: 'cart.remove', event: 'click' }
],
itemKey: 'key',
scopePattern: 'cart.items[key]'
}
};Shopify Integration
Theme Structure
your-theme/
├── src/
│ └── templates/ # Your .jsx templates
│ └── cart-item.jsx
├── snippets/
│ └── _cart-item.liquid # Generated
├── assets/
│ ├── _cart-item-template.liquid # Generated
│ ├── template-manifest.js # Generated
│ └── drip-runtime.js # Copy from node_modules
├── layout/
│ └── theme.liquid
└── package.jsonSetup
- Install drip-templates
- Copy
node_modules/drip-templates/assets/drip-runtime.jsto your theme's assets - Add build script to package.json:
{ "scripts": { "templates:build": "drip build --input ./src/templates --output .", "templates:watch": "drip watch --input ./src/templates --output ." } }
Including Templates in theme.liquid
<!DOCTYPE html>
<html>
<head>...</head>
<body>
{{ content_for_layout }}
{% comment %} Include all template elements {% endcomment %}
{% render '_cart-item-template' %}
{% comment %} Initialize runtime {% endcomment %}
<script type="module">
import { Drip } from '{{ 'drip-runtime.js' | asset_url }}';
import { templates } from '{{ 'template-manifest.js' | asset_url }}';
window.drip = new Drip({
manifest: { templates },
initialState: {
cart: {{ cart | json }}
}
});
// Register action handlers
drip.on('cart.remove', async (e, { key }) => {
// ... handle remove
});
// Hydrate existing content
drip.hydrate(document.body);
</script>
</body>
</html>Programmatic API
For build tools and custom integrations:
import { build } from 'drip-templates/compiler';
await build({
input: './src/templates',
output: './dist'
});License
MIT
