Interpolation
Template and Variable System in Spry.
Introduction
The interpolate module is Spry's template engine that handles variable substitution, dynamic content generation, and reusable content fragments. It provides both secure (safe) and powerful (unsafe) interpolation modes, giving you the flexibility to choose the right tool for your use case.
What Problem Does Interpolate Solve?
When writing runbooks and automation, you often need to:
- Inject configuration values: Database URLs, API keys, environment-specific settings
- Generate dynamic content: File names with timestamps, computed values
- Reuse content: Common SQL snippets, shared configuration blocks
- Compose templates: Build complex outputs from smaller pieces
Interpolate provides a unified system for all these needs with two key modes:
- Safe Mode: Fast, secure variable substitution (like
${VAR}expansion) - Unsafe Mode: Full JavaScript execution in templates (for trusted sources)
Directory Structure
lib/interpolate/
├── safe.ts # Safe interpolation engine (no code execution)
├── safe_test.ts # Safe mode tests
├── unsafe.ts # Unsafe interpolation (JavaScript evaluation)
├── unsafe_test.ts # Unsafe mode tests
├── partial.ts # Reusable content fragments system
├── partial_test.ts # Partial tests
├── capture.ts # Variable extraction utilities
├── capture_test.ts # Capture tests
└── mod_test.ts # Integration testsCore Concepts
1. Safe vs Unsafe Interpolation
Choosing between safe and unsafe interpolation is about trust and capability:
| Aspect | Safe | Unsafe |
|---|---|---|
| Code execution | ✅ No eval, no Function() | ❌ Full JavaScript execution |
| Use case | Config files, user input, environment vars | Trusted templates, complex logic |
| Performance | ⚡ Fast (no compilation) | 🐌 Slower (compiles JS) |
| Security | 🔒 High (no injection risk) | ⚠️ Requires trusted input |
| Expressions | Mini language (paths, functions) | Full JavaScript |
| When to use | Default choice, external input | Internal templates only |
Rule of thumb: Use safe by default. Only use unsafe when you control the template source and need JavaScript's full power.
2. Safe Interpolation
Safe interpolation uses a mini expression language that supports common operations without code execution.
Basic Variable Substitution
import { safeInterpolate } from "./safe.ts";
// Simple variables
const result = safeInterpolate(
"Hello ${name}!",
{ name: "World" }
);
// Output: "Hello World!"
// Nested objects
const result2 = safeInterpolate(
"Database: ${db.host}:${db.port}",
{ db: { host: "localhost", port: 5432 } }
);
// Output: "Database: localhost:5432"
// Array access
const result3 = safeInterpolate(
"First item: ${items[0]}",
{ items: ["apple", "banana", "cherry"] }
);
// Output: "First item: apple"Custom Delimiters
Safe mode supports multiple delimiter styles:
const r1 = safeInterpolate(
"Hello {{name}}!",
{ name: "World" },
{
brackets: [{ id: "mustache", open: "{{", close: "}}" }]
}
);const r2 = safeInterpolate(
"Hello ${name}!",
{ name: "World" },
{
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }]
}
);const r3 = safeInterpolate(
"Hello <%name%>!",
{ name: "World" },
{
brackets: [{ id: "custom", open: "<%", close: "%>" }]
}
);Expression Language Features
Safe mode's mini language supports:
// 1. Property access (dot notation)
"${user.profile.name}"
// 2. Array indexing
"${items[0]}", "${matrix[1][2]}"
// 3. String literals
"${'Hello World'}"
// 4. Number literals
"${42}", "${3.14}"
// 5. Boolean literals
"${true}", "${false}"
// 6. Null literal
"${null}"
// 7. Function calls (with registered functions)
"${upper(name)}", "${join(items, ', ')}"
// 8. Nested backtick templates
"${`nested ${value}`}"Function Registry
Register custom functions for safe mode:
import { safeInterpolate } from "./safe.ts";
const options = {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
functions: {
// String transformation
upper: ([val]) => String(val).toUpperCase(),
lower: ([val]) => String(val).toLowerCase(),
// Array operations
join: ([arr, sep]) => Array.isArray(arr) ? arr.join(sep || ',') : String(arr),
length: ([val]) => Array.isArray(val) ? val.length : String(val).length,
// Conditionals
default: ([val, defaultVal]) => val ?? defaultVal,
ifelse: ([condition, trueVal, falseVal]) => condition ? trueVal : falseVal,
// Date/time
now: () => new Date().toISOString(),
timestamp: () => Date.now(),
}
};
const result = safeInterpolate(
"Welcome ${upper(user.name)}! Today is ${now()}",
{ user: { name: "alice" } },
options
);
// Output: "Welcome ALICE! Today is 2024-01-15T10:30:00.000Z"Compiled Templates for Performance
When rendering the same template multiple times, compile it once:
import { compileSafeTemplate, renderCompiledTemplate } from "./safe.ts";
// Compile once
const template = compileSafeTemplate(
"Hello ${name}, you have ${count} items",
{ brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }] }
);
// Render many times (fast!)
const users = [
{ name: "Alice", count: 5 },
{ name: "Bob", count: 12 },
{ name: "Charlie", count: 3 }
];
for (const user of users) {
console.log(renderCompiledTemplate(template, user));
}
// Output:
// Hello Alice, you have 5 items
// Hello Bob, you have 12 items
// Hello Charlie, you have 3 itemsHandling Missing Values
Control what happens when variables are missing:
safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: "leave"
});
// Output: "Hello Alice, age ${age}"safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: "empty"
});
// Output: "Hello Alice, age "try {
safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: "throw"
});
} catch (e) {
console.error("Missing variable:", e);
}safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: (expr) => `[MISSING: ${expr}]`
});
// Output: "Hello Alice, age [MISSING: age]"3. Unsafe Interpolation
Unsafe mode executes full JavaScript within templates. Use only with trusted sources.
When to Use Unsafe Mode
- Internal configuration templates you control
- Complex calculations requiring JavaScript
- Dynamic SQL generation from trusted sources
- Build-time code generation
- User-provided templates
- External configuration files
- Any untrusted input
- Public-facing forms or APIs
Basic Unsafe Usage
import { unsafeInterpolator } from "./unsafe.ts";
// Create interpolator with global context
const { interpolate } = unsafeInterpolator({
app: {
name: "Spry",
version: "1.0.0"
},
env: Deno.env.get("ENV") || "development",
utils: {
upper: (s: string) => s.toUpperCase(),
dateFormat: (d: Date) => d.toISOString().split('T')[0]
}
});
// Template with JavaScript expressions
const template = `
Application: \${ctx.app.name} v\${ctx.app.version}
Environment: \${ctx.env.toUpperCase()}
Today: \${ctx.utils.dateFormat(new Date())}
Computed: \${ctx.app.version.split('.').map(n => parseInt(n) * 2).join('.')}
`;
const result = await interpolate(template, {});
// Output:
// Application: Spry v1.0.0
// Environment: DEVELOPMENT
// Today: 2024-01-15
// Computed: 2.0.0Context Access
The global context is bound to a variable name (default: ctx):
// Default context name
const { interpolate } = unsafeInterpolator({
version: "1.0"
});
await interpolate("${ctx.version}", {}); // Access via 'ctx'
// Custom context name
const { interpolate: interp2 } = unsafeInterpolator(
{ version: "1.0" },
{ ctxName: "globals" }
);
await interp2("${globals.version}", {}); // Access via 'globals'Combining Global and Local Context
const { interpolate } = unsafeInterpolator({
appName: "Spry",
config: { timeout: 5000 }
});
// Global context in 'ctx', local data passed directly
const result = await interpolate(
"App: ${ctx.appName}, User: ${user}, Timeout: ${ctx.config.timeout}",
{ user: "Alice" } // Local data
);
// Output: "App: Spry, User: Alice, Timeout: 5000"Advanced JavaScript in Templates
Since unsafe mode runs JavaScript, you can use any valid expression:
const { interpolate } = unsafeInterpolator({
data: {
items: [10, 20, 30, 40, 50],
threshold: 25
}
});
const template = `
Total: \${ctx.data.items.reduce((a, b) => a + b, 0)}
Average: \${ctx.data.items.reduce((a, b) => a + b, 0) / ctx.data.items.length}
Above threshold: \${ctx.data.items.filter(x => x > ctx.data.threshold).length}
Doubled: \${ctx.data.items.map(x => x * 2).join(', ')}
`;
const result = await interpolate(template, {});
// Output:
// Total: 150
// Average: 30
// Above threshold: 2
// Doubled: 20, 40, 60, 80, 1004. Partials System
Partials are reusable content fragments that can be composed, validated, and injected into other content.
What Are Partials?
Think of partials as named, reusable templates:
- Definition: Named content with optional variable substitution
- Schema: Optional validation for required variables
- Injectable: Can wrap or be inserted into other content
- Composable: Partials can reference other partials
Creating a Partial Collection
import { partialContent, partialContentCollection } from "./partial.ts";
// Create a collection to hold all partials
const collection = partialContentCollection();
// Simple partial (no variables)
const header = partialContent(
"header",
"<header><h1>My Application</h1></header>"
);
collection.register(header);
// Partial with variables
const greeting = partialContent(
"greeting",
"Hello ${name}, welcome to ${app}!"
);
collection.register(greeting);
// Partial with schema validation
const sqlQuery = partialContent(
"select-user",
"SELECT * FROM users WHERE id = ${userId} AND status = '${status}'",
{
schemaSpec: {
userId: { type: "number" },
status: { type: "string" }
}
}
);
collection.register(sqlQuery);Rendering Partials
// Render with variables
const result = await collection.render({
identity: "greeting",
locals: { name: "Alice", app: "Spry" }
});
// Output: "Hello Alice, welcome to Spry!"
// Schema validation happens automatically
try {
await collection.render({
identity: "select-user",
locals: { userId: "not-a-number", status: "active" }
});
} catch (error) {
console.error("Validation failed:", error);
// userId must be a number!
}Injectable Partials
Injectable partials wrap or enhance other content based on patterns:
// SQL transaction wrapper
const sqlWrapper = partialContent(
"sql-transaction",
"BEGIN TRANSACTION;\n${content}\nCOMMIT;",
{
inject: {
globs: ["*.sql"], // Match .sql files
mode: "both" // Prepend + append
}
}
);
collection.register(sqlWrapper);
// Error handling wrapper
const errorHandler = partialContent(
"error-handler",
"try {\n${content}\n} catch (error) {\n console.error(error);\n}",
{
inject: {
globs: ["*.js", "*.ts"],
mode: "both"
}
}
);
collection.register(errorHandler);
// Header prepend only
const fileHeader = partialContent(
"file-header",
"// Generated at ${timestamp}\n// Do not edit manually\n",
{
inject: {
globs: ["*.ts"],
mode: "prepend"
}
}
);
collection.register(fileHeader);Rendering with Injection
// Render SQL query with automatic transaction wrapper
const result = await collection.renderWithInjection({
identity: "select-user",
path: "query.sql", // Matches *.sql glob
locals: {
userId: 123,
status: "active",
timestamp: new Date().toISOString()
}
});
// Output:
// BEGIN TRANSACTION;
// SELECT * FROM users WHERE id = 123 AND status = 'active'
// COMMIT;Partial Composition
Partials can reference other partials:
// Base partials
const logo = partialContent("logo", "<img src='logo.png'>");
const nav = partialContent("nav", "<nav>Home | About</nav>");
const footer = partialContent("footer", "<footer>© 2024</footer>");
// Composite partial
const layout = partialContent(
"page-layout",
`
<!DOCTYPE html>
<html>
<head><title>\${title}</title></head>
<body>
\${partial:logo}
\${partial:nav}
<main>\${content}</main>
\${partial:footer}
</body>
</html>
`
);
collection.register(logo);
collection.register(nav);
collection.register(footer);
collection.register(layout);
// Render with all partials expanded
const page = await collection.render({
identity: "page-layout",
locals: {
title: "Welcome",
content: "<h1>Hello World</h1>"
}
});5. Variable Capture
Extract variable names from templates without rendering:
import { captureVariables } from "./capture.ts";
// Extract all variable names
const vars1 = captureVariables("Hello ${name}, you have ${count} items");
// Output: ["name", "count"]
// Works with nested paths
const vars2 = captureVariables("DB: ${db.host}:${db.port}");
// Output: ["db.host", "db.port"]
// Works with different delimiters
const vars3 = captureVariables(
"Hello {{name}}!",
{ open: "{{", close: "}}" }
);
// Output: ["name"]Use cases for variable capture:
- Validate that all required variables are provided
- Generate documentation of template dependencies
- Build configuration UIs
- Detect unused variables
Module Organization
safe.ts
The safe interpolation engine. Contains the mini expression parser and renderer.
Key exports:
safeInterpolate(template, data, options?)- One-shot interpolationcompileSafeTemplate(template, options?)- Compile for reuserenderCompiledTemplate(compiled, data)- Render compiled template
Configuration options:
brackets- Delimiter configurationfunctions- Custom function registryonMissing- Missing value strategyescape- Output escaping functionmaxDepth- Recursion limit (default: 5)
unsafe.ts
The unsafe interpolation engine with JavaScript evaluation.
Key exports:
unsafeInterpolator(context, options?)- Create interpolatorunsafeInterpFactory(options)- Factory with partials
Security notes:
- Uses
Function()constructor for evaluation - Context is bound to configurable name (default:
ctx) - NEVER use with untrusted input
partial.ts
Reusable content fragments with validation and injection.
Key exports:
partialContent(identity, content, options?)- Create partialpartialContentCollection()- Create collection- Collection methods:
register(),render(),renderWithInjection()
Partial options:
schemaSpec- Zod validation schemainject.globs- File patterns to matchinject.mode- 'prepend', 'append', or 'both'
capture.ts
Variable extraction utilities.
Key exports:
captureVariables(template, options?)- Extract variable names
Real-World Examples
Example 1: Environment-Specific Configuration
import { safeInterpolate } from "./safe.ts";
// Configuration template
const configTemplate = `
{
"database": {
"host": "${DB_HOST}",
"port": ${DB_PORT},
"name": "${DB_NAME}",
"ssl": ${DB_SSL}
},
"api": {
"url": "${API_URL}",
"timeout": ${API_TIMEOUT}
}
}
`;
// Development environment
const devEnv = {
DB_HOST: "localhost",
DB_PORT: 5432,
DB_NAME: "dev_db",
DB_SSL: false,
API_URL: "http://localhost:8000",
API_TIMEOUT: 5000
};
// Production environment
const prodEnv = {
DB_HOST: "prod.db.example.com",
DB_PORT: 5432,
DB_NAME: "production",
DB_SSL: true,
API_URL: "https://api.example.com",
API_TIMEOUT: 10000
};
const options = {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }]
};
const devConfig = safeInterpolate(configTemplate, devEnv, options);
const prodConfig = safeInterpolate(configTemplate, prodEnv, options);Example 2: SQL Template Library
import { partialContentCollection, partialContent } from "./partial.ts";
const sql = partialContentCollection();
// Common SQL patterns
sql.register(partialContent(
"pagination",
"LIMIT ${limit} OFFSET ${offset}",
{ schemaSpec: { limit: { type: "number" }, offset: { type: "number" } } }
));
sql.register(partialContent(
"timestamp-filter",
"created_at >= '${startDate}' AND created_at < '${endDate}'",
{ schemaSpec: { startDate: { type: "string" }, endDate: { type: "string" } } }
));
sql.register(partialContent(
"audit-columns",
"created_at, updated_at, created_by, updated_by"
));
// Composite query
sql.register(partialContent(
"list-users",
`
SELECT id, username, email, ${partial:audit-columns}
FROM users
WHERE ${partial:timestamp-filter}
${partial:pagination}
`
));
// Render complete query
const query = await sql.render({
identity: "list-users",
locals: {
startDate: "2024-01-01",
endDate: "2024-02-01",
limit: 50,
offset: 100
}
});Example 3: Dynamic Runbook Generation
import { unsafeInterpolator } from "./unsafe.ts";
const { interpolate } = unsafeInterpolator({
project: {
name: "my-app",
version: "2.1.0",
environments: ["dev", "staging", "prod"]
},
helpers: {
gitTag: (version: string) => `v${version}`,
deployCmd: (env: string, version: string) =>
`./deploy.sh --env ${env} --version ${version}`
}
});
const runbookTemplate = `
# Deploy ${ctx.project.name}
## Version: ${ctx.project.version}
${ctx.project.environments.map(env => `
### Deploy to ${env.toUpperCase()}
\`\`\`bash
# Tag release
git tag ${ctx.helpers.gitTag(ctx.project.version)}
git push origin ${ctx.helpers.gitTag(ctx.project.version)}
# Deploy
${ctx.helpers.deployCmd(env, ctx.project.version)}
# Verify
curl https://${env}.example.com/health
\`\`\`
`).join('\n')}
`;
const runbook = await interpolate(runbookTemplate, {});Integration Points
With axiom/
Axiom uses interpolate for code fence content:
import { interpolateUnsafely } from "../interpolate/unsafe.ts";
// Interpolate task content before execution
const result = await interpolateUnsafely({
source: taskNode.content,
interpolate: true
});With sqlpage/
SQLPage uses interpolate for SQL template rendering:
import { sqlPageInterpolate } from "../sqlpage/interpolate.ts";
// Render SQL templates with partials
const sql = sqlPageInterpolate(template, context);With task/
Task execution leverages interpolation for dynamic commands:
// Before execution, interpolate environment variables
const command = safeInterpolate(
task.command,
{ ...Deno.env.toObject(), ...taskContext }
);With markdown/
Markdown notebooks use interpolation for cell content:
import { renderCell } from "../markdown/notebook/cell.ts";
// Cell content is interpolated before rendering
const output = await renderCell(cell, interpolationContext);Best Practices
1. Choose the Right Mode
// Safe for user input
const userGreeting = safeInterpolate(
"Hello ${username}!",
{ username: userInput }
);// Unsafe with user input
const unsafe = unsafeInterpolator({});
await unsafe.interpolate(
`Hello ${userInput}!`, // DANGER: Code injection!
{}
);2. Validate with Schemas
// Schema catches errors early
const query = partialContent(
"get-user",
"SELECT * FROM users WHERE id = ${id}",
{ schemaSpec: { id: { type: "number" } } }
);// No validation
const query2 = partialContent(
"get-user",
"SELECT * FROM users WHERE id = ${id}" // Any type accepted
);3. Compile Repeated Templates
// Compile once, render many times
const template = compileSafeTemplate("Hello ${name}!");
for (const user of users) {
renderCompiledTemplate(template, user);
}// Reparse every time
for (const user of users) {
safeInterpolate("Hello ${name}!", user);
}4. Use Partials for Reuse
// DRY with partials
const header = partialContent("header", "...");
const footer = partialContent("footer", "...");
// Use in multiple templates// Copy-paste everywhere
const page1 = "<!-- header -->" + content1 + "<!-- footer -->";
const page2 = "<!-- header -->" + content2 + "<!-- footer -->";5. Limit Recursion Depth
// Set reasonable limit
safeInterpolate(template, data, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
maxDepth: 3 // Prevent infinite loops
});Testing
# Run all interpolate tests
deno test lib/interpolate/
# Run specific mode tests
deno test lib/interpolate/safe_test.ts
deno test lib/interpolate/unsafe_test.ts
deno test lib/interpolate/partial_test.ts
# Run with coverage
deno test --coverage=coverage/ lib/interpolate/Security Considerations
Safe Mode Security
Safe mode is designed to prevent code injection:
- No
eval()orFunction()calls - No property access beyond data object
- Limited expression language
- Safe for user-provided templates
Unsafe Mode Security
Unsafe mode executes arbitrary JavaScript:
- Only use with trusted template sources
- Never use with user input or external data
- Consider sandboxing or alternative approaches
- Audit all templates that use unsafe mode
When in doubt, use safe mode.
Key Takeaways
The Interpolation module provides powerful templating capabilities:
- Safe by default: Use
safeInterpolateunless you need JavaScript - Partials for reuse: Build a library of reusable content fragments
- Compile for performance: Pre-compile templates used multiple times
- Validate with schemas: Catch errors early with partial schemas
- Never trust user input: Only use unsafe mode with controlled sources
This document provides a comprehensive overview of Spry's Interpolation system, covering both safe and unsafe modes, partials, and real-world usage patterns.
How is this guide?
Last updated on