Interpolatation
Interpolate: Template and Variable System
Section titled “Interpolate: Template and Variable System”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?
Section titled “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
Section titled “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
Section titled “Core Concepts”1. Safe vs Unsafe Interpolation
Section titled “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
Section titled “2. Safe Interpolation”Safe interpolation uses a mini expression language that supports common operations without code execution.
Basic Variable Substitution
Section titled “Basic Variable Substitution”import { safeInterpolate } from "./safe.ts";
// Simple variablesconst result = safeInterpolate( "Hello ${name}!", { name: "World" });// Output: "Hello World!"
// Nested objectsconst result2 = safeInterpolate( "Database: ${db.host}:${db.port}", { db: { host: "localhost", port: 5432 } });// Output: "Database: localhost:5432"
// Array accessconst result3 = safeInterpolate( "First item: ${items[0]}", { items: ["apple", "banana", "cherry"] });// Output: "First item: apple"Custom Delimiters
Section titled “Custom Delimiters”Safe mode supports multiple delimiter styles:
// Mustache styleconst r1 = safeInterpolate( "Hello {{name}}!", { name: "World" }, { brackets: [{ id: "mustache", open: "{{", close: "}}" }] });
// Dollar style (default)const r2 = safeInterpolate( "Hello ${name}!", { name: "World" }, { brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }] });
// Custom styleconst r3 = safeInterpolate( "Hello <%name%>!", { name: "World" }, { brackets: [{ id: "custom", open: "<%", close: "%>" }] });Expression Language Features
Section titled “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
Section titled “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
Section titled “Compiled Templates for Performance”When rendering the same template multiple times, compile it once:
import { compileSafeTemplate, renderCompiledTemplate } from "./safe.ts";
// Compile onceconst 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
Section titled “Handling Missing Values”Control what happens when variables are missing:
const text = "Hello ${name}, age ${age}";
// Strategy 1: Leave as-is (default)safeInterpolate(text, { name: "Alice" }, { brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }], onMissing: "leave"});// Output: "Hello Alice, age ${age}"
// Strategy 2: Empty stringsafeInterpolate(text, { name: "Alice" }, { brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }], onMissing: "empty"});// Output: "Hello Alice, age "
// Strategy 3: Throw errortry { safeInterpolate(text, { name: "Alice" }, { brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }], onMissing: "throw" });} catch (e) { console.error("Missing variable:", e);}
// Strategy 4: Custom handlersafeInterpolate(text, { name: "Alice" }, { brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }], onMissing: (expr) => `[MISSING: ${expr}]`});// Output: "Hello Alice, age [MISSING: age]"3. Unsafe Interpolation
Section titled “3. Unsafe Interpolation”Unsafe mode executes full JavaScript within templates. Use only with trusted sources.
When to Use Unsafe Mode
Section titled “When to Use Unsafe Mode”Good use cases:
- Internal configuration templates you control
- Complex calculations requiring JavaScript
- Dynamic SQL generation from trusted sources
- Build-time code generation
Bad use cases:
- User-provided templates
- External configuration files
- Any untrusted input
- Public-facing forms or APIs
Basic Unsafe Usage
Section titled “Basic Unsafe Usage”import { unsafeInterpolator } from "./unsafe.ts";
// Create interpolator with global contextconst { 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 expressionsconst 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
Section titled “Context Access”The global context is bound to a variable name (default: ctx):
// Default context nameconst { interpolate } = unsafeInterpolator({ version: "1.0"});await interpolate("${ctx.version}", {}); // Access via 'ctx'
// Custom context nameconst { interpolate: interp2 } = unsafeInterpolator( { version: "1.0" }, { ctxName: "globals" });await interp2("${globals.version}", {}); // Access via 'globals'Combining Global and Local Context
Section titled “Combining Global and Local Context”const { interpolate } = unsafeInterpolator({ appName: "Spry", config: { timeout: 5000 }});
// Global context in 'ctx', local data passed directlyconst 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
Section titled “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
Section titled “4. Partials System”Partials are reusable content fragments that can be composed, validated, and injected into other content.
What Are Partials?
Section titled “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
Section titled “Creating a Partial Collection”import { partialContent, partialContentCollection } from "./partial.ts";
// Create a collection to hold all partialsconst collection = partialContentCollection();
// Simple partial (no variables)const header = partialContent( "header", "<header><h1>My Application</h1></header>");collection.register(header);
// Partial with variablesconst greeting = partialContent( "greeting", "Hello ${name}, welcome to ${app}!");collection.register(greeting);
// Partial with schema validationconst 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
Section titled “Rendering Partials”// Render with variablesconst result = await collection.render({ identity: "greeting", locals: { name: "Alice", app: "Spry" }});// Output: "Hello Alice, welcome to Spry!"
// Schema validation happens automaticallytry { 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
Section titled “Injectable Partials”Injectable partials wrap or enhance other content based on patterns:
// SQL transaction wrapperconst sqlWrapper = partialContent( "sql-transaction", "BEGIN TRANSACTION;\n${content}\nCOMMIT;", { inject: { globs: ["*.sql"], // Match .sql files mode: "both" // Prepend + append } });collection.register(sqlWrapper);
// Error handling wrapperconst 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 onlyconst fileHeader = partialContent( "file-header", "// Generated at ${timestamp}\n// Do not edit manually\n", { inject: { globs: ["*.ts"], mode: "prepend" } });collection.register(fileHeader);Rendering with Injection
Section titled “Rendering with Injection”// Render SQL query with automatic transaction wrapperconst 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
Section titled “Partial Composition”Partials can reference other partials:
// Base partialsconst logo = partialContent("logo", "<img src='logo.png'>");const nav = partialContent("nav", "<nav>Home | About</nav>");const footer = partialContent("footer", "<footer>© 2024</footer>");
// Composite partialconst 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 expandedconst page = await collection.render({ identity: "page-layout", locals: { title: "Welcome", content: "<h1>Hello World</h1>" }});5. Variable Capture
Section titled “5. Variable Capture”Extract variable names from templates without rendering:
import { captureVariables } from "./capture.ts";
// Extract all variable namesconst vars1 = captureVariables("Hello ${name}, you have ${count} items");// Output: ["name", "count"]
// Works with nested pathsconst vars2 = captureVariables("DB: ${db.host}:${db.port}");// Output: ["db.host", "db.port"]
// Works with different delimitersconst vars3 = captureVariables( "Hello {{name}}!", { open: "{{", close: "}}" });// Output: ["name"]Use cases:
- Validate that all required variables are provided
- Generate documentation of template dependencies
- Build configuration UIs
- Detect unused variables
Module Organization
Section titled “Module Organization”safe.ts
Section titled “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
Section titled “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
Section titled “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
Section titled “capture.ts”Variable extraction utilities.
Key exports:
captureVariables(template, options?)- Extract variable names
Real-World Examples
Section titled “Real-World Examples”Example 1: Environment-Specific Configuration
Section titled “Example 1: Environment-Specific Configuration”import { safeInterpolate } from "./safe.ts";
// Configuration templateconst configTemplate = `{ "database": { "host": "${DB_HOST}", "port": ${DB_PORT}, "name": "${DB_NAME}", "ssl": ${DB_SSL} }, "api": { "url": "${API_URL}", "timeout": ${API_TIMEOUT} }}`;
// Development environmentconst devEnv = { DB_HOST: "localhost", DB_PORT: 5432, DB_NAME: "dev_db", DB_SSL: false, API_URL: "http://localhost:8000", API_TIMEOUT: 5000};
// Production environmentconst 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
Section titled “Example 2: SQL Template Library”import { partialContentCollection, partialContent } from "./partial.ts";
const sql = partialContentCollection();
// Common SQL patternssql.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 querysql.register(partialContent( "list-users", `SELECT id, username, email, ${partial:audit-columns}FROM usersWHERE ${partial:timestamp-filter}${partial:pagination} `));
// Render complete queryconst 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
Section titled “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 releasegit tag ${ctx.helpers.gitTag(ctx.project.version)}git push origin ${ctx.helpers.gitTag(ctx.project.version)}
# Deploy${ctx.helpers.deployCmd(env, ctx.project.version)}
# Verifycurl https://${env}.example.com/health\`\`\``).join('\n')}`;
const runbook = await interpolate(runbookTemplate, {});Integration Points
Section titled “Integration Points”With axiom/
Section titled “With axiom/”Axiom uses interpolate for code fence content:
import { interpolateUnsafely } from "../interpolate/unsafe.ts";
// Interpolate task content before executionconst result = await interpolateUnsafely({ source: taskNode.content, interpolate: true});With sqlpage/
Section titled “With sqlpage/”SQLPage uses interpolate for SQL template rendering:
import { sqlPageInterpolate } from "../sqlpage/interpolate.ts";
// Render SQL templates with partialsconst sql = sqlPageInterpolate(template, context);With task/
Section titled “With task/”Task execution leverages interpolation for dynamic commands:
// Before execution, interpolate environment variablesconst command = safeInterpolate( task.command, { ...Deno.env.toObject(), ...taskContext });With markdown/
Section titled “With markdown/”Markdown notebooks use interpolation for cell content:
import { renderCell } from "../markdown/notebook/cell.ts";
// Cell content is interpolated before renderingconst output = await renderCell(cell, interpolationContext);Best Practices
Section titled “Best Practices”1. Choose the Right Mode
Section titled “1. Choose the Right Mode”// GOOD: Safe for user inputconst userGreeting = safeInterpolate( "Hello ${username}!", { username: userInput });
// BAD: Unsafe with user inputconst unsafe = unsafeInterpolator({});await unsafe.interpolate( `Hello ${userInput}!`, // DANGER: Code injection! {});2. Validate with Schemas
Section titled “2. Validate with Schemas”// GOOD: Schema catches errors earlyconst query = partialContent( "get-user", "SELECT * FROM users WHERE id = ${id}", { schemaSpec: { id: { type: "number" } } });
// BAD: No validationconst query2 = partialContent( "get-user", "SELECT * FROM users WHERE id = ${id}" // Any type accepted);3. Compile Repeated Templates
Section titled “3. Compile Repeated Templates”// GOOD: Compile once, render many timesconst template = compileSafeTemplate("Hello ${name}!");for (const user of users) { renderCompiledTemplate(template, user);}
// BAD: Reparse every timefor (const user of users) { safeInterpolate("Hello ${name}!", user);}4. Use Partials for Reuse
Section titled “4. Use Partials for Reuse”// GOOD: DRY with partialsconst header = partialContent("header", "...");const footer = partialContent("footer", "...");// Use in multiple templates
// BAD: Copy-paste everywhereconst page1 = "<!-- header -->" + content1 + "<!-- footer -->";const page2 = "<!-- header -->" + content2 + "<!-- footer -->";5. Limit Recursion Depth
Section titled “5. Limit Recursion Depth”// GOOD: Set reasonable limitsafeInterpolate(template, data, { brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }], maxDepth: 3 // Prevent infinite loops});Testing
Section titled “Testing”# Run all interpolate testsdeno test lib/interpolate/
# Run specific mode testsdeno test lib/interpolate/safe_test.tsdeno test lib/interpolate/unsafe_test.tsdeno test lib/interpolate/partial_test.ts
# Run with coveragedeno test --coverage=coverage/ lib/interpolate/Security Considerations
Section titled “Security Considerations”Safe Mode Security
Section titled “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
Section titled “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
Section titled “Key Takeaways”- 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