Introduction

Salata -- "salad" in Bosnian, Croatian, Serbian, and most Slavic languages -- is a polyglot text templating engine written in Rust. Like its namesake, it's a mix of everything thrown together: it processes .slt template files containing embedded runtime blocks -- <python>, <ruby>, <javascript>, <typescript>, <php>, and <shell> -- executes them server-side, and writes the combined output to stdout. The output is whatever the code prints: HTML, JSON, plain text, YAML, config files, Markdown, or anything else. HTML is the most common use case, but Salata is not limited to it.

Warning: Salata is a concept project under active development. It is not production-ready. APIs, configuration format, and behavior may change between versions. Use it for experimentation, learning, and prototyping -- not for production workloads.

What makes Salata different

Most templating engines are tied to a single language and a single output format. Salata takes a different approach:

  • Six runtimes in one file. Python, Ruby, JavaScript, TypeScript, PHP, and Shell can all appear in the same .slt template. Each block runs in its native interpreter -- no transpilation, no emulation.

  • Cross-runtime data sharing. The #set / #get macro system lets runtimes pass data to each other through Salata as a broker. Python can generate data, Ruby can transform it, and JavaScript can format the final output -- all in the same file, with JSON serialization handled transparently.

  • Output-agnostic. Salata does not assume you are generating HTML. Runtime blocks print to stdout, and whatever they print becomes the output. Generate an nginx config with Python, a JSON API response with JavaScript, or a Markdown report with Ruby -- it all works the same way.

  • Context-aware PHP. Salata automatically selects the right PHP binary based on the execution context: php for CLI, php-cgi for CGI, and php-fpm for FastCGI and the dev server. This mirrors how PHP itself works with different SAPIs.

  • Built-in shell sandbox. The shell runtime includes a hardcoded security sandbox with command blacklists, blocked patterns, path restrictions, environment stripping, ulimit enforcement, and timeout monitoring. No external sandboxing tools required.

  • Four binaries, one core. The salata CLI interpreter, salata-cgi bridge, salata-fastcgi daemon (stub), and salata-server dev server all share the same core library. Build once, deploy however you need.

Who is Salata for

Salata is aimed at developers who want to:

  • Experiment with polyglot templating and see how different languages can cooperate in a single document
  • Learn about language interoperability, process management, and cross-runtime data exchange
  • Build text-processing pipelines where each stage uses the best language for the job
  • Prototype web pages that combine server-side logic from multiple languages
  • Generate any kind of text output (configs, reports, data files) using familiar languages

A quick look

Here is a .slt file that uses three runtimes to build a sales report. Python generates raw data, Ruby aggregates it, and JavaScript formats the output:

<!-- Python generates the raw data -->
<python>
import json

sales = [
    {"product": "Widget A", "region": "North", "amount": 1200},
    {"product": "Widget B", "region": "South", "amount": 850},
    {"product": "Widget A", "region": "South", "amount": 2100},
    {"product": "Widget C", "region": "North", "amount": 675},
]

#set("raw_sales", sales)
</python>

<!-- Ruby aggregates by product -->
<ruby>
sales = #get("raw_sales")

totals = {}
sales.each do |sale|
  name = sale["product"]
  totals[name] ||= 0
  totals[name] += sale["amount"]
end

sorted = totals.sort_by { |_, v| -v }.map { |k, v| {"product" => k, "total" => v} }
#set("product_totals", sorted)
</ruby>

<!-- JavaScript formats the final report -->
<javascript>
const totals = #get("product_totals");

println("=== Sales Summary ===");
println("");
totals.forEach((item, i) => {
    println(`  ${i + 1}. ${item.product.padEnd(10)} $${item.total}`);
});
</javascript>

Run it:

salata report.slt

Output:

=== Sales Summary ===

  1. Widget A   $3300
  2. Widget B   $850
  3. Widget C   $675

Three languages, one file, one command. Each runtime does what it does best.

Next steps

Installation

Salata is built from source using the Rust toolchain. There are no pre-built binaries or package manager packages at this time.

Prerequisites

Rust toolchain

Salata requires a working Rust installation with cargo. The recommended way to install Rust is through rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

On Windows, download and run the installer from rustup.rs.

Salata targets the stable Rust toolchain. Any recent stable version (1.70+) should work. Verify your installation:

rustc --version
cargo --version

At least one runtime

Salata needs at least one language runtime installed on your system to be useful. The supported runtimes are:

RuntimeBinaryCommon locations
Pythonpython3 (or python)/usr/bin/python3, /usr/local/bin/python3, /opt/homebrew/bin/python3
Rubyruby/usr/bin/ruby, /usr/local/bin/ruby, /opt/homebrew/bin/ruby
JavaScriptnode/usr/bin/node, /usr/local/bin/node, /opt/homebrew/bin/node
TypeScripttsx, ts-node, or bun/usr/local/bin/tsx, /usr/local/bin/ts-node
PHPphp (CLI), php-cgi (CGI)/usr/bin/php, /usr/bin/php-cgi, /opt/homebrew/bin/php
Shellbash, sh, zsh, fish, dash, ash/bin/bash, /bin/sh, /usr/bin/zsh

You do not need all six. Salata detects which runtimes are available and disables the rest. If your .slt file uses a disabled runtime, Salata will report a clear error.

Tip: If you do not want to install runtimes locally, use the Docker Playground instead. It comes with all six runtimes pre-installed.

Building from source

Clone the repository and build in release mode:

git clone https://github.com/nicholasgasior/salata.git
cd salata
cargo build --release

This produces four binaries in target/release/:

BinaryPurpose
salataCore CLI interpreter. Processes .slt files and writes output to stdout.
salata-cgiCGI bridge with attack protections. Built, but nginx/Apache integration not yet tested.
salata-fastcgiFastCGI daemon (stub -- not yet implemented).
salata-serverStandalone dev server -- the only tested way to serve .slt over HTTP right now.

You can copy these binaries wherever you like. The only requirement is that a config.toml file must be present next to the binary or specified via the --config flag. Without a config file, none of the binaries will run.

Note: The build produces all four binaries from a Cargo workspace. You cannot build them individually without the workspace root Cargo.toml.

Initializing a project

After building, the fastest way to get started is the salata init command. It scans your system for available runtimes, generates a config.toml with the correct binary paths and enabled/disabled flags, and creates starter files:

./target/release/salata init

Or specify a target directory:

./target/release/salata init --path ./my-project

The init command:

  1. Detects runtimes -- checks well-known paths and falls back to which (Unix) or where (Windows) for each of the six runtimes
  2. Generates config.toml -- with detected paths, enabled = true for found runtimes and enabled = false for missing ones
  3. Creates index.slt -- a starter template using the first available runtime (prefers Python, then Node.js, Ruby, Shell, PHP, TypeScript)
  4. Creates errors/404.slt and errors/500.slt -- default error page templates

Example output:

Detecting runtimes...
  python         /usr/bin/python3  (Python 3.12.3)
  ruby           /usr/bin/ruby  (ruby 3.2.2)
  javascript     /usr/local/bin/node  (v20.11.0)
  typescript     /usr/local/bin/tsx  (tsx v4.7.0)
  php            /usr/bin/php  (PHP 8.3.2)
  php-cgi        /usr/bin/php-cgi  (PHP 8.3.2)
  shell          /bin/bash  (GNU bash, version 5.2.26)
Created config.toml with 6 of 6 runtimes enabled.
Run: salata index.slt

If a runtime is not found, it will show:

  typescript     not found — will be disabled

The generated config.toml will have enabled = false for that runtime.

Runtime discovery scripts

For environments where salata init is not available (or if you prefer a shell-based approach), Salata includes standalone runtime discovery scripts in the scripts/ directory:

ScriptPlatform
scripts/detect-runtimes.shLinux, macOS (Bash)
scripts/detect-runtimes.batWindows (CMD)
scripts/detect-runtimes.ps1Windows (PowerShell)

These scripts scan for the same runtimes as salata init and generate a config.toml file. They are useful if you want to generate a config without building Salata first, or if you are setting up a deployment environment.

# Linux / macOS
bash scripts/detect-runtimes.sh > config.toml

# Windows CMD
scripts\detect-runtimes.bat > config.toml

# PowerShell
powershell -File scripts\detect-runtimes.ps1 > config.toml

Cross-platform notes

Salata is designed to run on macOS, Linux, and Windows across x64, x86, and ARM architectures. There is no platform-specific code in the Salata codebase itself.

Platform considerations:

  • macOS (including Apple Silicon): Homebrew-installed runtimes are detected at /opt/homebrew/bin/. System Python at /usr/bin/python3 is typically available.
  • Linux: Most distributions include Python and Bash by default. Other runtimes can be installed via your package manager (apt, dnf, pacman, etc.).
  • Windows: Runtime detection uses where instead of which. Shell paths must still be absolute. Common paths differ (C:\Python312\python.exe, etc.). The detection scripts handle these differences.

Tip: The config.toml file uses absolute paths to runtime binaries. If you move runtimes or switch between system and Homebrew installations, re-run salata init or update the paths manually.

Alternative: Docker playground

If you want to try Salata without installing anything locally (beyond Docker), the playground container has everything pre-configured:

cd playground
./start-playground.sh

See the Playground Guide for full details.

Verifying the installation

After building and initializing, verify everything works:

# Check the version
./target/release/salata --version

# Process the starter file
./target/release/salata index.slt

You should see HTML output with "Hello from Salata!" (the exact runtime used depends on what was detected on your system).

Next steps

Quick Start

This guide gets you from zero to running Salata output in under 5 minutes. Choose the path that suits your setup.

Option A: Docker Playground (fastest)

The playground is a Docker container with all six runtimes, editors, and pre-built Salata binaries. No local Rust toolchain needed.

Prerequisites: Docker and Docker Compose v2.

1. Start the playground

From the Salata repository root:

cd playground
./start-playground.sh

On Windows CMD:

cd playground
start-playground.bat

On PowerShell:

cd playground
.\start-playground.ps1

The first run builds the Docker image, which takes a few minutes (installing runtimes, compiling Salata). Subsequent runs start instantly.

2. You are inside the container

When the container starts, you will see a welcome banner showing all detected runtimes and their versions. You land in /home/playground with a pre-generated config.toml and index.slt.

3. Run the starter file

salata index.slt

Expected output:

<!DOCTYPE html>
<html>
<head><title>Salata</title></head>
<body>
<h1>Hello from Salata!</h1>
</body>
</html>

4. Try an example

The playground comes with pre-loaded examples:

salata --config examples/cli/hello-world/config.toml \
       examples/cli/hello-world/python.slt

Output:

Hello from Python!

5. Start the dev server

salata-server . --port 3000

Open http://localhost:3000 in your browser (port 3000 is forwarded from the container to your host).


Option B: Local build

Build Salata from source and run it directly on your machine.

Prerequisites: Rust toolchain (rustup), at least one runtime installed (Python, Ruby, Node.js, PHP, or Bash).

1. Clone and build

git clone https://github.com/nicholasgasior/salata.git
cd salata
cargo build --release

2. Initialize a project

./target/release/salata init

This detects your installed runtimes, generates config.toml, and creates index.slt plus error page templates. You will see output like:

Detecting runtimes...
  python         /usr/bin/python3  (Python 3.12.3)
  ruby           not found — will be disabled
  javascript     /usr/local/bin/node  (v20.11.0)
  typescript     /usr/local/bin/tsx  (tsx v4.7.0)
  php            not found — will be disabled
  php-cgi        not found — will be disabled
  shell          /bin/bash  (GNU bash, version 5.2.26)
Created config.toml with 4 of 6 runtimes enabled.
Run: salata index.slt

3. Run the starter file

./target/release/salata index.slt

Expected output (varies depending on which runtime was detected first):

<!DOCTYPE html>
<html>
<head><title>Salata</title></head>
<body>
<h1>Hello from Salata!</h1>
</body>
</html>

4. Write your own template

Create a file called hello.slt:

<python>
print("Hello from Salata!")
print("1 + 1 =", 1 + 1)
</python>

Run it:

./target/release/salata hello.slt

Output:

Hello from Salata!
1 + 1 = 2

5. Pipe output to a file

Salata writes to stdout, so you can redirect output anywhere:

./target/release/salata hello.slt > output.txt
./target/release/salata template.slt > config.yml
./target/release/salata report.slt | less

What just happened

When you run salata your-file.slt, the following pipeline executes:

  1. Salata reads the .slt file
  2. Resolves any #include directives (text substitution)
  3. Parses the content, finding runtime blocks (<python>, <ruby>, etc.) and plain text
  4. Expands #set / #get macros into native code for each runtime
  5. Executes each runtime block in its native interpreter, capturing stdout
  6. Splices the captured output back into the document in place of the runtime tags
  7. Writes the final result to stdout

Plain text (including HTML, CSS, and anything outside runtime blocks) passes through untouched. Only the content inside runtime tags gets executed and replaced.

Next steps

Your First .slt File

This chapter walks through creating, running, and understanding .slt template files step by step.

What is an .slt file

An .slt file is a text file that can contain:

  • Plain text -- passed through to the output unchanged (HTML, Markdown, JSON, anything)
  • Runtime blocks -- code wrapped in language tags (<python>...</python>, <ruby>...</ruby>, etc.) that gets executed, with stdout captured and placed at the tag's position
  • Directives -- instructions like #include, #status, and #content-type that control processing behavior
  • Macros -- #set and #get calls inside runtime blocks for cross-runtime data sharing

Step 1: A minimal .slt file

Create a file called hello.slt with one runtime block:

<python>
print("Hello from Salata!")
</python>

Make sure you have a config.toml in the same directory (run salata init if you have not already). Then run it:

salata hello.slt

Output:

Hello from Salata!

That is the entire flow. Salata found the <python> block, executed it with your system's Python interpreter, captured the print() output, and wrote it to stdout.

Step 2: Mixing plain text and code

Runtime blocks can be mixed freely with plain text. Everything outside the tags passes through untouched:

<!DOCTYPE html>
<html>
<head><title>My Page</title></head>
<body>
  <h1>Welcome</h1>
  <p>The current time is:
  <python>
from datetime import datetime
print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
  </python>
  </p>
</body>
</html>

Output:

<!DOCTYPE html>
<html>
<head><title>My Page</title></head>
<body>
  <h1>Welcome</h1>
  <p>The current time is:
  2026-02-22 14:30:45
  </p>
</body>
</html>

The HTML structure passes through unchanged. Only the <python> block is replaced by its output.

Note: <style> and <script> tags are client-side HTML tags. Salata passes them through untouched -- they are not runtime blocks. Only <python>, <ruby>, <javascript>, <typescript>, <php>, and <shell> are Salata runtime tags.

Step 3: Hello world in all six languages

Here is "Hello from [language]!" in each of the six supported runtimes. Each uses the language's native stdout mechanism:

Python (hello-python.slt):

<python>
print("Hello from Python!")
</python>

Ruby (hello-ruby.slt):

<ruby>
puts "Hello from Ruby!"
</ruby>

JavaScript (hello-js.slt):

<javascript>
println("Hello from JavaScript!");
</javascript>

TypeScript (hello-ts.slt):

<typescript>
const greeting: string = "Hello from TypeScript!";
println(greeting);
</typescript>

PHP (hello-php.slt):

<php>
echo "Hello from PHP!\n";
</php>

Shell (hello-shell.slt):

<shell>
echo "Hello from Shell!"
</shell>

Tip: JavaScript and TypeScript get injected print() and println() helper functions. print() writes without a trailing newline (like process.stdout.write()), and println() adds a newline. These are additive -- console.log() still works as usual.

Step 4: Output is not limited to HTML

Salata does not care what your code prints. The output format is determined entirely by the runtime blocks. Here is a .slt file that generates a JSON document:

<python>
import json

data = {
    "name": "salata",
    "version": "0.1.0",
    "runtimes": ["python", "ruby", "javascript", "typescript", "php", "shell"]
}

print(json.dumps(data, indent=2))
</python>

Run it and redirect to a file:

salata api-response.slt > response.json

The resulting response.json:

{
  "name": "salata",
  "version": "0.1.0",
  "runtimes": ["python", "ruby", "javascript", "typescript", "php", "shell"]
}

You can generate YAML, TOML, CSV, Markdown, nginx configs, Dockerfiles -- anything that can be represented as text.

Step 5: Multiple runtimes in one file

The real power of Salata shows when you combine runtimes. Each runtime block executes in order, top to bottom:

<python>
print("Step 1 (Python): Generating data...")
</python>

---

<ruby>
puts "Step 2 (Ruby): Processing..."
</ruby>

---

<javascript>
println("Step 3 (JavaScript): Formatting output.");
</javascript>

Output:

Step 1 (Python): Generating data...

---

Step 2 (Ruby): Processing...

---

Step 3 (JavaScript): Formatting output.

Each block runs in its own runtime's interpreter. Python blocks run in Python, Ruby blocks in Ruby, and so on. The plain text between blocks (the --- lines) passes through unchanged.

Step 6: Sharing data between runtimes

Runtimes are isolated from each other -- a Python variable is not visible in Ruby. To pass data between runtimes, use the #set / #get macros:

<python>
# Store data for other runtimes
items = ["apple", "banana", "cherry"]
#set("fruits", items)
#set("count", len(items))
</python>

<ruby>
# Retrieve data from Python
fruits = #get("fruits")
count = #get("count")

puts "Ruby received #{count} fruits:"
fruits.each { |f| puts "  - #{f}" }
</ruby>

<javascript>
// Retrieve the same data
const fruits = #get("fruits");
const count = #get("count");

println(`JavaScript confirms: ${count} fruits total.`);
println(`First fruit: ${fruits[0]}`);
</javascript>

Output:

Ruby received 3 fruits:
  - apple
  - banana
  - cherry
JavaScript confirms: 3 fruits total.
First fruit: apple

Salata acts as the data broker. Values are JSON-serialized when stored with #set and deserialized back into native types when retrieved with #get. Strings, numbers, booleans, arrays/lists, objects/dicts, and null are all supported.

Note: #set and #get are macros, not function calls. Salata expands them into runtime-specific native code before execution. They can only be used inside runtime blocks.

Step 7: Using #get with defaults

The #get macro accepts an optional default value for when a key has not been set:

<javascript>
const name = #get("username", "anonymous");
const theme = #get("theme", "dark");

println(`Welcome, ${name}! (theme: ${theme})`);
</javascript>

Output:

Welcome, anonymous! (theme: dark)

If "username" or "theme" had been set by a previous runtime block, those values would be used instead.

Step 8: Shared scope within a runtime

By default, all blocks of the same language share a single process. This means variables defined in one block are visible in later blocks of the same language:

<python>
x = 42
</python>

<p>Some plain text in between.</p>

<python>
# x is still defined because both Python blocks share the same process
print(f"x is {x}")
</python>

Output:


<p>Some plain text in between.</p>

x is 42

This is called shared scope and it is the default behavior. You can opt into isolated scope per runtime (via config.toml) or per block (via the scope="isolated" attribute) when you want blocks to run in separate processes.

What to try next

Now that you understand the basics of .slt files, here are some things to explore:

  • Pipe to files: salata template.slt > output.html to save the result
  • Use as a config generator: Write a .slt file that generates nginx or Docker configs
  • Explore the examples: The examples/cli/ directory has ready-to-run examples covering hello-world, cross-runtime pipelines, scope demos, config generation, JSON API mocking, and more
  • Start the dev server: salata-server . --port 3000 serves .slt files over HTTP with hot reload

Next steps

Playground Guide

The Salata Playground is a Docker container pre-loaded with all six runtimes, editors, and pre-built Salata binaries. It is the recommended way to try Salata, especially if you do not want to install language runtimes on your host system.

What is included

The playground container is built on Ubuntu and comes with:

Runtimes:

  • Python 3
  • Ruby
  • Node.js (LTS)
  • TypeScript via ts-node and tsx
  • PHP (CLI and CGI)
  • Shell: Bash, Dash, Zsh, and Fish

Editors:

  • nano
  • Vim
  • Neovim
  • Emacs (terminal mode)

Developer tools:

  • Rust stable toolchain (for recompiling Salata from source)
  • Git
  • bat (syntax-highlighted cat replacement)
  • Starship prompt

Salata binaries (pre-built):

  • salata -- CLI interpreter
  • salata-cgi -- CGI bridge
  • salata-fastcgi -- FastCGI stub
  • salata-server -- dev server

Starting the playground

The playground ships with cross-platform start scripts that handle building the Docker image and launching the container.

Linux / macOS:

cd playground
./start-playground.sh

Windows CMD:

cd playground
start-playground.bat

PowerShell:

cd playground
.\start-playground.ps1

Manual Docker Compose:

If you prefer to use Docker Compose directly:

docker compose -f playground/docker-compose.playground.yml up -d
docker exec -it salata-playground-1 bash --login

Note: The start scripts use docker compose run which gives you an interactive session directly. The docker compose up -d approach starts the container in the background, and you attach with docker exec.

First run

The first time you start the playground, Docker builds the image. This takes a few minutes because it:

  1. Installs all runtimes and tools from Ubuntu packages
  2. Sets up Node.js via nodesource
  3. Installs TypeScript tooling globally (typescript, ts-node, tsx)
  4. Installs the Rust stable toolchain
  5. Installs Starship prompt
  6. Copies the Salata source and runs cargo build --release
  7. Runs salata init to generate a starter project

Subsequent starts are nearly instant because the image is cached.

The welcome banner

When you enter the container, a welcome banner displays:

  ____    _    _        _  _____  _
 / ___|  / \  | |      / \|_   _|/ \
 \___ \ / _ \ | |     / _ \ | | / _ \
  ___) / ___ \| |___ / ___ \| |/ ___ \
 |____/_/   \_\_____/_/   \_\_/_/   \_\

 salata v0.1.0 — Polyglot Text Templating Engine

 Runtimes:
   Python ...... Python 3.12.3
   Ruby ........ ruby 3.2.2
   Node.js ..... v20.11.0
   TypeScript .. ts-node v10.9.2, tsx v4.7.0
   PHP ......... PHP 8.3.2
   Bash ........ GNU bash, version 5.2.26

 Quick start:
   salata index.slt              Process the starter file
   salata init --path mysite     Scaffold a new project
   salata-server . --port 3000   Start dev server (localhost:3000)
   cat index.slt                 See the starter file

The banner shows the exact versions of all installed runtimes, quick start commands, and a list of available examples.

Directory layout inside the container

/home/playground/              # Your working directory (HOME)
  ├── config.toml              # Pre-generated config (all runtimes enabled)
  ├── index.slt                # Starter template
  ├── errors/
  │   ├── 404.slt
  │   └── 500.slt
  ├── workspace/               # Bind-mounted to playground/workspace/ on host
  └── examples/                # Pre-loaded example projects
      ├── cli/
      │   ├── hello-world/
      │   ├── cross-runtime-pipeline/
      │   ├── scope-demo/
      │   ├── config-generator/
      │   ├── json-api-mock/
      │   ├── data-processing/
      │   ├── markdown-report/
      │   └── multi-format/
      └── web/
          └── ...

/opt/salata/                   # Salata source code (live mount from host)
/usr/local/bin/salata          # Pre-built salata binary
/usr/local/bin/salata-cgi      # Pre-built salata-cgi binary
/usr/local/bin/salata-fastcgi  # Pre-built salata-fastcgi binary
/usr/local/bin/salata-server   # Pre-built salata-server binary
/usr/local/bin/config.toml     # Symlink to /home/playground/config.toml

Workspace persistence

The playground/workspace/ directory on your host machine is bind-mounted to /home/playground/workspace/ inside the container. Any files you create in the workspace directory persist across container restarts.

# Inside the container
cd workspace
salata init --path .
salata index.slt

# Files are visible on your host at playground/workspace/

This is the recommended place to put your own .slt projects while experimenting.

Tip: Files created outside /home/playground/workspace/ (except in /opt/salata) are ephemeral and will be lost when the container is removed.

Port forwarding

Port 3000 is forwarded from the container to your host. This is used by salata-server:

# Inside the container
salata-server . --port 3000

Then open http://localhost:3000 in your browser on your host machine. The dev server processes .slt files on the fly and serves static files (CSS, JS, images) as-is.

Live source code

The Salata source code from your host repository is mounted at /opt/salata inside the container. This is a live bind mount -- changes you make to Rust source files on your host are immediately visible inside the container.

A named Docker volume (cargo-target) is used for the build artifacts so that the Linux build cache does not conflict with your host's (macOS/Windows) target/ directory.

Recompiling after code changes

If you modify the Salata source code (either on your host or inside the container at /opt/salata), use the rebuild-salata helper command to recompile and install the updated binaries:

rebuild-salata

This runs cargo build --release in /opt/salata and copies the four binaries to /usr/local/bin/. Output looks like:

Rebuilding salata from /opt/salata ...

   Compiling salata-core v0.1.0 (/opt/salata/crates/salata-core)
   Compiling salata-cli v0.1.0 (/opt/salata/crates/salata-cli)
   ...
    Finished `release` profile [optimized] target(s) in 12.34s

Done! All 4 binaries installed:
salata v0.1.0
  salata-cgi, salata-fastcgi, salata-server

Running the examples

The playground comes with pre-loaded examples in ~/examples/. Each example has its own config.toml and one or more .slt files.

Hello world (one file per runtime):

salata --config examples/cli/hello-world/config.toml \
       examples/cli/hello-world/python.slt
# Output: Hello from Python!

salata --config examples/cli/hello-world/config.toml \
       examples/cli/hello-world/ruby.slt
# Output: Hello from Ruby!

Cross-runtime pipeline (data flows Python -> Ruby -> JavaScript):

salata --config examples/cli/cross-runtime-pipeline/config.toml \
       examples/cli/cross-runtime-pipeline/pipeline.slt

List all available examples:

ls examples/cli examples/web

Scaffolding a new project

Use salata init inside the container to scaffold new projects:

# In the persistent workspace
cd workspace
salata init --path my-site
cd my-site
salata index.slt

Since all runtimes are available in the container, the generated config.toml will have all six runtimes enabled.

Stopping the playground

If you used the start scripts (start-playground.sh, etc.), just type exit or press Ctrl+D. The container is removed automatically (the --rm flag is used).

If you started with docker compose up -d:

docker compose -f playground/docker-compose.playground.yml down

Your workspace files persist in playground/workspace/ regardless of how you stop the container.

Rebuilding the Docker image

If the Dockerfile changes (new runtimes, updated base image, etc.), rebuild the image:

docker compose -f playground/docker-compose.playground.yml build --no-cache

Or delete the image and let the start script rebuild it:

docker image rm salata-playground
./playground/start-playground.sh

Next steps

SLT Syntax

Salata processes .slt files -- plain text files with embedded runtime blocks. The format is intentionally simple: anything outside a runtime block passes through to the output unchanged, and anything inside a runtime block is executed server-side with its stdout captured and spliced into the output.

File Format

A .slt file is just text. It can contain HTML, JSON, YAML, plain prose, configuration syntax, or anything else. Salata does not parse or validate the surrounding text -- it only looks for runtime block tags.

<!DOCTYPE html>
<html>
<head><title>Hello</title></head>
<body>
  <h1>Static heading</h1>
  <python>
    print("<p>This paragraph is generated by Python.</p>")
  </python>
</body>
</html>

In this example, everything outside the <python>...</python> block is emitted as-is. The Python block executes, its print() output replaces the block, and the final result is a complete HTML page.

Runtime Blocks

Six runtime tags are available:

  • <python>...</python>
  • <ruby>...</ruby>
  • <javascript>...</javascript>
  • <typescript>...</typescript>
  • <php>...</php>
  • <shell>...</shell>

Each tag tells Salata to execute the enclosed code using the corresponding runtime. Whatever the code prints to stdout replaces the entire tag (opening tag, code, closing tag) in the output.

Today's date is: <shell>date +%Y-%m-%d</shell>

Output:

Today's date is: 2026-02-22

Pass-Through Tags

<style> and <script> tags are client-side -- Salata passes them through untouched. They are not runtime blocks and their contents are never executed server-side.

<style>
  body { font-family: sans-serif; }
</style>

<script>
  document.title = "Client-side JS";
</script>

This distinction matters: <script> is client-side JavaScript that runs in the browser, while <javascript> is server-side JavaScript executed by Node.js during template processing.

No Nesting

Runtime tags cannot be nested inside other runtime tags. This is a parse-time error:

<!-- THIS IS INVALID -->
<python>
  print("<ruby>puts 'hello'</ruby>")
</python>

Salata will reject this file with a parse error before any code executes. If you need one runtime to influence another, use the #set/#get macro system for cross-runtime data sharing.

Execution Order

Blocks execute top-to-bottom, synchronously. Each block finishes before the next one starts. This means you can rely on ordering:

<python>
  import time
  print(f"<p>Started at {time.strftime('%H:%M:%S')}</p>")
</python>

<ruby>
  puts "<p>Ruby runs after Python finishes.</p>"
</ruby>

Python always completes before Ruby begins.

Automatic Dedenting

Code inside runtime blocks is automatically dedented. Salata strips the common leading whitespace from all lines in a block, so you can indent your code naturally within the HTML structure without worrying about extra spaces being passed to the runtime:

<body>
  <div class="content">
    <python>
      for i in range(3):
          print(f"<p>Item {i}</p>")
    </python>
  </div>
</body>

The Python code is dedented before execution, so the for loop starts at column 0 from Python's perspective. This avoids IndentationError in Python and keeps your .slt files readable.

Encoding

UTF-8 is enforced everywhere -- input files, runtime output, and final output. All runtimes are invoked with UTF-8 encoding settings. Non-UTF-8 input will produce an error.

Output Is Not Restricted to HTML

While HTML is the most common use case, .slt files can produce any text format. The output is whatever the runtime blocks print, combined with the static text outside the blocks:

# Generated config
<python>
  services = ["web", "api", "worker"]
  for svc in services:
      print(f"[service.{svc}]")
      print(f"enabled = true")
      print()
</python>

This produces a TOML configuration file, not HTML. Salata is format-agnostic.

Runtime Blocks

Salata supports six server-side runtime blocks. Each block is delimited by its opening and closing tag, and the code inside is executed by the corresponding language runtime. The stdout output of each block replaces the block in the final output.

Python

<python>
  name = "world"
  print(f"<h1>Hello, {name}!</h1>")
</python>

Python blocks are executed by the Python 3 interpreter (configured via runtimes.python.path). Use print() for output. Multiple print() calls accumulate output as expected.

Ruby

<ruby>
  items = %w[apple banana cherry]
  items.each do |item|
    puts "<li>#{item}</li>"
  end
</ruby>

Ruby blocks are executed by the Ruby interpreter (configured via runtimes.ruby.path). Use puts for output with a trailing newline, or print for output without one.

JavaScript

<javascript>
  const colors = ["red", "green", "blue"];
  colors.forEach(c => {
    println(`<span style="color:${c}">${c}</span>`);
  });
</javascript>

JavaScript blocks are executed by Node.js (configured via runtimes.javascript.path). You have several output options:

  • console.log() -- standard Node.js, appends a newline
  • process.stdout.write() -- standard Node.js, no newline
  • print() -- Salata-injected helper, no newline (equivalent to process.stdout.write() with space-joined arguments)
  • println() -- Salata-injected helper, appends a newline

The print() and println() helpers are additive -- they do not override or replace console.log or any other built-in. They are injected before your code runs for convenience.

TypeScript

<typescript>
  interface User {
    name: string;
    age: number;
  }
  const user: User = { name: "Alice", age: 30 };
  println(`<p>${user.name} is ${user.age} years old.</p>`);
</typescript>

TypeScript blocks work the same as JavaScript blocks, with the same print()/println() helpers injected. The TypeScript runner is configurable -- you can use ts-node, tsx, bun, or deno by setting the runtimes.typescript.path in your config.

PHP

<php>
  $items = ["one", "two", "three"];
  foreach ($items as $item) {
      echo "<li>$item</li>\n";
  }
</php>

PHP blocks use echo for output. PHP is context-aware: the binary used depends on the execution context:

ContextBinary
CLI (salata)php (via cli_path)
CGI (salata-cgi)php-cgi (via cgi_path)
FastCGI / Serverphp-fpm (via fastcgi_socket or fastcgi_host)

See PHP runtime configuration for details on configuring the PHP paths.

Shell

<shell>
  echo "System uptime:"
  uptime
</shell>

Shell blocks are executed by the configured shell (default: /bin/bash). Use echo for output. Shell is the most restricted runtime -- it runs inside a sandbox with:

  • A hardcoded whitelist of allowed shell paths
  • Pre-execution scanning for blocked commands and patterns
  • A cleaned environment with stripped variables
  • Timeout, memory, and output size limits

See the Shell Sandbox documentation for full details.

Output Methods Summary

RuntimePrimary OutputAlternatives
Pythonprint()sys.stdout.write()
Rubyputsprint, $stdout.write()
JavaScriptconsole.log()print(), println(), process.stdout.write()
TypeScriptconsole.log()print(), println(), process.stdout.write()
PHPechoprint, printf()
Shellechoprintf

Nesting Rules

Runtime tags cannot be nested inside other runtime tags. The following is a parse-time error:

<!-- INVALID: nested runtime tags -->
<python>
  print("<javascript>console.log('no')</javascript>")
</python>

Salata detects this during parsing and rejects the file before any execution occurs. The output of print() is treated as plain text, not parsed for additional runtime tags.

To pass data between runtimes, use the #set/#get macro system.

The scope Attribute

By default, all blocks of the same language share a single process (shared scope). You can opt a specific block out of shared scope by adding the scope="isolated" attribute to its opening tag:

<python>
  x = 42
  print(f"x = {x}")
</python>

<python scope="isolated">
  # This block runs in a fresh process.
  # The variable x from the previous block is NOT available here.
  print("This is isolated.")
</python>

See Scope (Shared vs Isolated) for a full explanation of scope behavior.

Automatic Dedenting

Code inside runtime blocks is automatically dedented before execution. Salata strips the common leading whitespace from all lines in a block. This allows you to indent code naturally within your document structure:

<div>
  <ul>
    <python>
      for i in range(5):
          print(f"<li>Item {i}</li>")
    </python>
  </ul>
</div>

The Python code is dedented so the for statement starts at column 0 from Python's perspective, avoiding indentation errors.

Directives

Directives are pre-execution instructions that Salata resolves before any runtime blocks execute. They control HTTP response metadata, file inclusion, and routing behavior.

Key rule: Directives appear outside runtime blocks only. Placing a directive inside a <python>, <ruby>, or any other runtime block is invalid. For data sharing between runtime blocks, use macros instead.

#include

C-style text substitution. The referenced file's contents are pasted in place of the directive.

Syntax:

#include "path/to/file.slt"

Rules:

  • Included files can contain runtime blocks, directives, and static text
  • Included runtime blocks participate in shared scope (variables are visible)
  • Maximum include depth: 16 levels (deeper recursion produces an error)
  • Paths are relative to the file containing the #include

Example:

#include "includes/header.slt"

<h1>Page Content</h1>

<python>
  print("<p>Dynamic content here.</p>")
</python>

#include "includes/footer.slt"

See Includes for detailed usage patterns.

#status

Sets the HTTP response status code.

Syntax:

#status CODE

Rules:

  • Only once per page (multiple #status directives produce a parse error)
  • Default: 200
  • Any runtime failure automatically overrides this to 500
  • No runtime block can set the status code -- only this directive can

Examples:

#status 404

<h1>Page Not Found</h1>
<p>The requested page does not exist.</p>
#status 201

<python>
  import json
  print(json.dumps({"created": True}))
</python>

#content-type

Sets the response MIME type.

Syntax:

#content-type MIME_TYPE

Rules:

  • Only once per page (multiple produces a parse error)
  • Default: text/html; charset=utf-8

Examples:

#content-type application/json

<python>
  import json
  data = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
  print(json.dumps(data, indent=2))
</python>
#content-type text/plain

This is a plain text response.
<shell>echo "Generated at: $(date)"</shell>
#content-type text/csv

<ruby>
  puts "name,email,age"
  puts "Alice,alice@example.com,30"
  puts "Bob,bob@example.com,25"
</ruby>

Adds a custom HTTP response header.

Syntax:

#header "Header-Name" "value"

Rules:

  • Can appear multiple times (each adds a header)
  • Both the header name and value must be quoted

Examples:

#header "X-Powered-By" "Salata"
#header "Cache-Control" "no-cache, no-store, must-revalidate"
#header "X-Request-Id" "abc-123"

<h1>Hello</h1>

Sets a response cookie.

Syntax:

#cookie "name" "value" [flags...]

Rules:

  • Can appear multiple times (each sets a different cookie)
  • The cookie name and value must be quoted
  • Optional flags follow the value, space-separated: httponly, secure, samesite=Strict, samesite=Lax, samesite=None, path=/..., max-age=SECONDS, domain=...

Examples:

#cookie "session" "abc123" httponly secure

<h1>Welcome back</h1>
#cookie "theme" "dark" path=/ max-age=31536000
#cookie "lang" "en" path=/ samesite=Lax

<python>
  print("<p>Preferences saved.</p>")
</python>

#redirect

Issues an HTTP redirect response.

Syntax:

#redirect "destination"

Examples:

#redirect "/login"
#redirect "/dashboard"

When a #redirect directive is present, the response body is typically empty since the client will follow the redirect.

Summary

DirectiveRepeatableWherePurpose
#includeYesOutside blocksPaste file contents in place
#statusNoOutside blocksSet HTTP status code
#content-typeNoOutside blocksSet response MIME type
#headerYesOutside blocksAdd custom response header
#cookieYesOutside blocksSet response cookie
#redirectNoOutside blocksIssue HTTP redirect

Macros (#set / #get)

Macros are Salata's cross-runtime data bridge. They allow runtime blocks written in different languages to share data with each other. Unlike directives, macros work inside runtime blocks only.

Overview

  • #set("key", value) -- store a value under a named key
  • #get("key") -- retrieve a value by key
  • #get("key", default) -- retrieve a value with a fallback default

Salata expands these macros into native code for each language before execution. The runtimes never communicate directly -- Salata acts as the broker, serializing data to JSON and deserializing it back into native types.

#set

Stores a value under a named key. The value is JSON-serialized automatically.

Syntax (inside a runtime block):

#set("key", value)

Examples:

<python>
  users = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
  #set("users", users)
  #set("user_count", len(users))
</python>
<ruby>
  config = { "debug" => false, "version" => "1.0" }
  #set("app_config", config)
</ruby>

#get

Retrieves a previously stored value by key. The JSON data is deserialized into the native type for the receiving language.

Syntax (inside a runtime block):

#get("key")
#get("key", default)

If the key does not exist and no default is provided, the result is null (or the language's equivalent: None in Python, nil in Ruby, null in JavaScript/PHP).

Example with default:

<javascript>
  const count = #get("user_count", 0);
  println(`There are ${count} users.`);
</javascript>

Supported Types

The macro system handles these types transparently across all runtimes:

TypePythonRubyJavaScriptTypeScriptPHP
StringstrStringstringstringstring
Number (int)intIntegernumbernumberint
Number (float)floatFloatnumbernumberfloat
BooleanboolTrueClass/FalseClassbooleanbooleanbool
Array/ListlistArrayArrayArrayarray
Object/DictdictHashObjectObjectarray (assoc)
NullNonenilnullnullnull

How It Works

When Salata encounters #set or #get in a runtime block, it expands them into native code before passing the block to the runtime for execution.

For example, a #set("users", users) in a Python block might be expanded into code that JSON-serializes the users variable and writes it to a temporary file. A subsequent #get("users") in a JavaScript block would be expanded into code that reads that file and parses the JSON back into a native JavaScript object.

The key points:

  1. Expansion happens before execution -- the runtime sees native code, not macro syntax
  2. JSON is the interchange format -- data is serialized to JSON by the setter and deserialized by the getter
  3. Salata is the broker -- runtimes never communicate directly with each other
  4. Data is stored as JSON files in a temporary directory managed by Salata

Cross-Runtime Example

A common pattern is to generate data in one language and consume it in another:

<python>
  import datetime

  report = {
      "title": "Monthly Report",
      "generated": datetime.datetime.now().isoformat(),
      "items": [
          {"name": "Revenue", "value": 50000},
          {"name": "Expenses", "value": 32000},
          {"name": "Profit", "value": 18000}
      ]
  }
  #set("report", report)
</python>

<ruby>
  report = #get("report")
  puts "<h1>#{report['title']}</h1>"
  puts "<p>Generated: #{report['generated']}</p>"
</ruby>

<javascript>
  const report = #get("report");
  println("<table>");
  println("<tr><th>Metric</th><th>Value</th></tr>");
  for (const item of report.items) {
    println(`<tr><td>${item.name}</td><td>$${item.value.toLocaleString()}</td></tr>`);
  }
  println("</table>");
</javascript>

In this example:

  1. Python creates the report data and stores it with #set
  2. Ruby retrieves the data and renders the heading
  3. JavaScript retrieves the same data and renders the table

Each runtime gets the data as its native type -- Python dict becomes Ruby Hash becomes JavaScript Object.

Multi-Step Pipeline

You can chain data through multiple runtimes, with each step transforming and re-storing:

<python>
  raw_data = [5, 3, 8, 1, 9, 2, 7]
  #set("numbers", raw_data)
</python>

<ruby>
  numbers = #get("numbers")
  sorted = numbers.sort
  #set("sorted_numbers", sorted)
  puts "<p>Sorted: #{sorted.join(', ')}</p>"
</ruby>

<javascript>
  const sorted = #get("sorted_numbers");
  const sum = sorted.reduce((a, b) => a + b, 0);
  const avg = sum / sorted.length;
  println(`<p>Average: ${avg.toFixed(2)}</p>`);
</javascript>

Known Limitations

Shell blocks and #set/#get: The macro expansion generates parenthesized function-call syntax (e.g., #set("key", value)), which is not valid shell syntax. Shell uses space-separated arguments, not parenthesized calls. If you need to pass data to or from shell blocks, use another runtime as an intermediary:

<python>
  #set("greeting", "Hello from Python")
</python>

<!-- This works: Python sets, JavaScript gets -->
<javascript>
  const msg = #get("greeting");
  println(msg);
</javascript>

<!-- Avoid: #set/#get in shell blocks will produce syntax errors -->

Use Python, Ruby, JavaScript, TypeScript, or PHP for data sharing. Shell blocks are best used for standalone commands that do not need cross-runtime data.

Includes

The #include directive provides C-style text substitution: the contents of the referenced file are pasted directly in place of the directive. This enables reusable templates, shared layouts, and modular .slt file organization.

Syntax

#include "path/to/file.slt"

The path is relative to the file containing the #include directive.

How It Works

When Salata encounters an #include directive, it reads the referenced file and inserts its entire contents at that position. This happens during the pre-processing phase, before any runtime blocks are executed.

Given this file structure:

project/
  index.slt
  includes/
    header.slt
    footer.slt

includes/header.slt:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>My Site</title>
  <style>
    body { font-family: sans-serif; margin: 2rem; }
  </style>
</head>
<body>
  <nav>
    <a href="/">Home</a>
    <a href="/about">About</a>
  </nav>

includes/footer.slt:

  <footer>
    <p>
      <python>
        import datetime
        print(f"&copy; {datetime.datetime.now().year} My Site")
      </python>
    </p>
  </footer>
</body>
</html>

index.slt:

#include "includes/header.slt"

  <h1>Welcome</h1>

  <python>
    print("<p>This is the home page.</p>")
  </python>

#include "includes/footer.slt"

After include resolution, Salata processes the combined document as if it were a single file. The Python block in footer.slt executes just like any other block in the page.

Included Files Can Contain Anything

Included files can contain:

  • Static text and HTML
  • Runtime blocks (<python>, <ruby>, etc.)
  • Directives (#status, #content-type, #header, etc.)
  • Other #include directives (nested includes)
<!-- includes/meta.slt -->
#content-type text/html; charset=utf-8
#header "X-Powered-By" "Salata"
#include "includes/meta.slt"
#include "includes/header.slt"

<h1>Page with included meta directives</h1>

#include "includes/footer.slt"

Shared Scope Across Includes

Runtime blocks in included files participate in shared scope. Variables defined in an included file are visible to blocks in the main file (and vice versa), as long as they are the same language and shared scope is active.

includes/setup.slt:

<python>
  site_name = "My Site"
  version = "2.1.0"
</python>

index.slt:

#include "includes/setup.slt"

<python>
  # site_name and version are available here because
  # both Python blocks share the same process.
  print(f"<h1>{site_name} v{version}</h1>")
</python>

This works because all Python blocks (regardless of which file they originate from) run in the same process under shared scope.

Maximum Include Depth

Includes can be nested up to 16 levels deep. This prevents infinite recursion from circular includes. If the depth limit is exceeded, Salata produces a clear error:

Error: Maximum include depth (16) exceeded. Check for circular includes.

For example, this chain is valid (3 levels):

index.slt
  -> includes/layout.slt
    -> includes/nav.slt
      -> includes/logo.slt

But a circular reference is caught and rejected:

<!-- a.slt -->
#include "b.slt"

<!-- b.slt -->
#include "a.slt"

Common Patterns

Shared Layout

A typical pattern is to split your layout into header and footer includes:

#include "includes/header.slt"

<!-- Page-specific content -->
<h1>About Us</h1>
<p>This is the about page.</p>

#include "includes/footer.slt"

Reusable Components

Create reusable blocks that generate common UI elements:

includes/user-table.slt:

<python>
  users = #get("users", [])
  if users:
      print("<table>")
      print("<tr><th>Name</th><th>Email</th></tr>")
      for u in users:
          print(f"<tr><td>{u['name']}</td><td>{u['email']}</td></tr>")
      print("</table>")
  else:
      print("<p>No users found.</p>")
</python>

page.slt:

<python>
  #set("users", [
      {"name": "Alice", "email": "alice@example.com"},
      {"name": "Bob", "email": "bob@example.com"}
  ])
</python>

#include "includes/user-table.slt"

Configuration Includes

Keep directives in a shared file to ensure consistent headers across pages:

includes/security-headers.slt:

#header "X-Content-Type-Options" "nosniff"
#header "X-Frame-Options" "DENY"
#header "Referrer-Policy" "strict-origin-when-cross-origin"
#include "includes/security-headers.slt"
#include "includes/header.slt"

<h1>Secure Page</h1>

#include "includes/footer.slt"

Scope (Shared vs Isolated)

Salata supports two scope modes that control how runtime blocks of the same language interact: shared scope (the default) and isolated scope.

Shared Scope (Default)

By default, all blocks of the same language run in a single process. Variables, functions, imports, and any other state defined in one block are visible to subsequent blocks of that language.

Under the hood, Salata concatenates all blocks of a given language and sends them to one process, separated by boundary markers (__SALATA_BLOCK_BOUNDARY__). The runtime executes the concatenated code sequentially, and Salata splits the captured output at the boundary markers to splice each block's output back into its correct position in the document.

Example: Shared Scope

<python>
  greeting = "Hello"
  count = 0
</python>

<p>Some static HTML in between.</p>

<python>
  # greeting and count are available here because both
  # Python blocks share the same process.
  count += 1
  print(f"<p>{greeting}, visitor #{count}!</p>")
</python>

Output:


<p>Some static HTML in between.</p>

<p>Hello, visitor #1!</p>

The first block defines greeting and count. The second block can access and modify them because they run in the same Python process.

Shared Scope Across Includes

Shared scope extends across #include boundaries. If a main file and its included files all contain Python blocks, those blocks all share one Python process:

<!-- setup.slt -->
<python>
  app_name = "MyApp"
</python>
<!-- index.slt -->
#include "setup.slt"

<python>
  print(f"<h1>Welcome to {app_name}</h1>")
</python>

The app_name variable from setup.slt is available in index.slt because both Python blocks run in the same process.

Language Isolation

Shared scope applies within a language only. Different languages are always isolated from each other -- a Python block cannot see Ruby variables, and vice versa:

<python>
  secret = "python-only"
</python>

<ruby>
  # `secret` is NOT available here. Ruby has its own process.
  # Use #set/#get macros for cross-language data sharing.
  puts "<p>Ruby cannot see Python variables.</p>"
</ruby>

To share data between languages, use the #set/#get macro system.

Isolated Scope

Isolated scope gives a block its own fresh process. No state carries over from previous blocks, and no state leaks to subsequent blocks.

There are two ways to enable isolated scope:

Per-Block: The scope Attribute

Add scope="isolated" to the opening tag of any runtime block:

<python>
  x = 42
  print(f"<p>x = {x}</p>")
</python>

<python scope="isolated">
  # This block runs in a separate, fresh Python process.
  # x is NOT defined here.
  try:
      print(f"<p>x = {x}</p>")
  except NameError:
      print("<p>x is not defined in this scope.</p>")
</python>

<python>
  # This block is back in the shared process.
  # x is still available from the first block.
  print(f"<p>x is still {x} in shared scope.</p>")
</python>

Output:

<p>x = 42</p>

<p>x is not defined in this scope.</p>

<p>x is still 42 in shared scope.</p>

The scope="isolated" block gets a completely fresh environment. The shared-scope blocks (first and third) still share state with each other.

Per-Runtime: Configuration

Set shared_scope = false in the runtime's configuration to make all blocks of that language use isolated scope:

[runtimes.python]
enabled = true
path = "/usr/bin/python3"
shared_scope = false

With this configuration, every <python> block runs in its own process. No state is shared between any Python blocks:

<python>
  x = 100
  print(f"<p>x = {x}</p>")
</python>

<python>
  # x is NOT available here -- each block is isolated.
  # This will raise a NameError.
  print(f"<p>x = {x}</p>")
</python>

When to Use Each Mode

Use Shared Scope When:

  • You want to define variables, functions, or imports once and reuse them across blocks
  • You are building a page incrementally with multiple blocks of the same language
  • You want included files to set up state that later blocks can use

Use Isolated Scope When:

  • A block should not be affected by (or affect) other blocks
  • You need a clean environment for a specific computation
  • You want to prevent variable name collisions between unrelated blocks
  • You are running untrusted or experimental code that should be sandboxed from the rest

How the Boundary Marker Works

For shared-scope blocks, Salata uses the marker __SALATA_BLOCK_BOUNDARY__ to separate output from different blocks within a single process. The flow is:

  1. Salata collects all shared-scope blocks for a given language
  2. Between each block's code, Salata injects a print statement that outputs the boundary marker
  3. The concatenated code is sent to one runtime process
  4. The runtime executes all blocks sequentially, producing output with boundary markers between sections
  5. Salata splits the output at the boundary markers
  6. Each section of output is spliced back into the document at the corresponding block's position

This is transparent to you as a user -- you write individual blocks and Salata handles the concatenation and splitting behind the scenes.

Configuration Reference

Salata uses a single config.toml file for all configuration. This file is mandatory -- none of the four binaries (salata, salata-cgi, salata-fastcgi, salata-server) will start without a valid config.

Config Lookup Order

  1. --config /path/to/config.toml flag (explicit path)
  2. config.toml in the same directory as the binary
  3. Error -- Salata refuses to run
# Explicit config path
salata --config /etc/salata/config.toml index.slt

# Looks for config.toml next to the salata binary
salata index.slt

Full Default config.toml

[salata]
display_errors = true
default_content_type = "text/html; charset=utf-8"
encoding = "utf-8"

[server]
hot_reload = true

[logging]
directory = "./logs"
rotation_max_size = "50MB"
rotation_max_files = 10

[logging.server]
access_log = "access.log"
error_log = "error.log"
format = "combined"

[logging.runtimes]
python = "python.log"
ruby = "ruby.log"
javascript = "javascript.log"
typescript = "typescript.log"
php = "php.log"
shell = "shell.log"

[runtimes.python]
enabled = true
path = "/usr/bin/python3"
shared_scope = true
display_errors = true

[runtimes.ruby]
enabled = true
path = "/usr/bin/ruby"
shared_scope = true

[runtimes.javascript]
enabled = true
path = "/usr/bin/node"
shared_scope = true

[runtimes.typescript]
enabled = true
path = "/usr/bin/ts-node"
shared_scope = true

[runtimes.php]
enabled = true
mode = "cgi"
cli_path = "/usr/bin/php"
cgi_path = "/usr/bin/php-cgi"
shared_scope = true

[runtimes.shell]
enabled = true
path = "/bin/bash"
shared_scope = true

[cgi]
header_timeout = "5s"
body_timeout = "30s"
min_data_rate = "100b/s"
max_url_length = 2048
max_header_size = "8KB"
max_header_count = 50
max_query_string_length = 2048
max_body_size = "10MB"
max_connections_per_ip = 20
max_total_connections = 200
max_execution_time = "30s"
max_memory_per_request = "128MB"
max_response_size = "50MB"
response_timeout = "60s"
block_dotfiles = true
block_path_traversal = true
blocked_extensions = [".toml", ".env", ".git", ".log"]
block_null_bytes = true
block_non_printable_headers = true
validate_content_length = true
max_child_processes = 10
allow_outbound_network = true

[errors]
page_404 = "./errors/404.slt"
page_500 = "./errors/500.slt"

Section Reference

[salata]

Global settings that apply across all binaries and runtimes.

FieldTypeDefaultDescription
display_errorsbooltrueShow runtime errors in the output. When false, errors are logged but not displayed. Individual runtimes can override this.
default_content_typestring"text/html; charset=utf-8"Default MIME type for responses when no #content-type directive is used.
encodingstring"utf-8"Enforced character encoding for all input and output.

[server]

Settings specific to salata-server.

FieldTypeDefaultDescription
hot_reloadbooltrueWatch for file changes and trigger reparse in dev mode.

See Server Configuration for details.

[logging]

Log file management.

FieldTypeDefaultDescription
directorystring"./logs"Log directory, relative to the binary location. Created on first run.
rotation_max_sizestring"50MB"Maximum size of a log file before rotation.
rotation_max_filesint10Maximum number of rotated log files to keep.

See Logging Configuration for the full logging reference.

[runtimes.*]

Each runtime has its own configuration section. Common fields shared by all runtimes:

FieldTypeDefaultDescription
enabledbooltrueEnable or disable this runtime.
pathstringvariesAbsolute path to the runtime binary.
shared_scopebooltrueAll blocks of this language share one process.
display_errorsbool(inherited)Override the global display_errors setting for this runtime.

PHP has additional fields for its context-aware binary selection:

FieldTypeDefaultDescription
modestring"cgi"PHP execution mode: "cgi" or "fastcgi".
cli_pathstring"/usr/bin/php"Path to the PHP CLI binary (used in CLI context).
cgi_pathstring"/usr/bin/php-cgi"Path to php-cgi (used in CGI context).
fastcgi_socketstring(none)Unix socket path for php-fpm (FastCGI/Server context).
fastcgi_hoststring(none)TCP host:port for php-fpm (FastCGI/Server context).

See Runtime Configuration for detailed per-runtime settings.

[cgi]

Security and resource limits for salata-cgi. These settings protect against common CGI attack vectors.

See CGI Configuration for the full reference with descriptions of each field.

[errors]

Custom error page templates.

FieldTypeDefaultDescription
page_404string"./errors/404.slt"Path to the 404 error page. Can be a .slt file.
page_500string"./errors/500.slt"Path to the 500 error page. Can be a .slt file.

Error pages can be .slt files with runtime blocks, so you can generate dynamic error pages. Paths are relative to the binary location.

Runtime Configuration

Each runtime is configured under its own [runtimes.*] section in config.toml. This chapter covers the common configuration fields and the specifics for each runtime.

Common Fields

All runtimes share these configuration fields:

enabled

Type: bool Default: true

Enables or disables the runtime. When a runtime is disabled:

  • Salata skips it during execution
  • If a .slt file uses a disabled runtime's tag, Salata produces a clear error: "Runtime 'python' is disabled in config.toml"
  • If all runtimes are disabled, Salata prints "No runtimes enabled. Enable at least one runtime in config.toml to process .slt files." and exits with a non-zero status
[runtimes.ruby]
enabled = false  # Ruby blocks will produce an error

path

Type: string Default: varies by runtime

Absolute path to the runtime binary. This must point to a valid executable on the system.

[runtimes.python]
path = "/usr/local/bin/python3.12"

shared_scope

Type: bool Default: true

When true, all blocks of this language run in a single process and share state (variables, imports, functions). When false, each block runs in its own fresh process.

[runtimes.python]
shared_scope = false  # Every <python> block gets a fresh process

See Scope (Shared vs Isolated) for detailed behavior.

display_errors

Type: bool Default: inherited from [salata] display_errors

Override the global error display setting for this specific runtime. When true, runtime errors are included in the output. When false, errors are logged but the output shows nothing (or an empty string) for the failed block.

Resolution order: runtime-specific display_errors -> global [salata] display_errors fallback.

[salata]
display_errors = false  # Global: hide errors

[runtimes.python]
display_errors = true   # Override: show Python errors anyway

Per-Runtime Configuration

Python

[runtimes.python]
enabled = true
path = "/usr/bin/python3"
shared_scope = true
display_errors = true

The path should point to a Python 3 interpreter. Common locations:

  • Linux: /usr/bin/python3
  • macOS (Homebrew): /usr/local/bin/python3 or /opt/homebrew/bin/python3
  • Custom virtualenv: /path/to/venv/bin/python3

Ruby

[runtimes.ruby]
enabled = true
path = "/usr/bin/ruby"
shared_scope = true

Common locations:

  • Linux: /usr/bin/ruby
  • macOS (Homebrew): /usr/local/bin/ruby or /opt/homebrew/bin/ruby
  • rbenv: ~/.rbenv/shims/ruby

JavaScript

[runtimes.javascript]
enabled = true
path = "/usr/bin/node"
shared_scope = true

The path should point to a Node.js binary. Common locations:

  • Linux: /usr/bin/node or /usr/local/bin/node
  • macOS (Homebrew): /usr/local/bin/node or /opt/homebrew/bin/node
  • nvm: ~/.nvm/versions/node/v20.x.x/bin/node

TypeScript

[runtimes.typescript]
enabled = true
path = "/usr/bin/ts-node"
shared_scope = true

The TypeScript runner is configurable. You can use any of these by changing the path:

  • ts-node -- the traditional TypeScript runner
  • tsx -- a faster alternative to ts-node
  • bun -- Bun's built-in TypeScript support
  • deno -- Deno's built-in TypeScript support
# Using tsx instead of ts-node
[runtimes.typescript]
path = "/usr/local/bin/tsx"

# Using bun
[runtimes.typescript]
path = "/usr/local/bin/bun"

PHP

PHP has the most complex configuration because it is context-aware -- different binaries are used depending on the execution context.

[runtimes.php]
enabled = true
mode = "cgi"
cli_path = "/usr/bin/php"
cgi_path = "/usr/bin/php-cgi"
# fastcgi_socket = "/run/php/php-fpm.sock"
# fastcgi_host = "127.0.0.1:9000"
shared_scope = true
FieldUsed WhenDescription
modeAlways"cgi" or "fastcgi" -- determines how PHP is invoked
cli_pathsalata (CLI context)Path to the php binary for command-line use
cgi_pathsalata-cgi (CGI context)Path to the php-cgi binary
fastcgi_socketsalata-fastcgi / salata-serverUnix socket path for php-fpm
fastcgi_hostsalata-fastcgi / salata-serverTCP address for php-fpm (e.g., "127.0.0.1:9000")

The binary selection follows the execution context:

BinaryContextPHP Binary Used
salataClicli_path (php)
salata-cgiCgicgi_path (php-cgi)
salata-fastcgiFastCgifastcgi_socket or fastcgi_host (php-fpm)
salata-serverServerfastcgi_socket or fastcgi_host (php-fpm)

Shell

[runtimes.shell]
enabled = true
path = "/bin/bash"
shared_scope = true

The shell runtime is the most restricted. The path must be one of the hardcoded allowed shells:

  • /bin/sh
  • /bin/bash
  • /bin/zsh
  • /usr/bin/sh
  • /usr/bin/bash
  • /usr/bin/zsh
  • /usr/bin/fish
  • /usr/bin/dash
  • /usr/bin/ash

Setting path to any other value will be rejected. This is a security boundary -- the whitelist is hardcoded in the binary and cannot be changed via configuration.

See the Shell Sandbox documentation for full details on shell security restrictions.

Runtime Detection Scripts

Salata ships with helper scripts that detect installed runtimes and generate a config.toml with the correct paths for your system:

  • Linux / macOS: scripts/detect-runtimes.sh
  • Windows CMD: scripts/detect-runtimes.bat
  • PowerShell: scripts/detect-runtimes.ps1
# Generate config.toml with detected runtime paths
./scripts/detect-runtimes.sh > config.toml

These scripts check standard locations for each runtime binary and produce a valid configuration file with enabled = false for any runtimes not found on the system.

Server Configuration

The [server] section in config.toml controls the behavior of salata-server, the standalone development and lightweight production server.

Configuration

[server]
hot_reload = true

hot_reload

Type: bool Default: true

When enabled, salata-server watches for file changes in the served directory and automatically reparses .slt files when they are modified. This also invalidates the parsed file cache, ensuring that the next request picks up the latest changes.

Set to false in production to avoid the overhead of file watching:

[server]
hot_reload = false

Usage

salata-server serves directories or individual .slt files over HTTP:

# Serve a directory on port 3000
salata-server ./my-site --port 3000

# Serve a single file
salata-server index.slt --port 3000

When serving a directory:

  • .slt files are processed through the Salata engine and the output is returned as the response
  • All other files (HTML, CSS, JavaScript, images, fonts, media) are served as static files with correct MIME types
  • Directory index files (e.g., index.slt) are served automatically when a directory path is requested

Framework Capabilities

salata-server is built on a mature Rust web framework (actix-web) that provides:

  • Cookies and headers -- full HTTP cookie and header handling
  • Sessions -- server-side session management
  • Redirects -- HTTP redirect responses
  • Compression -- gzip/brotli response compression
  • TLS/HTTPS -- built-in TLS support for secure connections
  • Keep-alive -- persistent HTTP connections
  • Chunked transfer -- streaming responses for large outputs
  • Content negotiation -- automatic content type handling
  • Static file serving -- efficient static file delivery with proper MIME types

Relationship to Other Binaries

salata-server depends on salata-cgi, which in turn depends on salata-core. The dependency chain is:

salata-server → salata-cgi → salata-core

This means salata-server includes all of the CGI security protections. The CGI configuration in [cgi] applies to requests processed by salata-server as well. See CGI Configuration for those settings.

Logging

When salata-server is running, it writes to the server log files configured in [logging.server]:

[logging.server]
access_log = "access.log"
error_log = "error.log"
format = "combined"

The access log records every request in the configured format. The error log captures server-level errors. Runtime errors from .slt file processing are written to the per-runtime log files (see Logging Configuration).

CGI Configuration

The [cgi] section in config.toml configures the security protections and resource limits for salata-cgi (and by extension, salata-server which depends on it). These settings defend against common CGI attack vectors.

Full Configuration

[cgi]
header_timeout = "5s"
body_timeout = "30s"
min_data_rate = "100b/s"
max_url_length = 2048
max_header_size = "8KB"
max_header_count = 50
max_query_string_length = 2048
max_body_size = "10MB"
max_connections_per_ip = 20
max_total_connections = 200
max_execution_time = "30s"
max_memory_per_request = "128MB"
max_response_size = "50MB"
response_timeout = "60s"
block_dotfiles = true
block_path_traversal = true
blocked_extensions = [".toml", ".env", ".git", ".log"]
block_null_bytes = true
block_non_printable_headers = true
validate_content_length = true
max_child_processes = 10
allow_outbound_network = true

Slowloris Protection

These settings defend against slowloris attacks, where a client sends data extremely slowly to tie up server resources.

header_timeout

Type: string (duration) Default: "5s"

Maximum time to wait for the client to finish sending HTTP headers. If the headers are not fully received within this window, the connection is dropped.

body_timeout

Type: string (duration) Default: "30s"

Maximum time to wait for the client to finish sending the request body. Applies to POST, PUT, and PATCH requests.

min_data_rate

Type: string (rate) Default: "100b/s"

Minimum acceptable data transfer rate from the client. If the client sends data slower than this rate, the connection is terminated. This prevents slow-rate denial-of-service attacks.

Request Limits

These settings cap the size and complexity of incoming requests.

max_url_length

Type: integer Default: 2048

Maximum length of the request URL in characters. Requests with longer URLs are rejected with a 414 status.

max_header_size

Type: string (size) Default: "8KB"

Maximum total size of all HTTP headers combined. Requests exceeding this are rejected.

max_header_count

Type: integer Default: 50

Maximum number of HTTP headers in a single request. Requests with more headers are rejected.

max_query_string_length

Type: integer Default: 2048

Maximum length of the query string portion of the URL. Requests with longer query strings are rejected.

max_body_size

Type: string (size) Default: "10MB"

Maximum size of the request body. Requests with larger bodies are rejected with a 413 status. This protects against memory exhaustion from large uploads.

Process Limits

These settings control resource consumption per request and across the server.

max_connections_per_ip

Type: integer Default: 20

Maximum number of simultaneous connections from a single IP address. Additional connections from the same IP are rejected. This limits the impact of a single client on server resources.

max_total_connections

Type: integer Default: 200

Maximum number of simultaneous connections across all clients. When this limit is reached, new connections are rejected until existing ones complete.

max_execution_time

Type: string (duration) Default: "30s"

Maximum time a single request's runtime execution can take. If the runtime blocks in a .slt file take longer than this, execution is terminated and a 500 error is returned.

max_memory_per_request

Type: string (size) Default: "128MB"

Maximum memory that can be consumed by the runtime processes handling a single request. If exceeded, the processes are terminated.

max_response_size

Type: string (size) Default: "50MB"

Maximum size of the generated response. If the output from runtime blocks exceeds this, the response is truncated and a 500 error is returned.

response_timeout

Type: string (duration) Default: "60s"

Maximum total time for generating and sending a response. This is a wall-clock timeout covering the entire request lifecycle.

Path Security

These settings protect against file system access attacks.

block_dotfiles

Type: bool Default: true

When true, requests for files starting with a dot (e.g., .env, .htaccess, .git/config) are blocked with a 403 status. This prevents accidental exposure of configuration and version control files.

block_path_traversal

Type: bool Default: true

When true, requests containing path traversal sequences (.., %2e%2e) are blocked. This prevents attackers from accessing files outside the document root.

blocked_extensions

Type: array of strings Default: [".toml", ".env", ".git", ".log"]

File extensions that are blocked from being served. Requests for files with these extensions return a 403 status. This prevents access to configuration files, environment files, and log files.

# Add additional blocked extensions
blocked_extensions = [".toml", ".env", ".git", ".log", ".bak", ".sql"]

Input Sanitization

These settings validate and sanitize incoming request data.

block_null_bytes

Type: bool Default: true

When true, requests containing null bytes (\0, %00) in the URL, headers, or body are rejected. Null byte injection is a common attack vector against C-based systems.

block_non_printable_headers

Type: bool Default: true

When true, requests with non-printable characters in HTTP headers are rejected. This prevents header injection attacks that use control characters.

validate_content_length

Type: bool Default: true

When true, the Content-Length header is validated against the actual body size. Mismatches are rejected. This prevents request smuggling attacks.

Runtime Sandboxing

These settings control how runtime processes are managed.

max_child_processes

Type: integer Default: 10

Maximum number of child runtime processes that can run simultaneously. This limits the total system resource consumption from concurrent requests.

allow_outbound_network

Type: bool Default: true

When true, runtime processes are allowed to make outbound network connections (HTTP requests, database connections, etc.). Set to false to restrict runtimes to local-only operations.

# Lock down: no outbound network from runtime code
[cgi]
allow_outbound_network = false

Logging Configuration

The [logging] section in config.toml controls where and how Salata writes log files. Logging is always active -- errors are written to log files regardless of the display_errors setting.

Configuration

[logging]
directory = "./logs"
rotation_max_size = "50MB"
rotation_max_files = 10

[logging.server]
access_log = "access.log"
error_log = "error.log"
format = "combined"

[logging.runtimes]
python = "python.log"
ruby = "ruby.log"
javascript = "javascript.log"
typescript = "typescript.log"
php = "php.log"
shell = "shell.log"

General Settings

directory

Type: string Default: "./logs"

The directory where all log files are stored, relative to the binary location. This directory is created automatically on first run. If the directory cannot be created, Salata reports an error and exits.

[logging]
directory = "/var/log/salata"

rotation_max_size

Type: string (size) Default: "50MB"

Maximum size of a single log file before it is rotated. When a log file reaches this size, it is renamed (e.g., python.log becomes python.log.1) and a new log file is started.

rotation_max_files

Type: integer Default: 10

Maximum number of rotated log files to keep. Older rotated files beyond this count are deleted. With the default setting of 10, you will have at most python.log plus python.log.1 through python.log.10.

Per-Runtime Log Files

Each runtime writes to its own log file within the logging directory. This separation makes it straightforward to diagnose issues with a specific runtime.

[logging.runtimes]
python = "python.log"
ruby = "ruby.log"
javascript = "javascript.log"
typescript = "typescript.log"
php = "php.log"
shell = "shell.log"

All runtime errors, warnings, and informational messages for a given language go to its dedicated log file. With the default directory = "./logs", the full paths would be:

logs/
  python.log
  ruby.log
  javascript.log
  typescript.log
  php.log
  shell.log

Server Log Files

When running salata-server, two additional log files are written:

[logging.server]
access_log = "access.log"
error_log = "error.log"
format = "combined"

access_log

Type: string Default: "access.log"

Records every HTTP request handled by salata-server. Written in the format specified by the format field.

error_log

Type: string Default: "error.log"

Records server-level errors (startup failures, connection errors, etc.). Runtime errors from .slt processing go to the per-runtime log files, not this file.

format

Type: string Default: "combined"

The format for access log entries. The "combined" format follows the standard Apache/nginx combined log format.

Log Entry Format

Runtime log entries follow this format:

[TIMESTAMP] [LEVEL] [RUNTIME] [FILE:LINE] MESSAGE

Examples:

[2026-02-21 14:32:05] [ERROR] [python] [index.slt:15] NameError: name 'x' is not defined
[2026-02-21 14:32:05] [INFO]  [shell]  [index.slt:42] Block executed successfully (12ms)
[2026-02-21 14:32:06] [ERROR] [ruby]   [report.slt:8] undefined method 'foo' for nil (NoMethodError)
[2026-02-21 14:32:07] [INFO]  [javascript] [app.slt:20] Block executed successfully (3ms)

The fields are:

  • TIMESTAMP -- date and time in YYYY-MM-DD HH:MM:SS format
  • LEVEL -- ERROR, WARN, or INFO
  • RUNTIME -- which language runtime produced the log entry
  • FILE:LINE -- the .slt file and line number where the block starts
  • MESSAGE -- the error message or status information

Relationship to display_errors

The display_errors setting (global or per-runtime) controls whether errors appear in the output. It does not affect logging. Errors are always written to log files regardless of the display_errors setting.

display_errorsOutputLog file
trueError shown in outputError logged
falseError hidden from outputError logged

This means you can safely set display_errors = false in production to hide error details from users while still having full error information in the log files for debugging.

Runtimes

Salata ships with support for six server-side runtimes. Each runtime corresponds to a tag you embed in your .slt files. When Salata encounters a runtime tag, it executes the code inside it using the appropriate interpreter, captures whatever the code writes to stdout, and splices that output back into the document at the tag's position.

The output is not restricted to HTML. Runtimes can print JSON, plain text, YAML, CSV, or any other text format. What matters is that it goes to stdout.

Supported Runtimes

LanguageTagOutput MethodNotes
Python<python>print()Python 3 required
Ruby<ruby>puts, print, STDOUT.write
JavaScript<javascript>console.log(), print(), println()print()/println() injected
TypeScript<typescript>console.log(), print(), println()print()/println() injected
PHP<php>echoContext-aware binary selection
Shell<shell>echo, printfSandboxed, hardcoded shell whitelist

Enabling and Disabling Runtimes

Every runtime has an enabled field in config.toml that defaults to true. You can disable any runtime you do not need:

[runtimes.ruby]
enabled = false

When a .slt file contains a tag for a disabled runtime, Salata produces a clear error message: Runtime 'ruby' is disabled in config.toml. If every runtime is disabled, Salata prints an informative message and exits with a non-zero status code.

Shared Scope

By default, all blocks of the same language within a single .slt file run in one process. This means variables, functions, and state persist across blocks of the same language on the same page:

<python>
name = "Alice"
</python>

<p>Some HTML in between.</p>

<python>
print(f"Hello, {name}!")
</python>

The second <python> block can access name because both blocks share the same Python process. You can disable shared scope globally per runtime (shared_scope = false in config) or per block (scope="isolated" attribute on the tag).

Cross-Runtime Communication

Each language is isolated from every other language. A Python block cannot directly access a variable defined in a Ruby block. To pass data between runtimes, use the #set and #get macros:

<python>
users = [{"name": "Alice"}, {"name": "Bob"}]
#set("users", users)
</python>

<javascript>
const users = #get("users");
println(`Found ${users.length} users`);
</javascript>

Salata acts as the broker. It expands the macros into native code for each language and handles JSON serialization and deserialization transparently. See the Directives and Macros chapter for full details.

Encoding

UTF-8 is enforced everywhere: all input files, all runtime output, all final output. There is no option to change this.

Configuration

Each runtime is configured under [runtimes.<name>] in config.toml. Common fields shared by all runtimes:

FieldTypeDefaultDescription
enabledbooltrueWhether this runtime is available
pathstringvariesAbsolute path to the runtime binary
shared_scopebooltrueWhether blocks share one process per page
display_errorsbool(unset)Override the global display_errors setting

PHP has additional fields for context-aware binary selection. See the PHP runtime page for details.

Runtime Pages

Each runtime has its own page with language-specific details, examples, and configuration:

Python Runtime

PropertyValue
Tag<python>
Output methodprint()
Default binary/usr/bin/python3
Shared scopetrue (default)

Overview

The Python runtime executes code using Python 3. Whatever Python writes to stdout via print() is captured and placed at the tag's position in the output document. Python 2 is not supported.

Output

Use print() to produce output. Each call to print() adds a newline by default. Use end="" to suppress it:

<python>
print("<ul>")
for item in ["Apples", "Oranges", "Bananas"]:
    print(f"  <li>{item}</li>")
print("</ul>")
</python>

For finer control, you can also write directly to sys.stdout:

import sys
sys.stdout.write("no trailing newline")

Shared Scope

With shared scope enabled (the default), all <python> blocks on the same page run in a single Python process. Variables, imports, and function definitions persist across blocks:

<python>
import json

def format_price(cents):
    return f"${cents / 100:.2f}"

products = [
    {"name": "Widget", "price": 1999},
    {"name": "Gadget", "price": 4550},
]
</python>

<h2>Product List</h2>

<python>
for p in products:
    print(f"<div>{p['name']}: {format_price(p['price'])}</div>")
</python>

Both blocks share the same process, so the second block can use products and format_price defined in the first.

Data Processing

Python excels at data manipulation, making it a natural fit for processing data inline:

<python>
import csv
import io

raw = """name,department,salary
Alice,Engineering,95000
Bob,Marketing,72000
Carol,Engineering,102000
Dave,Marketing,68000"""

reader = csv.DictReader(io.StringIO(raw))
rows = list(reader)

by_dept = {}
for row in rows:
    dept = row["department"]
    by_dept.setdefault(dept, []).append(row)

for dept, members in sorted(by_dept.items()):
    total = sum(int(m["salary"]) for m in members)
    avg = total / len(members)
    print(f"<h3>{dept}</h3>")
    print(f"<p>Headcount: {len(members)}, Average salary: ${avg:,.0f}</p>")
    print("<ul>")
    for m in members:
        print(f"  <li>{m['name']} - ${int(m['salary']):,}</li>")
    print("</ul>")
</python>

Cross-Runtime Data Bridge

Python works well as a data source for other runtimes via #set and #get:

<python>
stats = {
    "total_users": 1524,
    "active_today": 342,
    "conversion_rate": 0.067
}
#set("stats", stats)
</python>

<javascript>
const stats = #get("stats");
println(`<p>Conversion: ${(stats.conversion_rate * 100).toFixed(1)}%</p>`);
</javascript>

Salata serializes the Python dictionary to JSON and deserializes it into a JavaScript object transparently.

Configuration

[runtimes.python]
enabled = true
path = "/usr/bin/python3"
shared_scope = true
display_errors = true
FieldTypeDefaultDescription
enabledbooltrueEnable or disable the Python runtime
pathstring/usr/bin/python3Absolute path to the Python 3 binary
shared_scopebooltrueAll blocks share one process per page
display_errorsbool(global fallback)Override the global display_errors setting

Isolated Scope

To run a block in its own process, use the scope attribute:

<python scope="isolated">
# This block has its own Python process.
# Variables from other blocks are not available here.
x = 42
print(x)
</python>

You can also set shared_scope = false in the config to make isolation the default for all Python blocks.

Tips

  • Python 3 is required. Ensure path points to a Python 3 interpreter.
  • Use f-strings for readable output formatting.
  • Heavy imports (like pandas or numpy) are fine but will increase execution time on the first block. With shared scope, the import cost is paid only once per page.
  • The working directory during execution is the directory containing the .slt file being processed.

Ruby Runtime

PropertyValue
Tag<ruby>
Output methodputs, print, STDOUT.write
Default binary/usr/bin/ruby
Shared scopetrue (default)

Overview

The Ruby runtime executes code using the system Ruby interpreter. Whatever Ruby writes to stdout is captured and placed at the tag's position in the output document.

Output

Ruby provides several ways to write to stdout:

  • puts -- writes a string followed by a newline
  • print -- writes a string with no trailing newline
  • STDOUT.write -- writes raw bytes to stdout, returns the number of bytes written
  • $stdout.write -- equivalent to STDOUT.write
<ruby>
puts "<h1>Hello from Ruby</h1>"
print "<p>This has "
print "no newline between parts.</p>"
</ruby>

Shared Scope

With shared scope enabled (the default), all <ruby> blocks on the same page run in a single Ruby process. Variables, methods, and classes persist across blocks:

<ruby>
def greet(name)
  "Hello, #{name}!"
end

colors = %w[red green blue]
</ruby>

<div class="content">

<ruby>
colors.each do |color|
  puts %(<span style="color: #{color}">#{greet(color)}</span>)
end
</ruby>

String Formatting and Interpolation

Ruby's string interpolation and formatting methods make it well-suited for generating text output:

<ruby>
products = [
  { name: "Espresso", price: 3.50, stock: 42 },
  { name: "Latte",    price: 4.75, stock: 18 },
  { name: "Mocha",    price: 5.25, stock: 7  },
]

puts "<table>"
puts "  <tr><th>Product</th><th>Price</th><th>Status</th></tr>"
products.each do |p|
  status = p[:stock] < 10 ? "Low stock" : "Available"
  puts "  <tr>"
  puts "    <td>#{p[:name]}</td>"
  puts "    <td>#{'$%.2f' % p[:price]}</td>"
  puts "    <td>#{status}</td>"
  puts "  </tr>"
end
puts "</table>"
</ruby>

Text Manipulation

Ruby's built-in string and enumerable methods are effective for transforming text:

<ruby>
raw_text = <<~TEXT
  the quick brown fox
  jumps over the lazy dog
  pack my box with five dozen liquor jugs
TEXT

sentences = raw_text.strip.lines.map do |line|
  words = line.strip.split
  words.map.with_index { |w, i| i == 0 ? w.capitalize : w }.join(" ") + "."
end

puts "<ul>"
sentences.each { |s| puts "  <li>#{s}</li>" }
puts "</ul>"

word_count = raw_text.split.size
char_count = raw_text.gsub(/\s+/, "").size
puts "<p>Words: #{word_count}, Characters: #{char_count}</p>"
</ruby>

Cross-Runtime Data Bridge

Ruby integrates with other runtimes through the #set and #get macros:

<python>
menu = [
    {"dish": "Ramen", "cuisine": "Japanese", "spicy": True},
    {"dish": "Pad Thai", "cuisine": "Thai", "spicy": True},
    {"dish": "Risotto", "cuisine": "Italian", "spicy": False},
]
#set("menu", menu)
</python>

<ruby>
menu = #get("menu")
spicy = menu.select { |item| item["spicy"] }
puts "<h3>Spicy Dishes</h3>"
puts "<ul>"
spicy.each { |item| puts "  <li>#{item['dish']} (#{item['cuisine']})</li>" }
puts "</ul>"
</ruby>

Configuration

[runtimes.ruby]
enabled = true
path = "/usr/bin/ruby"
shared_scope = true
FieldTypeDefaultDescription
enabledbooltrueEnable or disable the Ruby runtime
pathstring/usr/bin/rubyAbsolute path to the Ruby binary
shared_scopebooltrueAll blocks share one process per page
display_errorsbool(global fallback)Override the global display_errors setting

Isolated Scope

To run a block in its own process:

<ruby scope="isolated">
# This block has its own Ruby process.
x = 99
puts x
</ruby>

Or set shared_scope = false in the config for all Ruby blocks.

Tips

  • Ruby's heredocs (<<~HEREDOC) are useful for multi-line template strings.
  • Use Struct or hashes for lightweight data structures within templates.
  • ERB is not involved -- Salata handles the template layer. Ruby blocks are plain Ruby code.
  • The working directory during execution is the directory containing the .slt file being processed.

JavaScript Runtime

PropertyValue
Tag<javascript>
Output methodconsole.log(), process.stdout.write(), print(), println()
Default binary/usr/bin/node
Shared scopetrue (default)

Overview

The JavaScript runtime executes code using Node.js. Whatever the code writes to stdout is captured and placed at the tag's position in the output document. Salata injects two convenience helpers, print() and println(), that complement the standard Node.js output methods.

Injected Helpers

Before your code runs, Salata prepends two helper functions:

const print = (...args) => process.stdout.write(args.join(' '));
const println = (...args) => process.stdout.write(args.join(' ') + '\n');

These are additive. Nothing is overridden. console.log() and process.stdout.write() continue to work exactly as they always do.

FunctionTrailing NewlineNotes
print("hello")NoInjected by Salata
println("hello")YesInjected by Salata
console.log("hello")YesStandard Node.js
process.stdout.write(s)NoStandard Node.js, accepts strings/buffers

Use print() and println() when you want concise output calls. Use console.log() and process.stdout.write() if you prefer sticking to standard Node.js idioms. They all work.

Basic Usage

<javascript>
println("<h1>Welcome</h1>");
println("<p>Generated by JavaScript at " + new Date().toISOString() + "</p>");
</javascript>

Shared Scope

With shared scope enabled (the default), all <javascript> blocks on the same page share a single Node.js process. Variables, functions, and classes persist across blocks:

<javascript>
const formatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
});

function formatRow(item) {
  return `<tr><td>${item.name}</td><td>${formatter.format(item.price)}</td></tr>`;
}

const items = [
  { name: "Keyboard", price: 79.99 },
  { name: "Mouse", price: 34.50 },
  { name: "Monitor", price: 449.00 },
];
</javascript>

<table>
  <tr><th>Item</th><th>Price</th></tr>

<javascript>
items.forEach(item => println(formatRow(item)));
const total = items.reduce((sum, i) => sum + i.price, 0);
println(`<tr><td><strong>Total</strong></td><td><strong>${formatter.format(total)}</strong></td></tr>`);
</javascript>

</table>

JSON Handling

JavaScript's native JSON support makes it a natural fit for working with structured data:

<javascript>
const data = {
  api: "v2",
  endpoints: [
    { path: "/users", methods: ["GET", "POST"] },
    { path: "/users/:id", methods: ["GET", "PUT", "DELETE"] },
    { path: "/health", methods: ["GET"] },
  ],
};

println("<h2>API Reference</h2>");
for (const ep of data.endpoints) {
  println(`<div class="endpoint">`);
  println(`  <code>${ep.path}</code>`);
  println(`  <span>${ep.methods.join(", ")}</span>`);
  println(`</div>`);
}
</javascript>

Template Literals

JavaScript template literals provide a readable way to build multi-line output:

<javascript>
const users = [
  { name: "Alice", role: "admin", active: true },
  { name: "Bob", role: "editor", active: false },
  { name: "Carol", role: "viewer", active: true },
];

for (const user of users) {
  const badge = user.active ? "active" : "inactive";
  print(`<div class="user-card ${badge}">
  <h3>${user.name}</h3>
  <p>Role: ${user.role}</p>
  <span class="badge">${badge}</span>
</div>
`);
}
</javascript>

Cross-Runtime Data Bridge

JavaScript integrates with other runtimes through #set and #get:

<python>
analytics = {
    "page_views": 15230,
    "unique_visitors": 4891,
    "bounce_rate": 0.342,
    "top_pages": ["/", "/about", "/pricing"]
}
#set("analytics", analytics)
</python>

<javascript>
const data = #get("analytics");
println(`<div class="stats">`);
println(`  <p>Page views: ${data.page_views.toLocaleString()}</p>`);
println(`  <p>Unique visitors: ${data.unique_visitors.toLocaleString()}</p>`);
println(`  <p>Bounce rate: ${(data.bounce_rate * 100).toFixed(1)}%</p>`);
println(`  <p>Top pages: ${data.top_pages.join(", ")}</p>`);
println(`</div>`);
</javascript>

Configuration

[runtimes.javascript]
enabled = true
path = "/usr/bin/node"
shared_scope = true
FieldTypeDefaultDescription
enabledbooltrueEnable or disable the JavaScript runtime
pathstring/usr/bin/nodeAbsolute path to the Node.js binary
shared_scopebooltrueAll blocks share one process per page
display_errorsbool(global fallback)Override the global display_errors setting

Isolated Scope

To run a block in its own Node.js process:

<javascript scope="isolated">
// This block has its own process.
const x = 42;
println(x);
</javascript>

Or set shared_scope = false in the config for all JavaScript blocks.

Important: <script> vs <javascript>

Do not confuse <javascript> with <script>. They are completely different:

  • <javascript> is a Salata runtime tag. The code runs server-side in Node.js, and the stdout output replaces the tag.
  • <script> is a standard HTML tag. Salata passes it through untouched. The code runs client-side in the browser.
<!-- Server-side: runs in Node.js, output replaces this tag -->
<javascript>
println('<div id="data" data-count="42"></div>');
</javascript>

<!-- Client-side: runs in the browser, passed through as-is -->
<script>
const count = document.getElementById("data").dataset.count;
alert("Count is " + count);
</script>

Tips

  • Modern ES features (destructuring, async/await, optional chaining) are available depending on your Node.js version.
  • Use print() for inline output without trailing newlines. Use println() or console.log() when you want newlines.
  • The working directory during execution is the directory containing the .slt file being processed.

TypeScript Runtime

PropertyValue
Tag<typescript>
Output methodconsole.log(), process.stdout.write(), print(), println()
Default binary/usr/bin/ts-node
Shared scopetrue (default)

Overview

The TypeScript runtime executes code with full type-checking support. It receives the same injected print() and println() helpers as JavaScript. The key difference is the runner: by default Salata uses ts-node, but you can configure it to use tsx, bun, or deno instead.

Injected Helpers

Identical to the JavaScript runtime, Salata injects two helpers before your code runs:

const print = (...args: any[]) => process.stdout.write(args.join(' '));
const println = (...args: any[]) => process.stdout.write(args.join(' ') + '\n');

These are additive. console.log() and process.stdout.write() continue to work normally.

Basic Usage

<typescript>
interface Product {
  name: string;
  price: number;
  inStock: boolean;
}

const products: Product[] = [
  { name: "Laptop", price: 999.99, inStock: true },
  { name: "Tablet", price: 499.99, inStock: false },
  { name: "Phone", price: 699.99, inStock: true },
];

println("<ul>");
for (const p of products) {
  const cls = p.inStock ? "available" : "sold-out";
  println(`  <li class="${cls}">${p.name} - $${p.price.toFixed(2)}</li>`);
}
println("</ul>");
</typescript>

Shared Scope

With shared scope enabled (the default), all <typescript> blocks on the same page share a single process. Interfaces, types, variables, and functions persist across blocks:

<typescript>
interface User {
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
}

function badge(role: User["role"]): string {
  const colors: Record<User["role"], string> = {
    admin: "red",
    editor: "blue",
    viewer: "gray",
  };
  return `<span style="color: ${colors[role]}">${role}</span>`;
}

const users: User[] = [
  { name: "Alice", email: "alice@example.com", role: "admin" },
  { name: "Bob", email: "bob@example.com", role: "editor" },
  { name: "Carol", email: "carol@example.com", role: "viewer" },
];
</typescript>

<h2>Team Members</h2>

<typescript>
for (const user of users) {
  println(`<div class="user">`);
  println(`  <strong>${user.name}</strong> ${badge(user.role)}`);
  println(`  <a href="mailto:${user.email}">${user.email}</a>`);
  println(`</div>`);
}
</typescript>

Type Safety

TypeScript's type system catches errors at execution time rather than producing silent bugs:

<typescript>
type Status = "pending" | "active" | "archived";

interface Task {
  title: string;
  status: Status;
  priority: number;
}

function renderTask(task: Task): string {
  const icons: Record<Status, string> = {
    pending: "[?]",
    active: "[*]",
    archived: "[-]",
  };
  return `${icons[task.status]} ${task.title} (priority: ${task.priority})`;
}

const tasks: Task[] = [
  { title: "Deploy v2", status: "active", priority: 1 },
  { title: "Write docs", status: "pending", priority: 2 },
  { title: "Fix bug #42", status: "archived", priority: 3 },
];

println("<pre>");
tasks
  .sort((a, b) => a.priority - b.priority)
  .forEach(t => println(renderTask(t)));
println("</pre>");
</typescript>

Configurable Runner

The TypeScript runner is configurable. Set the path field to whichever runner you prefer:

RunnerConfig path ExampleNotes
ts-node/usr/bin/ts-nodeDefault. Widely used, requires Node.js.
tsx/usr/local/bin/tsxFaster startup, esbuild-based.
bun/usr/local/bin/bunAll-in-one JS/TS runtime.
deno/usr/local/bin/denoSecure-by-default runtime.
# Use tsx instead of ts-node
[runtimes.typescript]
enabled = true
path = "/usr/local/bin/tsx"
shared_scope = true

Cross-Runtime Data Bridge

TypeScript works with the #set and #get macros just like other runtimes:

<typescript>
interface Config {
  theme: string;
  maxItems: number;
  features: string[];
}

const config: Config = {
  theme: "dark",
  maxItems: 25,
  features: ["search", "export", "notifications"],
};
#set("config", config);
</typescript>

<python>
config = #get("config")
for feature in config["features"]:
    print(f'<div class="feature">{feature}</div>')
</python>

Configuration

[runtimes.typescript]
enabled = true
path = "/usr/bin/ts-node"
shared_scope = true
FieldTypeDefaultDescription
enabledbooltrueEnable or disable the TypeScript runtime
pathstring/usr/bin/ts-nodeAbsolute path to the TypeScript runner
shared_scopebooltrueAll blocks share one process per page
display_errorsbool(global fallback)Override the global display_errors setting

Isolated Scope

To run a block in its own process:

<typescript scope="isolated">
const x: number = 42;
println(x.toString());
</typescript>

Or set shared_scope = false in the config for all TypeScript blocks.

Tips

  • The injected print() and println() helpers are identical to those in the JavaScript runtime.
  • If startup time matters, consider tsx or bun as the runner -- they are typically faster than ts-node.
  • TypeScript blocks have access to the full Node.js ecosystem (when using ts-node or tsx).
  • The working directory during execution is the directory containing the .slt file being processed.

PHP Runtime

PropertyValue
Tag<php>
Output methodecho
Shared scopetrue (default)

Overview

The PHP runtime is unique among Salata's runtimes because it uses context-aware binary selection. PHP itself has multiple Server API (SAPI) interfaces -- CLI, CGI, and FPM -- and they behave differently. Salata mirrors this model: depending on which Salata binary is running, a different PHP binary is used.

Output

Use echo to produce output. PHP's string interpolation and heredoc syntax also work:

<php>
$items = ["Apples", "Oranges", "Bananas"];

echo "<ul>\n";
foreach ($items as $item) {
    echo "  <li>$item</li>\n";
}
echo "</ul>\n";
</php>

Other output functions like print, printf, and var_dump write to stdout as well, but echo is the standard approach.

Context-Aware Binary Selection

This is the defining feature of the PHP runtime. Each Salata binary sets an execution context, and that context determines which PHP binary handles <php> blocks:

Salata BinaryExecution ContextPHP Binary UsedConfig Field
salata (CLI)Cliphpcli_path
salata-cgiCgiphp-cgicgi_path
salata-fastcgiFastCgiphp-fpm (socket/TCP)fastcgi_socket / fastcgi_host
salata-serverServerphp-fpm (socket/TCP)fastcgi_socket / fastcgi_host

Why This Matters

PHP's different SAPIs handle things like headers, input, and environment variables differently:

  • php (CLI) reads from stdin, writes to stdout, and has no concept of HTTP headers. This is what you want when running salata template.slt > output.html from the command line.

  • php-cgi follows the CGI protocol. It reads request data from environment variables (REQUEST_METHOD, QUERY_STRING, etc.) and can set HTTP headers through its output. This is the correct binary when Salata is running as a CGI program behind nginx or Apache.

  • php-fpm is a long-running FastCGI process manager. It listens on a Unix socket or TCP port and handles requests persistently. This is used when Salata runs as a FastCGI daemon or standalone server, where a persistent PHP process pool is more efficient than spawning a new process per request.

Salata handles this selection automatically. You configure the paths once, and the correct binary is chosen based on which Salata binary is invoked.

Configuration

[runtimes.php]
enabled = true
mode = "cgi"
cli_path = "/usr/bin/php"
cgi_path = "/usr/bin/php-cgi"
# fastcgi_socket = "/run/php/php-fpm.sock"
# fastcgi_host = "127.0.0.1:9000"
shared_scope = true
FieldTypeDefaultDescription
enabledbooltrueEnable or disable the PHP runtime
modestring"cgi"PHP mode: "cgi" or "fastcgi"
cli_pathstring/usr/bin/phpPath to php binary (used in CLI context)
cgi_pathstring/usr/bin/php-cgiPath to php-cgi binary (used in CGI context)
fastcgi_socketstring(unset)Unix socket path for php-fpm
fastcgi_hoststring(unset)TCP host:port for php-fpm (e.g., 127.0.0.1:9000)
shared_scopebooltrueAll blocks share one process per page
display_errorsbool(global fallback)Override the global display_errors setting

The mode Field

The mode field has two values:

  • "cgi" -- Used for CLI and CGI contexts. Salata spawns php or php-cgi as a child process.
  • "fastcgi" -- Used for FastCGI and Server contexts. Salata connects to an already-running php-fpm process via socket or TCP.

When using "fastcgi" mode, you must configure either fastcgi_socket or fastcgi_host (not both). A Unix socket is generally preferred for same-machine setups:

[runtimes.php]
enabled = true
mode = "fastcgi"
cli_path = "/usr/bin/php"
cgi_path = "/usr/bin/php-cgi"
fastcgi_socket = "/run/php/php-fpm.sock"
shared_scope = true

For remote or containerized php-fpm:

[runtimes.php]
enabled = true
mode = "fastcgi"
cli_path = "/usr/bin/php"
cgi_path = "/usr/bin/php-cgi"
fastcgi_host = "127.0.0.1:9000"
shared_scope = true

Shared Scope

With shared scope enabled (the default), all <php> blocks on the same page share a single PHP process. Variables and functions defined in one block are available in later blocks:

<php>
function formatCurrency($amount) {
    return '$' . number_format($amount, 2);
}

$products = [
    ['name' => 'Widget', 'price' => 19.99],
    ['name' => 'Gadget', 'price' => 45.50],
    ['name' => 'Doohickey', 'price' => 8.75],
];
</php>

<h2>Products</h2>

<php>
echo "<table>\n";
echo "  <tr><th>Product</th><th>Price</th></tr>\n";
foreach ($products as $p) {
    echo "  <tr><td>{$p['name']}</td><td>" . formatCurrency($p['price']) . "</td></tr>\n";
}
$total = array_sum(array_column($products, 'price'));
echo "  <tr><td><strong>Total</strong></td><td><strong>" . formatCurrency($total) . "</strong></td></tr>\n";
echo "</table>\n";
</php>

Cross-Runtime Data Bridge

PHP works with #set and #get macros for cross-runtime data sharing:

<python>
config = {
    "site_name": "My Store",
    "currency": "USD",
    "tax_rate": 0.08
}
#set("config", config)
</python>

<php>
$config = #get("config");
$price = 29.99;
$tax = $price * $config['tax_rate'];
$total = $price + $tax;

echo "<p>{$config['site_name']}</p>\n";
echo "<p>Price: $" . number_format($price, 2) . "</p>\n";
echo "<p>Tax: $" . number_format($tax, 2) . "</p>\n";
echo "<p>Total: $" . number_format($total, 2) . "</p>\n";
</php>

Isolated Scope

To run a block in its own process:

<php scope="isolated">
// This block has its own PHP process.
$x = 42;
echo $x;
</php>

Or set shared_scope = false in the config for all PHP blocks.

Tips

  • The <php> tag in Salata is not the same as <?php. Salata's PHP blocks do not use PHP's opening/closing tags -- the code inside <php> is pure PHP code executed directly.
  • When testing locally with salata (CLI), the cli_path binary is used. You do not need php-cgi or php-fpm installed for CLI-only usage.
  • For production CGI setups, ensure php-cgi is installed and the cgi_path is correct.
  • The working directory during execution is the directory containing the .slt file being processed.

Shell Runtime

PropertyValue
Tag<shell>
Output methodecho, printf
Default binary/bin/bash
Shared scopetrue (default)

Overview

The Shell runtime executes code in a sandboxed shell environment. It is the most restricted runtime in Salata. While the other runtimes have relatively open access to their language's full capabilities, the Shell runtime enforces a strict security sandbox with pre-execution analysis, environment lockdown, and runtime monitoring. This sandbox is baked into Salata itself -- it does not rely on external tools.

Output

Use echo or printf to produce output:

<shell>
echo "<h1>System Report</h1>"
echo "<p>Hostname: $(hostname)</p>"
echo "<p>Date: $(date '+%Y-%m-%d %H:%M:%S')</p>"
echo "<p>Kernel: $(uname -sr)</p>"
</shell>

Shell Whitelist

The allowed shells are hardcoded into Salata. This is a security boundary and cannot be changed through configuration:

Shell Path
/bin/sh
/bin/bash
/bin/zsh
/usr/bin/sh
/usr/bin/bash
/usr/bin/zsh
/usr/bin/fish
/usr/bin/dash
/usr/bin/ash

The shell path configured in config.toml must be an absolute path and must match one of these entries. Relative paths are rejected.

The Three-Phase Sandbox

Shell execution goes through three security phases before and during execution.

Phase 1: Pre-Execution Static Analysis

Before the shell code runs, Salata scans it for dangerous patterns. If any are found, execution is blocked with an error.

Blocked commands -- commands that could damage the system or escape the sandbox:

Examples include rm, sudo, su, kill, killall, shutdown, reboot, mkfs, dd, mount, umount, chown, chmod, python, ruby, node, perl, docker, kubectl, and others.

Blocked patterns -- syntax patterns that enable evasion or background execution:

PatternReason
&Background execution / job control
| bash, | shPiping into a shell
evalArbitrary code execution
execProcess replacement
Fork bombsDenial of service
/dev/tcpNetwork access via bash pseudo-devices

Blocked paths -- filesystem paths that should not be accessed from templates:

PathReason
/devDevice files (includes /dev/null)
/procProcess information filesystem
/sysKernel and hardware interface
/etcSystem configuration files

Note that /dev/null is blocked because it falls under the /dev path restriction. This means common patterns like command > /dev/null will not work in shell blocks.

Phase 2: Environment Setup

If the code passes static analysis, Salata sets up a restricted execution environment:

  • Clean PATH -- only essential directories are on the PATH, preventing access to arbitrary binaries.
  • Stripped environment variables -- sensitive environment variables are removed. The shell process starts with a minimal, sanitized environment.
  • Locked working directory -- the shell process runs in a controlled working directory.
  • ulimit enforcement -- resource limits are applied to prevent runaway processes (CPU time, memory, file descriptors, file size).

Phase 3: Runtime Monitoring

During execution, Salata monitors the shell process:

  • Timeout -- if the shell block takes too long, it is terminated.
  • Memory tracking -- excessive memory usage triggers termination.
  • Output size tracking -- if stdout grows beyond the configured limit, the process is stopped.

Known Limitations

Because of the sandbox, some common shell idioms do not work:

PatternWhy It Fails
command > /dev/null/dev is a blocked path
command 2>&1& is a blocked pattern
#set("key", value)Macro syntax produces invalid shell code
#get("key")Macro syntax produces invalid shell code
command && is a blocked pattern (no backgrounding)
eval "$var"eval is a blocked command

The #set and #get macro limitation means Shell cannot participate in the cross-runtime data bridge. Use other runtimes (Python, Ruby, JavaScript) for data sharing, and use Shell for system information and text processing tasks.

Example: System Information Report

<shell>
echo "<h2>Server Status</h2>"
echo "<table>"
echo "  <tr><th>Property</th><th>Value</th></tr>"
echo "  <tr><td>Hostname</td><td>$(hostname)</td></tr>"
echo "  <tr><td>Date</td><td>$(date '+%Y-%m-%d %H:%M:%S %Z')</td></tr>"
echo "  <tr><td>Uptime</td><td>$(uptime -p 2>/dev/null || uptime)</td></tr>"
echo "  <tr><td>Kernel</td><td>$(uname -sr)</td></tr>"
echo "  <tr><td>Architecture</td><td>$(uname -m)</td></tr>"
echo "  <tr><td>Shell</td><td>$BASH_VERSION</td></tr>"
echo "</table>"
</shell>

Example: Text Processing

Shell is effective for text processing with tools like awk, sed, and grep:

<shell>
echo "<h2>Disk Usage</h2>"
echo "<pre>"
df -h | head -10
echo "</pre>"
</shell>
<shell>
echo "<h2>Environment</h2>"
echo "<dl>"
echo "  <dt>User</dt><dd>$(whoami)</dd>"
echo "  <dt>Home</dt><dd>$HOME</dd>"
echo "  <dt>PWD</dt><dd>$PWD</dd>"
echo "</dl>"
</shell>

Shared Scope

With shared scope enabled (the default), all <shell> blocks share a single shell process. Variables set in one block are available in later blocks:

<shell>
SITE_NAME="My Website"
BUILD_DATE=$(date '+%Y-%m-%d')
</shell>

<shell>
echo "<footer>"
echo "  <p>$SITE_NAME - Built on $BUILD_DATE</p>"
echo "</footer>"
</shell>

Configuration

[runtimes.shell]
enabled = true
path = "/bin/bash"
shared_scope = true
FieldTypeDefaultDescription
enabledbooltrueEnable or disable the Shell runtime
pathstring/bin/bashAbsolute path to the shell (must be whitelisted)
shared_scopebooltrueAll blocks share one process per page
display_errorsbool(global fallback)Override the global display_errors setting

Isolated Scope

To run a block in its own shell process:

<shell scope="isolated">
echo "This runs in its own shell process."
</shell>

Or set shared_scope = false in the config for all Shell blocks.

When to Use Shell

Shell is best suited for:

  • System information (hostname, date, uname, whoami, uptime)
  • Environment variable access
  • Text processing with awk, sed, grep
  • Reading files with cat
  • Simple string manipulation

For anything involving data processing, cross-runtime communication, or complex logic, use Python, Ruby, or JavaScript instead.

Architecture Overview

Salata is a polyglot text templating engine built as a Rust Cargo workspace. It takes .slt files containing embedded runtime blocks, executes them server-side using the appropriate language interpreter, captures their stdout, and produces the final output. The output can be HTML, JSON, plain text, configuration files, or any other text format.

Workspace Structure

The project is organized as five Rust crates in a Cargo workspace:

salata/
  ├── Cargo.toml              # Workspace root
  ├── config.toml              # Mandatory configuration
  ├── crates/
  │   ├── salata-core/         # Shared library
  │   ├── salata-cli/          # CLI binary
  │   ├── salata-cgi/          # CGI binary
  │   ├── salata-fastcgi/      # FastCGI binary (stub)
  │   └── salata-server/       # Dev server binary
  ├── tests/                   # Integration tests and fixtures
  ├── errors/                  # Default error page templates
  └── logs/                    # Created at runtime

salata-core: The Foundation

salata-core is the shared library that all other crates depend on. It contains:

ModuleResponsibility
config.rsTOML configuration parsing and validation
context.rsExecutionContext enum (Cli, Cgi, FastCgi, Server)
parser.rs.slt file parsing, block extraction
directives.rs#include, #status, #content-type, #header, #cookie, #redirect
macros.rs#set/#get macro expansion into native code
runtime/Runtime implementations for all six languages
scope.rsShared and isolated scope management
cache.rsParsed file caching by path + mtime
logging.rsLog formatting and rotation
error.rsError types and display_errors logic
security.rsShell sandbox and command blacklist
uniform_ast/Future cross-language transpilation (placeholder)

Four Binaries

Salata produces four binary executables, each serving a different deployment context:

  • salata -- The core interpreter. Reads a .slt file and writes the processed output to stdout. No networking, no HTTP. Pure file-in, text-out.

  • salata-cgi -- A CGI bridge for web servers like nginx or Apache. Receives HTTP requests via the CGI protocol, processes the requested .slt file, and returns an HTTP response. Includes built-in security protections against CGI attack vectors.

  • salata-fastcgi -- A FastCGI daemon (currently a stub that prints "not yet implemented"). Will eventually listen on a Unix socket or TCP port for persistent request handling.

  • salata-server -- A standalone development server. Serves directories with .slt files processed on the fly and static files served as-is. Supports hot reload for development.

See the Binaries chapter for detailed descriptions of each.

Configuration

All four binaries require a config.toml file. Without it, they refuse to run. The config file is looked up in this order:

  1. --config /path/to/config.toml command-line flag
  2. config.toml in the same directory as the binary
  3. Error -- Salata exits with a message

Configuration covers runtime paths and settings, logging, CGI security limits, error pages, and server options. Every runtime can be individually enabled or disabled.

Cross-Platform

Salata targets macOS, Linux, and Windows across x64, x86, and ARM architectures. The codebase avoids platform-specific code:

  • File paths use std::path::PathBuf
  • Line endings are handled correctly on all platforms
  • Process spawning is platform-agnostic

Encoding

UTF-8 is enforced everywhere -- all input files, all runtime output, all final output, all configuration. There is no option to change this.

How Components Fit Together

                    ┌─────────────────┐
                    │   config.toml   │
                    └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              │              │              │
    ┌─────────▼──┐  ┌───────▼────┐  ┌──────▼──────┐
    │ salata-cli │  │ salata-cgi │  │salata-server│
    │  (CLI)     │  │  (CGI)     │  │  (HTTP)     │
    └─────────┬──┘  └───────┬────┘  └──────┬──────┘
              │              │              │
              └──────────────┼──────────────┘
                             │
                    ┌────────▼────────┐
                    │   salata-core   │
                    │                 │
                    │  parser         │
                    │  directives     │
                    │  macros         │
                    │  runtimes       │
                    │  scope          │
                    │  security       │
                    │  config         │
                    │  logging        │
                    └────────┬────────┘
                             │
              ┌──────┬───────┼───────┬──────┬──────┐
              │      │       │       │      │      │
            Python  Ruby    JS     TS    PHP   Shell

Each binary sets an ExecutionContext before calling into salata-core. This context flows through the entire processing pipeline and affects runtime behavior -- most notably, which PHP binary is selected. See Execution Context for details.

Logging

Each runtime gets its own log file (python.log, ruby.log, etc.) in the logs/ directory next to the binary. The server also maintains access.log and error.log. Log rotation is configured via rotation_max_size and rotation_max_files.

Caching

Salata caches the parsed structure of .slt files (block positions, includes) keyed by file path and modification time. This is a parse cache, not an output cache. When a file changes, the cache entry is invalidated and the file is re-parsed.

Error Handling

Error display is controlled by the display_errors setting, which can be set globally and overridden per runtime. Regardless of the display setting, all errors are written to the log files. When any runtime block fails, the HTTP status code is automatically set to 500, overriding any #status directive.

Binaries

Salata produces four executable binaries. Each one serves a different deployment scenario, but they all share the same core engine (salata-core) for parsing and executing .slt files.

salata (CLI Interpreter)

The core interpreter. It reads a .slt file, processes all runtime blocks, and writes the final output to stdout. There is no networking, no HTTP, and no web server involved. It is a pure file-in, text-out tool.

# Process a template and write output to a file
salata index.slt > output.html

# Generate JSON output
salata api-response.slt > data.json

# Generate a config file from a template
salata nginx.conf.slt > /etc/nginx/sites-available/mysite

# Use a specific config file
salata --config /path/to/config.toml template.slt

The output format is determined entirely by what the runtime blocks print. Salata does not impose any format.

salata init

The CLI includes a project scaffolding command:

salata init my-project

This creates a new directory with a starter .slt file, a default config.toml, and the standard directory structure to get started quickly.

Execution Context

The CLI binary sets ExecutionContext::Cli. This means PHP blocks use the php CLI binary (configured via cli_path).

salata-cgi (CGI Bridge)

A traditional CGI bridge designed to sit between a web server (nginx, Apache, etc.) and the Salata interpreter. The web server invokes salata-cgi for each request, passing request data through CGI environment variables. salata-cgi determines which .slt file to process, runs it through salata-core, and returns the result as an HTTP response.

Note: The salata-cgi binary and all its security protections are fully built and unit-tested. However, integration with actual nginx and Apache web servers has not been tested yet. Testing and configuration documentation for real web server setups are coming. For now, use salata-server to serve .slt files over HTTP.

Security Protections

salata-cgi includes built-in protections against common CGI attack vectors:

  • Slowloris defense -- configurable timeouts for headers and body, minimum data rate enforcement
  • Request limits -- maximum URL length, header size, header count, query string length, body size
  • Process limits -- connections per IP, total connections, execution time, memory per request, response size
  • Path security -- blocks path traversal attempts, dotfile access, and dangerous file extensions
  • Input validation -- blocks null bytes, non-printable characters in headers, validates content-length

All of these are configurable through the [cgi] section of config.toml.

Execution Context

The CGI binary sets ExecutionContext::Cgi. This means PHP blocks use the php-cgi binary (configured via cgi_path).

salata-fastcgi (Stub)

The FastCGI binary is a placeholder for future development. Currently, running it produces:

$ salata-fastcgi
Salata FastCGI v0.1.0 — not yet implemented

When implemented (planned as salata-fpm), it will be a persistent FastCGI daemon that listens on a Unix socket or TCP port for integration with nginx and Apache. Unlike CGI (which spawns a new process per request), FastCGI keeps a persistent process that handles multiple requests, reducing overhead.

Execution Context

When implemented, the FastCGI binary will set ExecutionContext::FastCgi. PHP blocks will use php-fpm via socket or TCP (configured via fastcgi_socket or fastcgi_host).

salata-server (Development Server)

A standalone HTTP server for development and lightweight production use. This is currently the only tested way to serve .slt files over HTTP. It depends on salata-cgi (and transitively on salata-core) and uses a Rust web framework (actix-web) for HTTP handling.

# Serve a directory on the default port
salata-server ./my-site

# Serve on a specific port
salata-server ./my-site --port 3000

# Serve a single file
salata-server index.slt --port 8080

Directory Serving

When pointed at a directory, salata-server handles files based on their type:

  • .slt files -- processed through the Salata engine, output sent as the HTTP response
  • Everything else -- served as static files with proper MIME types (HTML, CSS, JavaScript, images, fonts, media, etc.)

Framework Features

Because salata-server uses a mature Rust web framework, it inherits full HTTP server capabilities:

  • Cookies, headers, and sessions
  • Redirects and content negotiation
  • Compression (gzip, brotli)
  • TLS/HTTPS support
  • Keep-alive connections
  • Chunked transfer encoding
  • Static file serving with correct MIME types

Hot Reload

When hot_reload = true in the [server] section of config.toml (the default), the server watches for file changes and triggers a reparse. This means you can edit .slt files and see the results immediately without restarting the server.

Execution Context

The server binary sets ExecutionContext::Server. PHP blocks use php-fpm via socket or TCP (configured via fastcgi_socket or fastcgi_host).

Configuration Requirement

All four binaries require a config.toml file to run. If no config is found, the binary prints an error and exits. The lookup order is:

  1. --config /path/to/config.toml flag (explicit path)
  2. config.toml in the same directory as the binary
  3. Error -- refuse to run

Log Directory

All binaries write logs to a logs/ directory next to the binary. This directory is created automatically on first run. Each runtime gets its own log file, and salata-server additionally maintains access.log and error.log.

Execution Context

Salata is context-aware. Each of the four binaries sets an ExecutionContext before invoking salata-core, and this context flows through the entire processing pipeline. The context is defined as a Rust enum:

#![allow(unused)]
fn main() {
enum ExecutionContext {
    Cli,
    Cgi,
    FastCgi,
    Server,
}
}

Which Binary Sets Which Context

BinaryContext
salata (CLI)Cli
salata-cgiCgi
salata-fastcgiFastCgi
salata-serverServer

The context is set once at startup and does not change during the lifetime of the process.

Why Context Matters

The primary effect of the execution context is PHP binary selection. PHP has multiple Server API (SAPI) interfaces, and using the wrong one produces incorrect behavior. Salata mirrors PHP's own model:

ContextPHP Binary UsedConfig Field
Cliphp (CLI SAPI)cli_path
Cgiphp-cgi (CGI SAPI)cgi_path
FastCgiphp-fpm via socket or TCPfastcgi_socket / fastcgi_host
Serverphp-fpm via socket or TCPfastcgi_socket / fastcgi_host

Why Different PHP Binaries

PHP's SAPIs differ in how they handle input, output, and HTTP semantics:

  • php (CLI) -- reads from stdin, writes to stdout, has no awareness of HTTP headers. This is the correct choice when Salata is invoked from the command line and the output is written to a file or piped to another program.

  • php-cgi -- implements the CGI protocol. It reads request metadata from environment variables (REQUEST_METHOD, QUERY_STRING, etc.) and can emit HTTP headers in its output. This is necessary when Salata runs behind a web server as a CGI program.

  • php-fpm -- a persistent FastCGI process manager. It maintains a pool of PHP worker processes and communicates over Unix sockets or TCP. This is the efficient choice for persistent server contexts where spawning a new process per request would be wasteful.

Context in the Pipeline

The execution context is passed as a parameter through the processing pipeline. When salata-core encounters a <php> block, it checks the current context to determine which binary to invoke:

salata-cli sets context = Cli
  → salata-core receives context
    → parser extracts <php> block
      → runtime module checks context
        → context is Cli → use cli_path ("/usr/bin/php")
        → context is Cgi → use cgi_path ("/usr/bin/php-cgi")
        → context is FastCgi → connect to fastcgi_socket or fastcgi_host
        → context is Server → connect to fastcgi_socket or fastcgi_host

Effect on Other Runtimes

For runtimes other than PHP, the execution context currently has no effect on behavior. Python, Ruby, JavaScript, TypeScript, and Shell all use the same binary regardless of context. The context is still available to these runtimes in case future features need it.

Request Data

In CGI, FastCGI, and Server contexts, HTTP request data is made available to runtimes through standard CGI environment variables:

VariableDescription
REQUEST_METHODHTTP method (GET, POST, etc.)
QUERY_STRINGURL query parameters
CONTENT_TYPERequest body MIME type
CONTENT_LENGTHRequest body size in bytes
HTTP_HOSTHost header value
HTTP_COOKIECookie header value
REMOTE_ADDRClient IP address
REQUEST_URIFull request URI
PATH_INFOExtra path information
SERVER_NAMEServer hostname
SERVER_PORTServer port number
HTTP_AUTHORIZATIONAuthorization header value

In the CLI context, these variables are not set (there is no HTTP request).

Processing Pipeline

Every .slt file that Salata processes goes through the same pipeline, regardless of which binary initiated the request. This chapter describes each step in order.

Pipeline Steps

1. Request Arrives

The pipeline starts when a .slt file needs to be processed. This can happen in several ways:

  • CLI: the user runs salata template.slt
  • CGI: a web server invokes salata-cgi with CGI environment variables pointing to a .slt file
  • Server: salata-server receives an HTTP request for a .slt file

The invoking binary sets the ExecutionContext (Cli, Cgi, FastCgi, or Server) and passes it to salata-core along with the file path.

2. Read the .slt File

Salata reads the .slt file from disk. If the file does not exist, an error is returned (which becomes an HTTP 404 in web contexts or a stderr message in CLI mode).

3. Resolve #include Directives

The parser scans for #include "file.slt" directives and performs text substitution -- the directive is replaced with the entire contents of the referenced file. This is recursive: included files can themselves contain #include directives.

To prevent infinite recursion, there is a maximum depth of 16 levels. If an include chain exceeds this depth, Salata produces a parse-time error.

Included files participate in the same processing pipeline. Their runtime blocks join the shared scope, and their directives are resolved alongside the main file's directives.

4. Resolve Response Directives

Salata scans for response-level directives and extracts them from the document:

DirectiveEffectMultiplicity
#status 404Sets the HTTP response status codeOnce per page
#content-type application/jsonSets the Content-Type headerOnce per page
#header "X-Custom" "value"Adds a custom response headerMultiple allowed
#cookie "name" "value" flagsSets a response cookieMultiple allowed
#redirect "/url"Sets a redirect responseOnce per page

These directives are removed from the document content. They only affect HTTP response metadata. In CLI mode, #status and #header have no effect (there is no HTTP response), but #content-type can still be used to signal the intended output format.

If #status or #content-type appears more than once, it is a parse error.

5. Parse Content and Extract Runtime Blocks

The parser walks through the document and identifies runtime blocks:

<python>code here</python>
<ruby>code here</ruby>
<javascript>code here</javascript>
<typescript>code here</typescript>
<php>code here</php>
<shell>code here</shell>

Each block is extracted with its position in the document (so the output can be spliced back later), the language, any attributes (like scope="isolated"), and the code content.

Client-side tags <style> and <script> are not runtime blocks. They are passed through untouched.

6. Validate: No Nested Runtime Tags

Salata checks that no runtime tag contains another runtime tag. This is a hard rule. The following is a parse-time error:

<!-- INVALID: nested runtime tags -->
<python>
  print("<ruby>puts 'hello'</ruby>")
</python>

Nesting is never allowed, regardless of whether the inner tag appears in a string literal.

7. Check Runtime Enabled Status

For each runtime block found, Salata checks whether that runtime is enabled in config.toml. If a block uses a disabled runtime, Salata emits an error: Runtime 'python' is disabled in config.toml.

If every runtime is disabled, Salata prints: No runtimes enabled. Enable at least one runtime in config.toml to process .slt files. and exits with a non-zero status code.

8. Expand #set/#get Macros

Inside runtime blocks, #set and #get macros are expanded into native code for each language. For example:

#set("count", 42)

might expand to something like (in Python):

__salata_store["count"] = json.dumps(42)

The exact expansion is language-specific, but the effect is the same: data is serialized to JSON and stored in a shared data structure that Salata manages. #get expands to code that deserializes the stored value back into a native type.

9. Group Blocks by Language

Blocks are grouped by language. If shared scope is enabled (the default), all blocks of the same language will be sent to a single process.

10. Spawn or Reuse Processes

For each language with blocks to execute:

  • Shared scope (default): Salata spawns one process per language per page. All blocks for that language run in this single process.
  • Isolated scope: Each isolated block gets its own process.

The process binary is determined by the runtime configuration and the current execution context (for PHP, this means selecting between php, php-cgi, or php-fpm).

11. Send Blocks with Boundary Markers

For shared-scope execution, Salata concatenates all blocks for a given language, separated by boundary markers:

<code from block 1>
print("__SALATA_BLOCK_BOUNDARY__")
<code from block 2>
print("__SALATA_BLOCK_BOUNDARY__")
<code from block 3>

The boundary marker __SALATA_BLOCK_BOUNDARY__ is a fixed string that Salata uses to split the output back into per-block segments. The concatenated code is sent to the runtime process's stdin.

12. Capture stdout Per Block

Salata reads the process's stdout and splits it on the boundary marker. This produces one output segment per block, maintaining the correct order.

If a runtime block produces an error (non-zero exit code, stderr output), Salata handles it according to the display_errors setting:

  • display_errors = true: the error message is included in the output at the block's position
  • display_errors = false: the error is suppressed in the output but still written to the log file

13. Splice Outputs Back Into Document

Each block's captured output replaces the original runtime tag in the document. The document is reassembled in order: static content, then block 1 output, then more static content, then block 2 output, and so on.

14. Send Final Output

The fully assembled document is written to stdout (in CLI mode) or sent as an HTTP response body (in CGI/Server mode), along with the response headers collected in step 4.

Execution Order

Execution is top-to-bottom and synchronous. Each block finishes before the next one starts. Within shared scope, blocks for the same language maintain their document ordering. There is no parallel execution of blocks.

Error Handling

display_errors

The display_errors setting controls whether error messages appear in the output:

  • Global setting: [salata] display_errors = true
  • Per-runtime override: [runtimes.python] display_errors = false
  • Resolution: runtime-specific setting takes precedence; global setting is the fallback

Regardless of the display setting, errors are always logged to the runtime's log file.

HTTP Status on Error

If any runtime block fails during execution, the HTTP status code is automatically set to 500, overriding any #status directive in the document. This ensures that errors are not silently served with a 200 status.

Custom Error Pages

The [errors] section of config.toml allows custom error pages:

[errors]
page_404 = "./errors/404.slt"
page_500 = "./errors/500.slt"

These can be .slt files themselves, meaning error pages can contain dynamic content.

Caching

Salata caches the parsed structure of .slt files -- block positions, include resolutions, and directive locations -- keyed by file path and modification time (mtime). When a file is modified, the cache entry is invalidated and the file is re-parsed on the next request.

This is a parse cache, not an output cache. The runtime blocks are always re-executed. Only the parsing work (steps 3 through 8) is cached.

Dependency Chain

Salata is structured as a Cargo workspace with five crates. The dependency relationships between them are intentionally simple and linear, with no circular dependencies.

Dependency Graph

salata-core       ← shared library (config, parser, runtimes, security)
    │
    ├── salata-cli        ← depends on salata-core
    │
    ├── salata-cgi        ← depends on salata-core
    │
    ├── salata-fastcgi    ← depends on salata-core
    │
    └── salata-server     ← depends on salata-cgi → salata-core

salata-core

The foundation of the entire project. Every other crate depends on it, either directly or transitively. It is a library crate (no main.rs, no binary output).

salata-core contains:

  • Configuration -- TOML parsing, validation, and the Config struct
  • Parser -- .slt file parsing and block extraction
  • Runtimes -- process spawning and stdout capture for all six languages (Python, Ruby, JavaScript, TypeScript, PHP, Shell)
  • Directives -- #include, #status, #content-type, #header, #cookie, #redirect
  • Macros -- #set/#get expansion into native code per language
  • Scope -- shared and isolated scope management, boundary markers
  • Security -- shell sandbox implementation, command blacklisting
  • Logging -- log formatting, file writing, rotation
  • Error handling -- error types, display_errors resolution
  • Caching -- parsed file cache by path + mtime
  • Context -- the ExecutionContext enum

No binary crate reimplements any of this logic. They all call into salata-core.

salata-cli

Depends on: salata-core

The CLI interpreter binary. Its main.rs handles argument parsing, loads config.toml, sets ExecutionContext::Cli, and calls salata-core to process the .slt file. The result is written to stdout.

# crates/salata-cli/Cargo.toml
[dependencies]
salata-core = { path = "../salata-core" }

salata-cgi

Depends on: salata-core

The CGI bridge binary. Its main.rs reads CGI environment variables, applies security protections (implemented in protection.rs), sets ExecutionContext::Cgi, and calls salata-core. The security protections (slowloris defense, request limits, path traversal blocking, etc.) are implemented within this crate, not in salata-core, because they are specific to the CGI deployment model.

# crates/salata-cgi/Cargo.toml
[dependencies]
salata-core = { path = "../salata-core" }

salata-fastcgi

Depends on: salata-core

Currently a stub. When implemented, it will be a persistent FastCGI daemon. It depends on salata-core directly (not on salata-cgi), because FastCGI has its own protocol and process model distinct from CGI.

# crates/salata-fastcgi/Cargo.toml
[dependencies]
salata-core = { path = "../salata-core" }

salata-server

Depends on: salata-cgi (which transitively depends on salata-core)

The standalone development server. This is the only binary that depends on another binary crate. It depends on salata-cgi because internally it processes .slt requests using the CGI pipeline -- the server receives an HTTP request, translates it into CGI-style invocation, and uses salata-cgi's processing logic to handle the request.

# crates/salata-server/Cargo.toml
[dependencies]
salata-cgi = { path = "../salata-cgi" }

Through salata-cgi, the server transitively depends on salata-core as well. This means salata-server has access to all configuration, parsing, runtime, and security functionality.

The server adds its own modules on top:

  • static_files.rs -- serves non-.slt files (HTML, CSS, JS, images, fonts, media) with correct MIME types
  • hot_reload.rs -- file watcher that triggers reparse when files change during development

Why This Structure

salata-core as a library

Keeping all shared logic in a single library crate means there is exactly one implementation of the parser, the runtimes, the macro expander, and everything else. Bug fixes and improvements in salata-core automatically apply to all four binaries.

salata-server depending on salata-cgi

The server needs CGI-style request processing (translating HTTP requests into .slt file processing) and CGI-specific security protections (request limits, path traversal blocking). Rather than reimplementing these, it reuses salata-cgi's implementation.

No circular dependencies

The dependency graph is a tree, not a graph with cycles. salata-core depends on no other project crates. The binary crates depend on salata-core (and in salata-server's case, also on salata-cgi). No crate depends on itself or on a crate that depends on it.

External Dependencies

Beyond the internal crate dependencies, the project uses standard Rust ecosystem crates:

CratePurpose
serdeSerialization/deserialization framework
tomlTOML configuration file parsing
thiserrorErgonomic error type definitions
actix-webHTTP framework (used by salata-server)

The project avoids unwrap() in production code and uses thiserror for proper error propagation throughout the crate hierarchy.

Shell Sandbox

The shell runtime is the most restricted runtime in Salata. Because shell code has direct access to system commands and the filesystem, Salata applies a three-phase security model: static analysis before execution, environment hardening at launch, and continuous monitoring during execution. All of these protections are baked into the salata binary itself -- no external sandboxing tools are required.

Shell Whitelist

Only the following shell interpreters are allowed. This list is hardcoded and cannot be changed via configuration:

  • /bin/sh
  • /bin/bash
  • /bin/zsh
  • /usr/bin/sh
  • /usr/bin/bash
  • /usr/bin/zsh
  • /usr/bin/fish
  • /usr/bin/dash
  • /usr/bin/ash

The shell path must be absolute. Relative paths like bash or ./sh are rejected.


Phase 1: Pre-Execution Static Analysis

Before any shell code runs, Salata scans the entire block for blocked commands, blocked patterns, and blocked path references. If any match is found, the block is rejected with an error and never executed.

Blocked Commands

These commands are always blocked and cannot be unblocked via configuration.

System commands:

rm, rmdir, shred, wipefs, mkfs, dd, fdisk, mount, umount, reboot, shutdown, halt, poweroff, init, systemctl, service, ln

Process and user management:

kill, killall, pkill, su, sudo, doas, chown, chmod, chgrp, chroot, useradd, userdel, usermod, groupadd, passwd

Network tools:

nc, ncat, netcat, nmap, telnet, ssh, scp, sftp, ftp, rsync, socat

Code execution:

python, python3, perl, ruby, node, php, lua, gdb, strace, ltrace, nohup, screen, tmux, at, batch, crontab

Package management:

apt, apt-get, yum, dnf, pacman, brew, pip, npm, gem

Disk and filesystem:

losetup, lvm, parted, mkswap, swapon, swapoff

Kernel modules:

insmod, rmmod, modprobe, dmesg, sysctl

Container runtimes:

docker, podman, kubectl, lxc

Network downloads (curl and wget):

These two commands are the exception. They are controlled by the allow_outbound_network setting in the [cgi] section of config.toml. When allow_outbound_network = true (the default), curl and wget are allowed. When set to false, they are blocked like any other network tool.

Blocked Patterns

These patterns are detected anywhere in the shell block and cause immediate rejection.

PatternReason
& (single ampersand)Blocks backgrounding of processes. Note that && (logical AND) is allowed.
| bash, | sh, | zsh, | dash, | fishPrevents piping output into a shell interpreter.
evalBlocks dynamic code evaluation.
execBlocks process replacement.
sourceBlocks sourcing external scripts.
. /Blocks the dot-source shorthand.
/dev/tcp/, /dev/udp/Blocks Bash network redirects (e.g., /dev/tcp/host/port).
base64 -d, base64 --decodeBlocks decoding of obfuscated payloads.
xxd -rBlocks hex-to-binary conversion.
\x, \u00, $'\xBlocks encoding-based bypass attempts.
history, HISTFILEBlocks shell history access and manipulation.
export PATH, export LD_Blocks PATH and dynamic linker variable manipulation.
LD_PRELOAD, LD_LIBRARY_PATHBlocks library injection attacks.
:(), bomb()Blocks fork bomb function definitions.
while true; do, while :; doBlocks infinite loop patterns.

Blocked Paths

Any reference to the following paths causes the block to be rejected:

  • /dev -- device files (includes /dev/null, /dev/zero, /dev/random, /dev/tcp, /dev/udp)
  • /proc -- process information filesystem
  • /sys -- kernel/device configuration filesystem
  • /etc -- system configuration files

This is a substring match, so even harmless uses like >/dev/null or cat /etc/hostname are blocked. This is an intentional trade-off: blocking /dev/null is the cost of preventing access to /dev/tcp and other dangerous device files. Similarly, 2>&1 is blocked because the & character triggers the backgrounding check.


Phase 2: Environment Setup

If the shell block passes static analysis, Salata prepares a hardened execution environment before launching the shell process.

Clean PATH

The PATH environment variable is set to a minimal list of safe directories only. System directories containing administrative tools are excluded.

Stripped Environment Variables

The shell process inherits only a whitelisted set of environment variables. Sensitive variables from the parent process (such as credentials, tokens, or library paths) are stripped.

Locked Working Directory

The shell process's working directory is locked to the document root. The shell block cannot cd to arbitrary locations outside the project.

ulimit Enforcement

Resource limits are applied via ulimit before the shell code runs:

  • Max memory -- prevents the shell process from consuming excessive RAM
  • Max processes -- limits the number of child processes (prevents fork bombs at the OS level)
  • Max file size -- limits the size of files the shell can create
  • Max open files -- limits the number of file descriptors

Phase 3: Runtime Monitoring

While the shell block is executing, Salata actively monitors the process and will terminate it if limits are exceeded.

Timeout

The shell process is killed if it exceeds max_execution_time (default: 30 seconds, configurable in the [cgi] section). This prevents infinite loops that were not caught by static analysis, long-running commands, and hung processes.

Memory Tracking

Salata tracks the memory usage of the shell process. If it exceeds max_memory_per_request (default: 128MB), the process is killed immediately.

Output Size Tracking

The stdout output of the shell process is tracked. If it exceeds max_response_size (default: 50MB), the process is killed. This prevents a shell block from flooding the output buffer with an enormous amount of data.


Practical Implications

The shell sandbox is strict by design, and some common shell idioms are not available inside <shell> blocks:

# These will NOT work in Salata shell blocks:

command > /dev/null       # /dev path blocked
command 2>&1              # & character blocked
command &                 # backgrounding blocked
rm temp_file              # rm command blocked
sudo apt install foo      # sudo and apt blocked
curl http://example.com   # allowed only if allow_outbound_network = true

Shell blocks are best suited for read-only tasks like gathering system information, text processing with awk/sed/grep, and generating output from existing data. For tasks that require more system access, consider using Python or Ruby runtime blocks instead, which have fewer restrictions.

CGI Protections

The salata-cgi binary includes built-in protections against common CGI attack vectors. All protections are configurable through the [cgi] section of config.toml and are enabled by default with sensible defaults.


Slowloris Protection

Slowloris attacks work by opening connections and sending data extremely slowly, tying up server resources indefinitely. Salata defends against this with three settings:

SettingDefaultDescription
header_timeout5sMaximum time allowed to receive all HTTP headers. If the client has not finished sending headers within this window, the connection is dropped.
body_timeout30sMaximum time allowed to receive the full request body. Applies after headers are received.
min_data_rate100b/sMinimum data transfer rate. If the client sends data slower than this threshold, the connection is terminated. This catches clients that technically send data but at an unusably slow rate.
[cgi]
header_timeout = "5s"
body_timeout = "30s"
min_data_rate = "100b/s"

Request Limits

These settings cap the size of various parts of the incoming HTTP request. Oversized requests are rejected before any processing occurs.

SettingDefaultDescription
max_url_length2048Maximum length of the request URL in characters. Prevents extremely long URLs from consuming parser resources.
max_header_size8KBMaximum total size of all HTTP headers combined.
max_header_count50Maximum number of individual HTTP headers. Prevents header flooding attacks.
max_query_string_length2048Maximum length of the query string portion of the URL.
max_body_size10MBMaximum size of the request body. Applies to POST, PUT, and PATCH requests.
[cgi]
max_url_length = 2048
max_header_size = "8KB"
max_header_count = 50
max_query_string_length = 2048
max_body_size = "10MB"

Process Limits

These settings control resource consumption at the process level. They prevent a single request or a single client from monopolizing server resources.

SettingDefaultDescription
max_connections_per_ip20Maximum simultaneous connections from a single IP address. Limits the impact of a single attacker.
max_total_connections200Maximum total simultaneous connections across all clients. Hard ceiling on concurrency.
max_execution_time30sMaximum time a single request is allowed to run, including all runtime block execution. Requests exceeding this are killed.
max_memory_per_request128MBMaximum memory a single request and its runtime processes may consume.
max_response_size50MBMaximum size of the generated response. Prevents runaway output from consuming disk or memory.
response_timeout60sMaximum total time from request start to response completion. A broader timeout than max_execution_time that includes I/O overhead.
[cgi]
max_connections_per_ip = 20
max_total_connections = 200
max_execution_time = "30s"
max_memory_per_request = "128MB"
max_response_size = "50MB"
response_timeout = "60s"

Path Security

These settings protect against filesystem traversal and access to sensitive files.

SettingDefaultDescription
block_path_traversaltrueBlocks any request URL containing ../ sequences. Prevents attackers from escaping the document root to access arbitrary files.
block_dotfilestrueBlocks access to files and directories starting with a dot (e.g., .env, .git, .htaccess). These files often contain sensitive configuration or credentials.
blocked_extensions[".toml", ".env", ".git", ".log"]List of file extensions that cannot be served. Requests for files with these extensions are rejected with a 403 Forbidden response.
[cgi]
block_path_traversal = true
block_dotfiles = true
blocked_extensions = [".toml", ".env", ".git", ".log"]

Input Sanitization

These settings validate and sanitize incoming request data to prevent injection attacks.

SettingDefaultDescription
block_null_bytestrueRejects requests containing %00 (null byte) anywhere in the URL or headers. Null byte injection is a classic attack that can cause C-based path parsers to truncate filenames.
block_non_printable_headerstrueRejects requests with non-printable ASCII characters in HTTP headers. Prevents header injection and response splitting attacks.
validate_content_lengthtrueVerifies that the Content-Length header matches the actual body size. A mismatch indicates a malformed or malicious request.
[cgi]
block_null_bytes = true
block_non_printable_headers = true
validate_content_length = true

Runtime Sandboxing

These settings apply to the runtime processes spawned by CGI to execute .slt files.

SettingDefaultDescription
max_child_processes10Maximum number of child processes that can be spawned per request. Prevents fork bombs where a runtime block attempts to spawn an unbounded number of processes.
allow_outbound_networktrueControls whether curl and wget are permitted in shell blocks. When set to false, these commands are added to the shell sandbox's blocked command list. Other runtimes (Python, Ruby, etc.) are not affected by this setting.
[cgi]
max_child_processes = 10
allow_outbound_network = true

Full Default Configuration

For reference, here is the complete [cgi] section with all defaults:

[cgi]
header_timeout = "5s"
body_timeout = "30s"
min_data_rate = "100b/s"
max_url_length = 2048
max_header_size = "8KB"
max_header_count = 50
max_query_string_length = 2048
max_body_size = "10MB"
max_connections_per_ip = 20
max_total_connections = 200
max_execution_time = "30s"
max_memory_per_request = "128MB"
max_response_size = "50MB"
response_timeout = "60s"
block_dotfiles = true
block_path_traversal = true
blocked_extensions = [".toml", ".env", ".git", ".log"]
block_null_bytes = true
block_non_printable_headers = true
validate_content_length = true
max_child_processes = 10
allow_outbound_network = true

Runtime Sandboxing

Salata runs all runtime code in separate child processes, never inside the salata process itself. Each runtime gets basic protections, but the level of sandboxing varies. Shell is the most restricted; all other runtimes receive a lighter set of protections.


Process Isolation

Every runtime block is executed in a separate child process. Python code runs in a python3 process, Ruby in a ruby process, JavaScript in a node process, and so on. This means:

  • A crash in a runtime process does not crash salata itself.
  • Runtime processes cannot access salata's internal memory or state.
  • Each runtime process can be individually monitored and terminated.
  • Runtimes are isolated from each other -- a Python process cannot see what a Ruby process is doing. Cross-runtime communication happens exclusively through the #set/#get macro system, with salata acting as the broker.

Protections Applied to All Runtimes

The following protections apply to Python, Ruby, JavaScript, TypeScript, PHP, and Shell equally.

Timeout

Every runtime process is subject to max_execution_time (default: 30 seconds). If a block takes longer than this to execute, the process is killed. This catches infinite loops, deadlocks, and unexpectedly long computations.

Memory Limits

Memory usage of each runtime process is tracked against max_memory_per_request (default: 128MB). If a process exceeds this limit, it is killed immediately. This prevents a single block from consuming all available system memory.

Output Size Limits

The stdout output of each runtime process is tracked against max_response_size (default: 50MB). If a process produces more output than this, it is killed. This prevents runaway output from filling up disk space or memory.

Child Process Limits

The max_child_processes setting (default: 10) limits how many child processes a single runtime block can spawn. This is a defense against fork bombs, where code attempts to create an exponentially growing number of processes to crash the system.


Shell: The Most Restricted Runtime

Shell is unique because shell code directly invokes system commands. A Python block that runs os.system("rm -rf /") requires the developer to explicitly import os and call a function. A shell block that runs rm -rf / does so directly -- there is no indirection.

Because of this, shell blocks receive the full three-phase sandbox described in the Shell Sandbox chapter:

  1. Pre-execution static analysis -- the code is scanned for blocked commands, blocked patterns, and blocked paths before it runs.
  2. Environment hardening -- the process launches with a clean PATH, stripped environment variables, locked working directory, and ulimit enforcement.
  3. Runtime monitoring -- timeout, memory, and output size are tracked continuously.

No other runtime receives the static analysis or environment hardening phases.


Python, Ruby, JavaScript, TypeScript, PHP

These runtimes receive timeout, memory, output size, and child process protections, but they do not get:

  • Command-level scanning -- there is no pre-execution analysis of the code. A Python block can call subprocess.run(["rm", "-rf", "/"]) and salata will not catch it at parse time (though the OS may prevent it depending on permissions).
  • Environment stripping -- these runtimes inherit a normal environment. They are not given a stripped-down PATH or cleared environment variables.
  • Blocked paths -- there is no restriction on referencing /dev, /proc, /sys, or /etc from these runtimes.

This is a deliberate design choice. Python, Ruby, JavaScript, TypeScript, and PHP are general-purpose programming languages. Restricting them at the command level would cripple their usefulness -- nearly any library import or file operation could trigger a false positive. Instead, these runtimes rely on OS-level permissions and the process-level resource limits described above.


Network Access

The allow_outbound_network setting in config.toml only affects the shell runtime. When set to false, curl and wget are added to the shell sandbox's blocked command list.

Python, Ruby, JavaScript, TypeScript, and PHP are unaffected by this setting. They can make outbound network requests using their native libraries (e.g., Python's urllib, Ruby's net/http, Node's fetch) regardless of the allow_outbound_network value.


Summary

ProtectionShellPythonRubyJSTSPHP
Process isolationYesYesYesYesYesYes
Timeout enforcementYesYesYesYesYesYes
Memory limitsYesYesYesYesYesYes
Output size limitsYesYesYesYesYesYes
Child process limitsYesYesYesYesYesYes
Command-level scanningYesNoNoNoNoNo
Environment strippingYesNoNoNoNoNo
Blocked pathsYesNoNoNoNoNo
Network controlYesNoNoNoNoNo

CLI Examples

These examples demonstrate using the salata CLI binary to process .slt files and output text to stdout. All examples live in the examples/cli/ directory of the repository.

To run any example:

salata --config examples/cli/<example>/config.toml examples/cli/<example>/<file>.slt

hello-world/

The simplest possible examples -- one .slt file per runtime, each printing a greeting. These are the best starting point for verifying that your runtimes are installed and configured correctly.

python.slt:

<python>
print("Hello from Python!")
</python>

ruby.slt:

<ruby>
puts "Hello from Ruby!"
</ruby>

javascript.slt:

<javascript>
println("Hello from JavaScript!");
</javascript>

typescript.slt:

<typescript>
const greeting: string = "Hello from TypeScript!";
println(greeting);
</typescript>

php.slt:

<php>
echo "Hello from PHP!\n";
</php>

shell.slt:

<shell>
echo "Hello from Shell!"
</shell>

Run all of them:

salata --config examples/cli/hello-world/config.toml examples/cli/hello-world/python.slt
salata --config examples/cli/hello-world/config.toml examples/cli/hello-world/ruby.slt
salata --config examples/cli/hello-world/config.toml examples/cli/hello-world/javascript.slt
salata --config examples/cli/hello-world/config.toml examples/cli/hello-world/typescript.slt
salata --config examples/cli/hello-world/config.toml examples/cli/hello-world/php.slt
salata --config examples/cli/hello-world/config.toml examples/cli/hello-world/shell.slt

data-processing/

Three examples demonstrating Salata as a data processing tool, producing formatted text output rather than HTML.

csv-table.slt -- Python processes inline CSV data into a formatted ASCII table:

<python>
import csv
import io

data = """name,role,department
Alice,Engineer,Backend
Bob,Designer,Frontend
Charlie,Manager,Operations
Diana,Engineer,Frontend
Eve,Analyst,Data"""

reader = csv.DictReader(io.StringIO(data))
rows = list(reader)
headers = rows[0].keys()

# Calculate column widths
widths = {h: max(len(h), max(len(r[h]) for r in rows)) for h in headers}

# Print header
header_line = " | ".join(h.ljust(widths[h]) for h in headers)
print(header_line)
print("-+-".join("-" * widths[h] for h in headers))

# Print rows
for row in rows:
    print(" | ".join(row[h].ljust(widths[h]) for h in headers))

print(f"\nTotal: {len(rows)} records")
</python>

json-filter.slt -- Ruby filters and sorts an inline JSON array:

<ruby>
require 'json'

data = JSON.parse('[
  {"name": "Alice", "age": 30, "city": "New York"},
  {"name": "Bob", "age": 25, "city": "London"},
  {"name": "Charlie", "age": 35, "city": "Tokyo"},
  {"name": "Diana", "age": 28, "city": "Paris"},
  {"name": "Eve", "age": 32, "city": "Berlin"}
]')

# Filter: age >= 28, sort by name
filtered = data
  .select { |p| p["age"] >= 28 }
  .sort_by { |p| p["name"] }

puts "People aged 28 and older (sorted by name):"
puts "=" * 40
filtered.each do |person|
  puts "  #{person['name']} (#{person['age']}) — #{person['city']}"
end
puts "=" * 40
puts "#{filtered.length} of #{data.length} matched"
</ruby>

system-report.slt -- Shell generates a system information report:

<shell>
echo "=== System Report ==="
echo ""
echo "Hostname: $(hostname)"
echo "Kernel:   $(uname -sr)"
echo "Arch:     $(uname -m)"
echo "Date:     $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
echo "--- Disk Usage ---"
df -h / | tail -1 | awk '{printf "  Root: %s used of %s (%s)\n", $3, $2, $5}'
echo ""
echo "=== End Report ==="
</shell>

config-generator/

Demonstrates using Salata to generate configuration files. Multiple runtimes collaborate to produce a complete nginx configuration.

nginx.slt -- Shell detects the CPU count, Python computes upstream servers and outputs a full nginx.conf:

<shell>
# Detect available CPU cores for worker_processes
CORES=$(nproc || echo 2)
echo "$CORES"
</shell>
<python>
# Define upstream application servers
upstreams = [
    ("app1", "127.0.0.1", 8001),
    ("app2", "127.0.0.1", 8002),
    ("app3", "127.0.0.1", 8003),
]

cores = 2

print(f"""# Auto-generated nginx.conf
# Generated by Salata config-generator example

worker_processes {cores};

events {{
    worker_connections 1024;
}}

http {{
    upstream backend {{""")

for name, host, port in upstreams:
    print(f"        server {host}:{port};  # {name}")

print("""    }

    server {
        listen 80;
        server_name example.com;

        location / {
            proxy_pass http://backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        location /static/ {
            root /var/www/html;
            expires 30d;
        }
    }
}""")
</python>

Usage:

salata --config examples/cli/config-generator/config.toml examples/cli/config-generator/nginx.slt > nginx.conf

markdown-report/

Shows how to generate Markdown output using multiple runtimes, with cross-runtime data sharing via #set/#get.

report.slt -- Python computes project statistics, Ruby formats a Markdown table, Shell adds build metadata:

<python>
# Compute some statistics
projects = [
    {"name": "Alpha", "status": "Complete", "tasks": 24, "done": 24},
    {"name": "Beta", "status": "In Progress", "tasks": 18, "done": 12},
    {"name": "Gamma", "status": "In Progress", "tasks": 30, "done": 21},
    {"name": "Delta", "status": "Planning", "tasks": 15, "done": 0},
]
total_tasks = sum(p["tasks"] for p in projects)
total_done = sum(p["done"] for p in projects)
overall_pct = round(total_done / total_tasks * 100, 1)

#set("projects", projects)
#set("total_tasks", total_tasks)
#set("total_done", total_done)
#set("overall_pct", overall_pct)
</python>
<ruby>
projects = #get("projects")
total_tasks = #get("total_tasks")
total_done = #get("total_done")
overall_pct = #get("overall_pct")

puts "# Project Status Report"
puts ""
puts "**Overall Progress:** #{total_done}/#{total_tasks} tasks (#{overall_pct}%)"
puts ""
puts "| Project | Status      | Progress        |"
puts "|---------|-------------|-----------------|"
projects.each do |p|
  pct = p["tasks"] > 0 ? (p["done"].to_f / p["tasks"] * 100).round(0) : 0
  bar = "#" * (pct / 10) + "." * (10 - pct / 10)
  puts "| #{p['name'].ljust(7)} | #{p['status'].ljust(11)} | #{p['done']}/#{p['tasks'].to_s.ljust(2)} [#{bar}] |"
end
puts ""
</ruby>
<shell>
echo "## Build Info"
echo ""
echo "- **Generated:** $(date '+%Y-%m-%d %H:%M:%S')"
echo "- **Host:** $(hostname)"
echo "- **Platform:** $(uname -s) $(uname -m)"
</shell>

Usage:

salata --config examples/cli/markdown-report/config.toml examples/cli/markdown-report/report.slt > report.md

cross-runtime-pipeline/

The flagship cross-runtime example: Python generates data, Ruby transforms it, JavaScript formats the output. This example has its own dedicated chapter -- see Cross-Runtime Pipeline for a full walkthrough.


scope-demo/

Two files demonstrating the difference between shared and isolated scope.

shared-scope.slt -- Two Python blocks that share a process. The second block can access variables defined in the first:

<python>
# First block: define variables
message = "Hello from the first block"
counter = 42
items = ["apple", "banana", "cherry"]
print(f"Block 1: Set message='{message}', counter={counter}")
</python>

Text between blocks — the Python process is still alive.

<python>
# Second block: access variables from the first block
# These variables are available because shared_scope = true (default)
print(f"Block 2: message='{message}'")
print(f"Block 2: counter={counter}")
print(f"Block 2: items={items}")

counter += 1
print(f"Block 2: incremented counter to {counter}")
</python>

isolated-scope.slt -- Two Python blocks with scope="isolated", each running in its own process. The second block cannot access the first block's variables:

<python scope="isolated">
# First block (isolated): define variables
message = "Hello from the first block"
counter = 42
print(f"Block 1: Set message='{message}', counter={counter}")
</python>

Text between blocks — each block gets a fresh Python process.

<python scope="isolated">
# Second block (isolated): try to access variables from block 1
# This will fail because scope="isolated" means a new process
try:
    print(f"Block 2: message='{message}'")
except NameError as e:
    print(f"Block 2: Cannot access 'message' — {e}")

try:
    print(f"Block 2: counter={counter}")
except NameError as e:
    print(f"Block 2: Cannot access 'counter' — {e}")

print("Block 2: Each isolated block starts fresh!")
</python>

json-api-mock/

Demonstrates using Salata to generate JSON output with the #content-type directive and cross-runtime data sharing.

api.slt:

#content-type application/json
<python>
import json

# Build the API response data
users = [
    {"id": 1, "name": "Alice", "email": "alice@example.com", "role": "admin"},
    {"id": 2, "name": "Bob", "email": "bob@example.com", "role": "user"},
    {"id": 3, "name": "Charlie", "email": "charlie@example.com", "role": "user"},
]

#set("users", users)
</python>
<javascript>
const users = #get("users");

const response = {
    status: "ok",
    data: users,
    meta: {
        total: users.length,
        page: 1,
        per_page: 10
    }
};

print(JSON.stringify(response, null, 2));
</javascript>

multi-format/

Three .slt files that take the same product inventory data and output it in three different formats: plain text, CSV, and YAML.

report.txt.slt -- Plain text table:

<python>
products = [
    {"name": "Laptop", "price": 999.99, "stock": 45},
    {"name": "Mouse", "price": 29.99, "stock": 200},
    {"name": "Keyboard", "price": 79.99, "stock": 120},
    {"name": "Monitor", "price": 449.99, "stock": 30},
]

print("PRODUCT INVENTORY REPORT")
print("=" * 45)
for p in products:
    status = "LOW" if p["stock"] < 50 else "OK"
    print(f"  {p['name']:<12} ${p['price']:>8.2f}  Stock: {p['stock']:>3}  [{status}]")
print("=" * 45)
print(f"  Total items: {sum(p['stock'] for p in products)}")
print(f"  Total value: ${sum(p['price'] * p['stock'] for p in products):,.2f}")
</python>

report.csv.slt -- CSV format:

<python>
products = [
    {"name": "Laptop", "price": 999.99, "stock": 45},
    {"name": "Mouse", "price": 29.99, "stock": 200},
    {"name": "Keyboard", "price": 79.99, "stock": 120},
    {"name": "Monitor", "price": 449.99, "stock": 30},
]

print("name,price,stock,status")
for p in products:
    status = "LOW" if p["stock"] < 50 else "OK"
    print(f"{p['name']},{p['price']},{p['stock']},{status}")
</python>

report.yaml.slt -- YAML format:

<python>
products = [
    {"name": "Laptop", "price": 999.99, "stock": 45},
    {"name": "Mouse", "price": 29.99, "stock": 200},
    {"name": "Keyboard", "price": 79.99, "stock": 120},
    {"name": "Monitor", "price": 449.99, "stock": 30},
]

print("inventory:")
print(f"  total_items: {sum(p['stock'] for p in products)}")
print(f"  total_value: {sum(p['price'] * p['stock'] for p in products):.2f}")
print("  products:")
for p in products:
    status = "low" if p["stock"] < 50 else "ok"
    print(f"    - name: {p['name']}")
    print(f"      price: {p['price']}")
    print(f"      stock: {p['stock']}")
    print(f"      status: {status}")
</python>

Usage:

salata --config examples/cli/multi-format/config.toml examples/cli/multi-format/report.txt.slt
salata --config examples/cli/multi-format/config.toml examples/cli/multi-format/report.csv.slt > report.csv
salata --config examples/cli/multi-format/config.toml examples/cli/multi-format/report.yaml.slt > report.yaml

Web Examples

These examples demonstrate using salata-server to serve .slt files over HTTP. All examples live in the examples/web/ directory of the repository.

To run any web example, point salata-server at the example directory:

salata-server examples/web/<example>/ --port 3000

Then open http://localhost:3000 in your browser.


single-file/

Five standalone .slt files, each demonstrating a different web directive. These are the simplest possible web examples.

hello.slt -- A basic HTML page with an embedded Python block that prints the current time:

<!DOCTYPE html>
<html>
<head>
    <title>Hello — Salata</title>
</head>
<body>
    <h1>Hello from Salata!</h1>
    <p>The current time is:</p>
    <python>
from datetime import datetime
print(f"<strong>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</strong>")
    </python>
    <p>This page was generated server-side by Salata.</p>
</body>
</html>

status-codes.slt -- Demonstrates the #status directive to set a custom HTTP status code:

#status 404
<!DOCTYPE html>
<html>
<head>
    <title>404 — Not Found</title>
</head>
<body>
    <h1>404 — Page Not Found</h1>
    <python>
print("<p>This page intentionally returns a 404 status code.</p>")
print("<p>The <code>#status 404</code> directive sets the HTTP response status.</p>")
    </python>
</body>
</html>

redirect.slt -- A single-line file that redirects to another page:

#redirect "/hello.slt"

headers.slt -- Sets custom HTTP headers and cookies using directives:

#header "X-Powered-By" "Salata"
#header "X-Example" "headers-demo"
#cookie "visited" "true" httponly
<!DOCTYPE html>
<html>
<head>
    <title>Headers &amp; Cookies — Salata</title>
</head>
<body>
    <h1>Custom Headers &amp; Cookies</h1>
    <p>This page sets the following:</p>
    <ul>
        <li><code>X-Powered-By: Salata</code> (custom header)</li>
        <li><code>X-Example: headers-demo</code> (custom header)</li>
        <li><code>visited=true</code> (httponly cookie)</li>
    </ul>
    <python>
print("<p>Check the response headers in your browser's developer tools!</p>")
    </python>
</body>
</html>

content-type.slt -- Returns JSON instead of HTML using the #content-type directive:

#content-type application/json
<python>
import json

data = {
    "message": "This page returns JSON, not HTML",
    "content_type": "application/json",
    "directive": "#content-type application/json"
}
print(json.dumps(data, indent=2))
</python>

Run the single-file examples:

salata-server examples/web/single-file/ --port 3000
# Visit http://localhost:3000/hello.slt
# Visit http://localhost:3000/status-codes.slt
# Visit http://localhost:3000/redirect.slt (redirects to /hello.slt)
# Visit http://localhost:3000/headers.slt
# Visit http://localhost:3000/content-type.slt

portfolio/

A multi-page website demonstrating #include for shared partials and static file serving.

Structure:

portfolio/
  index.slt           # Home page
  about.slt           # About page
  contact.slt         # Contact page
  includes/
    header.slt         # Shared header partial
    footer.slt         # Shared footer partial
  static/
    style.css          # Static CSS, served as-is

Each page uses #include to pull in the shared header and footer:

#include "includes/header.slt"

<main>
    <h2>Welcome</h2>
    <python>
print("<p>This is the home page.</p>")
    </python>
</main>

#include "includes/footer.slt"

The static/style.css file is served directly by salata-server without any processing. Only .slt files are processed through the template engine.

salata-server examples/web/portfolio/ --port 3000
# Visit http://localhost:3000/index.slt
# Visit http://localhost:3000/about.slt
# Visit http://localhost:3000/contact.slt

dashboard/

A single-page dashboard that uses multiple runtimes together, demonstrating cross-runtime data sharing on a web page.

Structure:

dashboard/
  index.slt            # Main dashboard page
  static/
    style.css          # Dashboard styles

The dashboard page uses:

  • Python to compute metrics and statistics
  • Ruby to generate HTML tables from the data
  • Shell to gather live server stats (hostname, uptime, platform)
  • JavaScript to render SVG sparkline charts client-side

Runtimes share data via #set/#get -- Python computes the numbers, Ruby and JavaScript consume them to render different visualizations.

salata-server examples/web/dashboard/ --port 3000

php-showcase/

Demonstrates PHP running alongside Python on the same page. This example highlights Salata's polyglot nature -- you can use PHP for what it does best (string manipulation, HTML generation) while using Python for data processing, all in a single .slt file.

salata-server examples/web/php-showcase/ --port 3000

api-endpoint/

A JSON API endpoint built with Salata, using #content-type and #status directives. Demonstrates that Salata is not limited to generating HTML -- it can serve as a lightweight API backend.

The endpoint returns a structured JSON response with proper content type headers. It uses #content-type application/json to set the MIME type and runtime blocks to build the response data.

salata-server examples/web/api-endpoint/ --port 3000

error-pages/

Demonstrates custom error pages using .slt files. The config.toml for this example points page_404 and page_500 to .slt files in the errors/ subdirectory.

Structure:

error-pages/
  config.toml          # Points page_404/page_500 to .slt files
  index.slt            # Main page
  errors/
    404.slt            # Custom 404 page with dynamic content
    500.slt            # Custom 500 page with dynamic content

The error page .slt files can contain runtime blocks, so your 404 and 500 pages can include dynamic content like timestamps, request information, or suggestions.

Configuration:

[errors]
page_404 = "./errors/404.slt"
page_500 = "./errors/500.slt"
salata-server examples/web/error-pages/ --port 3000
# Visit http://localhost:3000/nonexistent to see the custom 404 page

blog/

A file-based blog built entirely with Salata. Python reads text files from a posts/ directory and generates an index page with links and summaries. Shared header and footer are included via #include.

Structure:

blog/
  config.toml
  index.slt              # Blog index, lists all posts
  includes/
    header.slt           # Shared header
    footer.slt           # Shared footer
  posts/
    hello-world.txt      # Blog post content
    salata-guide.txt     # Blog post content
    tips-and-tricks.txt  # Blog post content
  static/
    style.css            # Blog styles

The index.slt file uses Python to scan the posts/ directory, read each text file, extract a title and preview, and generate the blog index HTML. The header and footer partials are shared across all pages via #include.

salata-server examples/web/blog/ --port 3000

Cross-Runtime Pipeline

This chapter walks through the cross-runtime pipeline example in detail. It demonstrates Salata's core superpower: different programming languages working together in a single file, passing data between each other via the #set/#get macro system.

The example lives at examples/cli/cross-runtime-pipeline/pipeline.slt.


The Full Pipeline

The pipeline flows through three runtimes: Python generates raw data, Ruby aggregates and transforms it, and JavaScript formats the final report.

<!-- Step 1: Python generates raw data and stores it with #set -->
<python>
import json

# Raw sales data
sales = [
    {"product": "Widget A", "region": "North", "amount": 1200},
    {"product": "Widget B", "region": "South", "amount": 850},
    {"product": "Widget A", "region": "South", "amount": 2100},
    {"product": "Widget C", "region": "North", "amount": 675},
    {"product": "Widget B", "region": "North", "amount": 1450},
    {"product": "Widget C", "region": "South", "amount": 920},
]

# Store for the next runtime to pick up
#set("raw_sales", sales)
</python>

<!-- Step 2: Ruby transforms the data — aggregates by product -->
<ruby>
sales = #get("raw_sales")

# Aggregate totals per product
totals = {}
sales.each do |sale|
  name = sale["product"]
  totals[name] ||= 0
  totals[name] += sale["amount"]
end

# Sort by total descending
sorted = totals.sort_by { |_, v| -v }.map { |k, v| {"product" => k, "total" => v} }

# Store aggregated data for the next runtime
#set("product_totals", sorted)
#set("grand_total", totals.values.sum)
</ruby>

<!-- Step 3: JavaScript formats and presents the final output -->
<javascript>
const totals = #get("product_totals");
const grandTotal = #get("grand_total");

println("=== Sales Summary Report ===");
println("");

totals.forEach((item, i) => {
    const pct = ((item.total / grandTotal) * 100).toFixed(1);
    const bar = "#".repeat(Math.round(pct / 2));
    println(`  ${i + 1}. ${item.product.padEnd(10)} $${item.total.toString().padStart(6)}  ${pct}%  ${bar}`);
});

println("");
println(`  Grand Total: $${grandTotal}`);
println("");
println("Pipeline: Python (generate) → Ruby (aggregate) → JavaScript (format)");
</javascript>

Step-by-Step Walkthrough

Step 1: Python Generates the Raw Data

The Python block creates a list of sales records -- each record is a dictionary with product, region, and amount fields. This represents raw transactional data that needs to be processed.

At the end of the block, #set("raw_sales", sales) stores the Python list for other runtimes to access. Before execution, Salata expands this macro into native Python code that serializes the sales list to JSON and writes it to a temporary file managed by Salata.

The Python block itself produces no stdout output. Its only purpose is to generate and store data.

Step 2: Ruby Retrieves and Transforms the Data

The Ruby block starts with #get("raw_sales"), which Salata expands into native Ruby code that reads the JSON file written by the Python block and deserializes it into a Ruby array of hashes. The Python list of dictionaries becomes a Ruby array of hashes automatically -- JSON is the common interchange format, and each language gets its native data types.

Ruby then aggregates the sales by product name, summing the amounts. The result is sorted in descending order by total. Two values are stored for the next runtime:

  • product_totals -- an array of hashes with product and total keys
  • grand_total -- a single integer representing the sum of all sales

Like the Python block, this Ruby block produces no stdout output. It only transforms data and passes it along.

Step 3: JavaScript Formats the Final Report

The JavaScript block retrieves both product_totals and grand_total using #get. The Ruby array of hashes becomes a JavaScript array of objects. The Ruby integer becomes a JavaScript number.

JavaScript then formats the data into a human-readable report with aligned columns, percentage calculations, and ASCII bar charts. This block uses println() -- one of the helper functions Salata injects into JavaScript and TypeScript runtimes -- to produce the output.

This is the only block that writes to stdout, and its output becomes the final result of the entire .slt file.


How Salata Brokers the Data

Runtimes never communicate with each other directly. Salata acts as the broker:

  1. When a #set("key", value) macro is encountered, Salata expands it into native code that serializes the value to JSON and writes it to a temporary file in Salata's temp directory.
  2. When a #get("key") macro is encountered, Salata expands it into native code that reads the JSON file and deserializes it into the runtime's native data types.
  3. Execution is strictly top-to-bottom. When the Ruby block runs, the Python block has already finished and its #set data is available. When the JavaScript block runs, both the Python and Ruby data are available.

The JSON serialization/deserialization is transparent to the developer. You work with native types in each language:

DataPython typeRuby typeJavaScript type
raw_saleslist of dictArray of HashArray of Object
product_totalslist of dictArray of HashArray of Object
grand_totalintIntegerNumber

Supported types for cross-runtime data: strings, numbers, booleans, arrays/lists, objects/dicts/hashes, and null/nil/None.


Running the Example

salata --config examples/cli/cross-runtime-pipeline/config.toml \
       examples/cli/cross-runtime-pipeline/pipeline.slt

Expected output:

=== Sales Summary Report ===

  1. Widget A   $ 3300  45.8%  #######################
  2. Widget B   $ 2300  31.9%  ################
  3. Widget C   $ 1595  22.1%  ###########

  Grand Total: $7195

Pipeline: Python (generate) → Ruby (aggregate) → JavaScript (format)

Key Takeaways

  • Use the best language for each task. Python is great for data generation and computation. Ruby shines at data transformation with its expressive enumerable methods. JavaScript excels at string formatting and template literals.
  • Data flows through #set/#get, not through stdout. Only the last step in the pipeline needs to produce output. Earlier steps just transform and pass data.
  • Execution is sequential. Each block finishes before the next one starts. There is no race condition or synchronization to worry about.
  • Types are preserved across runtimes. Lists stay as lists, numbers stay as numbers, strings stay as strings. JSON handles the conversion transparently.

FAQ / Troubleshooting

General Questions

What is Salata?

Salata is a polyglot text templating engine. It processes .slt template files that contain embedded runtime blocks -- <python>, <ruby>, <javascript>, <typescript>, <php>, and <shell> -- executes the code in each block server-side using the respective language interpreter, captures the stdout output, and splices it back into the document. The result is written to stdout (in CLI mode) or returned as an HTTP response (in CGI or server mode).

The output is whatever the code prints. It can be HTML, JSON, YAML, plain text, configuration files, Markdown, CSV, or any other text format.

Is it production-ready?

No. Salata is a concept project under active development. It is suitable for experimentation, learning, and prototyping. It has not been audited for security in production environments, and some features (FastCGI, Uniform AST) are not yet implemented. Use it to explore the idea of polyglot templating, not to serve production traffic.

Why Rust?

Rust provides several properties that are important for a templating engine that spawns and manages child processes:

  • Performance -- parsing, process management, and I/O are fast without a garbage collector.
  • Memory safety -- no segfaults or buffer overflows in the engine itself.
  • Cross-platform compilation -- a single codebase compiles to Linux, macOS, and Windows on multiple architectures.
  • Single binary distribution -- each of the four Salata binaries is a self-contained executable with no runtime dependencies (beyond the language interpreters themselves).

Why not just use PHP/Python/etc. directly?

Salata addresses a specific niche: mixing multiple languages in a single template file and sharing data between them. If your project only needs one language, use that language directly. Salata is for cases where you want to:

  • Use Python for data processing and JavaScript for formatting in the same file.
  • Generate a report where Shell gathers system info, Python computes statistics, and Ruby builds a Markdown table.
  • Prototype an idea using whichever language is most natural for each part of the task.

Can I add my own runtime?

Not yet. Adding a new runtime currently requires modifying the salata-core source code and recompiling. The architecture is designed to make this straightforward in the future, but there is no plugin system today. If you want to add a runtime, the relevant code is in crates/salata-core/src/runtime/.

What output formats does it support?

Any text format. Salata does not impose any structure on the output. Whatever the runtime blocks print to stdout becomes the output. Common formats include:

  • HTML (the most common use case for web serving)
  • JSON (using #content-type application/json)
  • YAML, TOML, INI, and other configuration formats
  • CSV and TSV
  • Plain text reports
  • Markdown
  • Source code (you can use Salata to generate code)

Does it work on Windows?

Salata compiles and builds on Windows. However, runtime availability varies -- you need the language interpreters installed and accessible. The shell sandbox uses Unix-specific features, so shell blocks may behave differently or not work on Windows. For a consistent experience across platforms, the Docker playground is recommended.

Is the shell sandbox secure enough for production?

The shell sandbox implements defense-in-depth with three phases (static analysis, environment hardening, runtime monitoring), but it is not a complete security boundary. Static analysis can be bypassed by sufficiently creative encoding or obfuscation. Do not run untrusted shell code in production. The sandbox is designed to prevent accidental damage and block common attack patterns, not to withstand a determined attacker.

Why can't I use /dev/null in shell blocks?

The shell sandbox blocks all references to the /dev path to prevent access to /dev/tcp, /dev/udp, and other dangerous device files that Bash can use for network connections. /dev/null is collateral damage of this broad block. This is an intentional trade-off -- granular path matching (allow /dev/null but block /dev/tcp) would be more complex and more likely to have bypass vulnerabilities.

Similarly, 2>&1 is blocked because the & character triggers the backgrounding check. The scanner does not distinguish between & (background a process) and >& (redirect file descriptors).


Troubleshooting

How do I debug .slt files?

  1. Check log files. Each runtime has its own log file in the logs/ directory (e.g., python.log, ruby.log, javascript.log). Errors, warnings, and execution details are written here regardless of the display_errors setting.

  2. Enable inline errors. Set display_errors = true in the [salata] section of config.toml (or per-runtime in [runtimes.*]). When enabled, runtime errors are included in the output at the position of the failed block, making it easy to see what went wrong and where.

  3. Test with the CLI. Use the salata CLI binary to process individual .slt files. This eliminates HTTP and server variables from the equation and shows you the raw output.

  4. Simplify. If a multi-runtime file is failing, isolate the problem by testing each runtime block in its own .slt file.

salata refuses to start -- "no config found"

All four Salata binaries require a config.toml file. They look for it in two places:

  1. The path specified by the --config flag.
  2. A file named config.toml in the same directory as the binary.

If neither exists, Salata refuses to start. Create a config.toml or use the --config flag to point to one. You can use scripts/detect-runtimes.sh (Linux/macOS) or scripts/detect-runtimes.ps1 (Windows) to auto-generate a config file that detects your installed runtimes.

"Runtime 'python' is disabled in config.toml"

The runtime you are trying to use has enabled = false in its [runtimes.*] section. Either enable it in config.toml or remove the corresponding blocks from your .slt file.

"No runtimes enabled"

All runtimes are disabled in your config.toml. Enable at least one runtime to process .slt files.

My shell block was rejected but the code looks safe

The shell sandbox uses broad pattern matching. Common false positives include:

  • Using >/dev/null (blocked by the /dev path check)
  • Using 2>&1 (blocked by the & character check)
  • Using command & for backgrounding (blocked by the & check)
  • Referencing /etc/hostname or other /etc paths (blocked by the /etc path check)

Consider using a Python or Ruby block instead for tasks that need these capabilities.

Cross-runtime data is not working

Verify that:

  1. The #set block executes before the #get block (execution is top-to-bottom).
  2. The key names match exactly (they are case-sensitive).
  3. The data is JSON-serializable (strings, numbers, booleans, arrays, objects, null).
  4. You are not using #set/#get in shell blocks (shell macro expansion currently produces invalid syntax -- this is a known issue).

Known Issues / TODO

This page documents known limitations, incomplete features, and planned work.


nginx / Apache Integration Untested

The salata-cgi binary and all its security protections (Slowloris defense, path traversal blocking, input sanitization, etc.) are fully built and unit-tested. However, integration with actual nginx and Apache web servers has not been tested yet. Configuration examples and integration testing are planned.

For now, salata-server is the only tested way to serve .slt files over HTTP.


FastCGI Stub

The salata-fastcgi binary is currently a stub. Running it prints:

Salata FastCGI v0.1.0 — not yet implemented

The full FastCGI daemon (planned as salata-fpm) would listen on a Unix socket or TCP port for persistent connections with nginx and Apache, avoiding per-request process spawning overhead. The module structure and placeholder code exist at crates/salata-fastcgi/.


Shell #set/#get Macros

The #set and #get macro expansion for shell blocks currently produces invalid syntax. Shell's string handling and lack of native JSON support make the expansion non-trivial. For now, use other runtimes (Python, Ruby, JavaScript, TypeScript, PHP) for cross-runtime data sharing. Shell blocks can still read and write files directly, but they cannot participate in the #set/#get data bridge.


Shell Sandbox Side Effects

The shell sandbox's security restrictions have some side effects that affect legitimate use cases:

  • No /dev/null redirects -- the /dev path block prevents >/dev/null, 2>/dev/null, and any other reference to device files. This is collateral damage from blocking /dev/tcp and /dev/udp.
  • No 2>&1 -- the & character check does not distinguish between backgrounding (command &) and file descriptor redirection (2>&1).
  • No backgrounding -- command & is blocked. Long-running background tasks cannot be started from shell blocks.
  • No /etc access -- reading configuration files like /etc/hostname or /etc/os-release is blocked.

These restrictions are by design. See the Shell Sandbox chapter for details.


Windows Support

Salata compiles and builds on Windows (x86_64, i686, ARM64), but it has not been extensively tested on Windows. Known concerns:

  • The shell sandbox uses Unix-specific path conventions (/bin/bash, /usr/bin/sh, etc.) and ulimit. These do not apply on Windows.
  • Runtime binary paths in the default config.toml use Unix paths. Windows users need to update these to point to their installed interpreters.
  • Line ending handling (CRLF vs LF) has not been thoroughly tested.

The Docker playground is recommended for a consistent cross-platform experience.


Uniform AST (Future Vision)

The Uniform AST is a planned feature for cross-language function and class transpilation, with TypeScript as the first-class citizen. The idea is that you would define a class or function in TypeScript, and Salata would transpile it to equivalent Python, Ruby, and PHP code so that all runtimes can use it natively.

Current status: Not implemented. A placeholder module exists at crates/salata-core/src/uniform_ast/mod.rs with comprehensive TODO comments describing the design.

Dependencies: The #set/#get data bridge must be fully implemented and stable first. TypeScript parsing would use the swc crate.

Design constraints: Only a "Salata-compatible" subset of TypeScript would be supported -- no decorators, no mixins, no closures, no async, no metaprogramming, no stdlib mapping. Shell is excluded from transpilation targets.


No Async Execution

All runtime blocks execute synchronously, top-to-bottom. Block 1 must finish before block 2 starts. There is no parallel execution of runtime blocks, even when blocks use different runtimes that could theoretically run concurrently. This simplifies the execution model and guarantees deterministic output, but it means performance scales linearly with the number of blocks.


No Output Caching

Salata caches the parsed structure of .slt files (block positions, include resolutions) by file path and modification time. However, runtime output is never cached -- every request re-executes all runtime blocks. This means the output is always fresh, but it also means repeated requests for the same page do the same work every time.


PHP FastCGI Mode

The php-fpm socket/TCP connection for PHP in FastCGI and Server execution contexts is not yet implemented. PHP blocks currently work in CLI mode (php binary) and CGI mode (php-cgi binary), but the FastCGI mode that would use php-fpm via a Unix socket or TCP connection is planned but not built.

The configuration fields (fastcgi_socket and fastcgi_host in [runtimes.php]) exist and are parsed, but they are not used yet.


Single-Threaded Per Request

Each request is processed sequentially through all runtime blocks. Within a single request, there is no parallelism. If a page has five runtime blocks, they execute one after another. This is a consequence of the synchronous, top-to-bottom execution model and the shared scope system (where blocks of the same language share a single process).

Changelog

v0.1.0 -- Initial Release

First public release of the Salata polyglot text templating engine.

Features

  • 6 runtimes: Python, Ruby, JavaScript, TypeScript, PHP, Shell
  • 4 binaries: salata (CLI), salata-cgi (CGI bridge), salata-fastcgi (stub), salata-server (dev server)
  • Cross-runtime data: #set/#get macros for sharing data between runtimes via JSON serialization
  • Directives: #include, #status, #content-type, #header, #cookie, #redirect
  • Scope management: Shared (default) and isolated scope per block or per runtime
  • Shell sandbox: Three-phase security (static analysis, environment setup, runtime monitoring)
  • CGI protections: Slowloris, path traversal, dotfiles, request limits, null bytes
  • Context-aware PHP: Automatic binary selection (php/php-cgi/php-fpm) based on execution context
  • JS/TS helpers: Injected print() and println() functions
  • Hot reload: File watcher in salata-server for development
  • Project scaffolding: salata init detects runtimes and generates config
  • Docker playground: Interactive container with all runtimes and editors
  • Automatic dedent: Code inside runtime blocks is automatically dedented
  • 8-platform builds: Linux (x86_64, ARM64, i686), macOS (x86_64, ARM64), Windows (x86_64, i686, ARM64)
  • Comprehensive examples: 15 example projects covering CLI and web use cases

Known Limitations

  • FastCGI daemon is a stub
  • Shell #set/#get macros produce invalid syntax
  • Windows builds untested
  • Uniform AST not yet implemented

Contributing

Salata is open source and welcomes contributions. The repository is hosted on GitHub at github.com/nicholasgasior/salata.


Getting Started

  1. Fork the repository on GitHub.
  2. Clone your fork locally:
    git clone https://github.com/<your-username>/salata.git
    cd salata
    
  3. Create a branch for your changes:
    git checkout -b my-feature
    
  4. Make your changes, following the code standards below.
  5. Push your branch and open a pull request against the main branch.

Code Standards

All contributions must follow these rules:

  • Format before committing. Run cargo fmt to ensure consistent code formatting. Unformatted code will not be accepted.
  • Zero clippy warnings. Run cargo clippy and fix all warnings before submitting. The CI pipeline rejects code with clippy warnings.
  • No unwrap() in production code. Use proper error handling with the thiserror crate. unwrap() is acceptable only in test code (#[cfg(test)] modules).
  • Use serde + toml for configuration. All config parsing goes through serde deserialization.
  • Use std::path::PathBuf for file paths. Do not use string manipulation for path handling.
  • Platform-agnostic code. Salata targets Linux, macOS, and Windows. Avoid platform-specific APIs. Use Rust's standard library abstractions for filesystem operations, process spawning, and path handling.
  • Handle line endings. Do not assume \n -- use Rust's cross-platform I/O facilities.
  • UTF-8 everywhere. All input, output, and internal strings are UTF-8.

Project Structure

Salata is a Cargo workspace with five crates:

CratePurpose
salata-coreShared library: config parsing, .slt parser, runtime execution, security, macros, directives
salata-cliThe salata binary (CLI interpreter)
salata-cgiThe salata-cgi binary (CGI bridge with attack protections)
salata-fastcgiThe salata-fastcgi binary (stub, not yet implemented)
salata-serverThe salata-server binary (standalone dev server)

The dependency chain flows in one direction:

salata-core       <-- shared library
salata-cli        <-- depends on salata-core
salata-cgi        <-- depends on salata-core
salata-fastcgi    <-- depends on salata-core
salata-server     <-- depends on salata-cgi --> salata-core

Most contributions will touch salata-core, since it contains the parser, runtime execution engine, and shared logic.


Running Tests

Unit and Integration Tests

cargo test

Unit tests live in #[cfg(test)] modules within each crate. Integration tests live in the tests/integration/ directory and use sample .slt fixtures from tests/fixtures/.

End-to-End Tests

E2E tests run inside Docker and must never assume that runtimes (Python, Ruby, Node, etc.) are installed on the host machine. All six runtimes are installed inside the Docker container.

docker compose -f docker/docker-compose.yml up --build test

E2E tests cover:

  • Each runtime individually
  • Shared and isolated scope
  • #include directive
  • #set/#get macros
  • All other directives (#status, #content-type, #header, #cookie, #redirect)
  • Error handling and display_errors
  • Shell sandbox enforcement
  • CGI protections
  • Static file serving
  • PHP dual mode (CLI vs CGI binary selection)

Pull Request Guidelines

  • Write a clear description. Explain what the change does and why it is needed.
  • Reference issues. If your PR addresses a GitHub issue, reference it in the description (e.g., "Fixes #42").
  • Include tests. New features should come with unit tests. Bug fixes should include a regression test that would have caught the bug.
  • Keep PRs focused. One feature or fix per pull request. Large PRs that mix unrelated changes are harder to review.
  • Run the full test suite (cargo test and cargo clippy) before submitting.

Where to Start

If you are looking for a good first contribution, consider:

  • Improving error messages in crates/salata-core/src/error.rs
  • Adding test coverage for edge cases in the parser
  • Improving documentation
  • Fixing issues labeled good first issue on GitHub

For larger contributions (new runtime, FastCGI implementation, Uniform AST), please open an issue first to discuss the approach before writing code.