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
.slttemplate. Each block runs in its native interpreter -- no transpilation, no emulation. -
Cross-runtime data sharing. The
#set/#getmacro 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:
phpfor CLI,php-cgifor CGI, andphp-fpmfor 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
salataCLI interpreter,salata-cgibridge,salata-fastcgidaemon (stub), andsalata-serverdev 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.
Project links
- GitHub: github.com/nicholasgasior/salata
- License: See the repository for license details
Next steps
- Installation -- build Salata from source
- Quick Start -- get running in 5 minutes
- Your First .slt File -- write and run your first template
- Playground Guide -- try Salata in a Docker container with all runtimes pre-installed
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:
| Runtime | Binary | Common locations |
|---|---|---|
| Python | python3 (or python) | /usr/bin/python3, /usr/local/bin/python3, /opt/homebrew/bin/python3 |
| Ruby | ruby | /usr/bin/ruby, /usr/local/bin/ruby, /opt/homebrew/bin/ruby |
| JavaScript | node | /usr/bin/node, /usr/local/bin/node, /opt/homebrew/bin/node |
| TypeScript | tsx, ts-node, or bun | /usr/local/bin/tsx, /usr/local/bin/ts-node |
| PHP | php (CLI), php-cgi (CGI) | /usr/bin/php, /usr/bin/php-cgi, /opt/homebrew/bin/php |
| Shell | bash, 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/:
| Binary | Purpose |
|---|---|
salata | Core CLI interpreter. Processes .slt files and writes output to stdout. |
salata-cgi | CGI bridge with attack protections. Built, but nginx/Apache integration not yet tested. |
salata-fastcgi | FastCGI daemon (stub -- not yet implemented). |
salata-server | Standalone 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:
- Detects runtimes -- checks well-known paths and falls back to
which(Unix) orwhere(Windows) for each of the six runtimes - Generates
config.toml-- with detected paths,enabled = truefor found runtimes andenabled = falsefor missing ones - Creates
index.slt-- a starter template using the first available runtime (prefers Python, then Node.js, Ruby, Shell, PHP, TypeScript) - Creates
errors/404.sltanderrors/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:
| Script | Platform |
|---|---|
scripts/detect-runtimes.sh | Linux, macOS (Bash) |
scripts/detect-runtimes.bat | Windows (CMD) |
scripts/detect-runtimes.ps1 | Windows (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/python3is 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
whereinstead ofwhich. Shell paths must still be absolute. Common paths differ (C:\Python312\python.exe, etc.). The detection scripts handle these differences.
Tip: The
config.tomlfile uses absolute paths to runtime binaries. If you move runtimes or switch between system and Homebrew installations, re-runsalata initor 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 -- go from zero to running output in 5 minutes
- Your First .slt File -- understand
.sltsyntax step by step - Playground Guide -- try Salata in Docker with all runtimes
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:
- Salata reads the
.sltfile - Resolves any
#includedirectives (text substitution) - Parses the content, finding runtime blocks (
<python>,<ruby>, etc.) and plain text - Expands
#set/#getmacros into native code for each runtime - Executes each runtime block in its native interpreter, capturing stdout
- Splices the captured output back into the document in place of the runtime tags
- 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 -- a step-by-step tutorial on
.sltsyntax - Playground Guide -- full details on the Docker playground environment
- SLT Syntax -- complete syntax reference
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-typethat control processing behavior - Macros --
#setand#getcalls 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()andprintln()helper functions.print()writes without a trailing newline (likeprocess.stdout.write()), andprintln()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:
#setand#getare 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.htmlto save the result - Use as a config generator: Write a
.sltfile 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 3000serves.sltfiles over HTTP with hot reload
Next steps
- Playground Guide -- try all runtimes in Docker
- SLT Syntax -- complete syntax reference
- Directives --
#include,#status,#content-type, and more - Macros (#set / #get) -- cross-runtime data sharing in depth
- Scope (Shared vs Isolated) -- controlling process sharing
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-nodeandtsx - 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-highlightedcatreplacement)- Starship prompt
Salata binaries (pre-built):
salata-- CLI interpretersalata-cgi-- CGI bridgesalata-fastcgi-- FastCGI stubsalata-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 runwhich gives you an interactive session directly. Thedocker compose up -dapproach starts the container in the background, and you attach withdocker exec.
First run
The first time you start the playground, Docker builds the image. This takes a few minutes because it:
- Installs all runtimes and tools from Ubuntu packages
- Sets up Node.js via nodesource
- Installs TypeScript tooling globally (
typescript,ts-node,tsx) - Installs the Rust stable toolchain
- Installs Starship prompt
- Copies the Salata source and runs
cargo build --release - Runs
salata initto 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
- Your First .slt File -- step-by-step tutorial on
.sltsyntax - SLT Syntax -- complete syntax reference
- Runtime Details -- language-specific behavior and configuration
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 newlineprocess.stdout.write()-- standard Node.js, no newlineprint()-- Salata-injected helper, no newline (equivalent toprocess.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:
| Context | Binary |
|---|---|
CLI (salata) | php (via cli_path) |
CGI (salata-cgi) | php-cgi (via cgi_path) |
| FastCGI / Server | php-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
| Runtime | Primary Output | Alternatives |
|---|---|---|
| Python | print() | sys.stdout.write() |
| Ruby | puts | print, $stdout.write() |
| JavaScript | console.log() | print(), println(), process.stdout.write() |
| TypeScript | console.log() | print(), println(), process.stdout.write() |
| PHP | echo | print, printf() |
| Shell | echo | printf |
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
#statusdirectives 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>
#header
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>
#cookie
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
| Directive | Repeatable | Where | Purpose |
|---|---|---|---|
#include | Yes | Outside blocks | Paste file contents in place |
#status | No | Outside blocks | Set HTTP status code |
#content-type | No | Outside blocks | Set response MIME type |
#header | Yes | Outside blocks | Add custom response header |
#cookie | Yes | Outside blocks | Set response cookie |
#redirect | No | Outside blocks | Issue 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:
| Type | Python | Ruby | JavaScript | TypeScript | PHP |
|---|---|---|---|---|---|
| String | str | String | string | string | string |
| Number (int) | int | Integer | number | number | int |
| Number (float) | float | Float | number | number | float |
| Boolean | bool | TrueClass/FalseClass | boolean | boolean | bool |
| Array/List | list | Array | Array | Array | array |
| Object/Dict | dict | Hash | Object | Object | array (assoc) |
| Null | None | nil | null | null | null |
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:
- Expansion happens before execution -- the runtime sees native code, not macro syntax
- JSON is the interchange format -- data is serialized to JSON by the setter and deserialized by the getter
- Salata is the broker -- runtimes never communicate directly with each other
- 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:
- Python creates the report data and stores it with
#set - Ruby retrieves the data and renders the heading
- 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"© {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
#includedirectives (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:
- Salata collects all shared-scope blocks for a given language
- Between each block's code, Salata injects a print statement that outputs the boundary marker
- The concatenated code is sent to one runtime process
- The runtime executes all blocks sequentially, producing output with boundary markers between sections
- Salata splits the output at the boundary markers
- 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
--config /path/to/config.tomlflag (explicit path)config.tomlin the same directory as the binary- 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.
| Field | Type | Default | Description |
|---|---|---|---|
display_errors | bool | true | Show runtime errors in the output. When false, errors are logged but not displayed. Individual runtimes can override this. |
default_content_type | string | "text/html; charset=utf-8" | Default MIME type for responses when no #content-type directive is used. |
encoding | string | "utf-8" | Enforced character encoding for all input and output. |
[server]
Settings specific to salata-server.
| Field | Type | Default | Description |
|---|---|---|---|
hot_reload | bool | true | Watch for file changes and trigger reparse in dev mode. |
See Server Configuration for details.
[logging]
Log file management.
| Field | Type | Default | Description |
|---|---|---|---|
directory | string | "./logs" | Log directory, relative to the binary location. Created on first run. |
rotation_max_size | string | "50MB" | Maximum size of a log file before rotation. |
rotation_max_files | int | 10 | Maximum 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:
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable this runtime. |
path | string | varies | Absolute path to the runtime binary. |
shared_scope | bool | true | All blocks of this language share one process. |
display_errors | bool | (inherited) | Override the global display_errors setting for this runtime. |
PHP has additional fields for its context-aware binary selection:
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "cgi" | PHP execution mode: "cgi" or "fastcgi". |
cli_path | string | "/usr/bin/php" | Path to the PHP CLI binary (used in CLI context). |
cgi_path | string | "/usr/bin/php-cgi" | Path to php-cgi (used in CGI context). |
fastcgi_socket | string | (none) | Unix socket path for php-fpm (FastCGI/Server context). |
fastcgi_host | string | (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.
| Field | Type | Default | Description |
|---|---|---|---|
page_404 | string | "./errors/404.slt" | Path to the 404 error page. Can be a .slt file. |
page_500 | string | "./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
.sltfile 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/python3or/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/rubyor/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/nodeor/usr/local/bin/node - macOS (Homebrew):
/usr/local/bin/nodeor/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 runnertsx-- a faster alternative to ts-nodebun-- Bun's built-in TypeScript supportdeno-- 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
| Field | Used When | Description |
|---|---|---|
mode | Always | "cgi" or "fastcgi" -- determines how PHP is invoked |
cli_path | salata (CLI context) | Path to the php binary for command-line use |
cgi_path | salata-cgi (CGI context) | Path to the php-cgi binary |
fastcgi_socket | salata-fastcgi / salata-server | Unix socket path for php-fpm |
fastcgi_host | salata-fastcgi / salata-server | TCP address for php-fpm (e.g., "127.0.0.1:9000") |
The binary selection follows the execution context:
| Binary | Context | PHP Binary Used |
|---|---|---|
salata | Cli | cli_path (php) |
salata-cgi | Cgi | cgi_path (php-cgi) |
salata-fastcgi | FastCgi | fastcgi_socket or fastcgi_host (php-fpm) |
salata-server | Server | fastcgi_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:
.sltfiles 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:SSformat - LEVEL --
ERROR,WARN, orINFO - RUNTIME -- which language runtime produced the log entry
- FILE:LINE -- the
.sltfile 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_errors | Output | Log file |
|---|---|---|
true | Error shown in output | Error logged |
false | Error hidden from output | Error 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
| Language | Tag | Output Method | Notes |
|---|---|---|---|
| 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> | echo | Context-aware binary selection |
| Shell | <shell> | echo, printf | Sandboxed, 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:
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether this runtime is available |
path | string | varies | Absolute path to the runtime binary |
shared_scope | bool | true | Whether blocks share one process per page |
display_errors | bool | (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
| Property | Value |
|---|---|
| Tag | <python> |
| Output method | print() |
| Default binary | /usr/bin/python3 |
| Shared scope | true (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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable the Python runtime |
path | string | /usr/bin/python3 | Absolute path to the Python 3 binary |
shared_scope | bool | true | All blocks share one process per page |
display_errors | bool | (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
pathpoints to a Python 3 interpreter. - Use f-strings for readable output formatting.
- Heavy imports (like
pandasornumpy) 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
.sltfile being processed.
Ruby Runtime
| Property | Value |
|---|---|
| Tag | <ruby> |
| Output method | puts, print, STDOUT.write |
| Default binary | /usr/bin/ruby |
| Shared scope | true (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 newlineprint-- writes a string with no trailing newlineSTDOUT.write-- writes raw bytes to stdout, returns the number of bytes written$stdout.write-- equivalent toSTDOUT.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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable the Ruby runtime |
path | string | /usr/bin/ruby | Absolute path to the Ruby binary |
shared_scope | bool | true | All blocks share one process per page |
display_errors | bool | (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
Structor 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
.sltfile being processed.
JavaScript Runtime
| Property | Value |
|---|---|
| Tag | <javascript> |
| Output method | console.log(), process.stdout.write(), print(), println() |
| Default binary | /usr/bin/node |
| Shared scope | true (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.
| Function | Trailing Newline | Notes |
|---|---|---|
print("hello") | No | Injected by Salata |
println("hello") | Yes | Injected by Salata |
console.log("hello") | Yes | Standard Node.js |
process.stdout.write(s) | No | Standard 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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable the JavaScript runtime |
path | string | /usr/bin/node | Absolute path to the Node.js binary |
shared_scope | bool | true | All blocks share one process per page |
display_errors | bool | (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. Useprintln()orconsole.log()when you want newlines. - The working directory during execution is the directory containing the
.sltfile being processed.
TypeScript Runtime
| Property | Value |
|---|---|
| Tag | <typescript> |
| Output method | console.log(), process.stdout.write(), print(), println() |
| Default binary | /usr/bin/ts-node |
| Shared scope | true (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:
| Runner | Config path Example | Notes |
|---|---|---|
| ts-node | /usr/bin/ts-node | Default. Widely used, requires Node.js. |
| tsx | /usr/local/bin/tsx | Faster startup, esbuild-based. |
| bun | /usr/local/bin/bun | All-in-one JS/TS runtime. |
| deno | /usr/local/bin/deno | Secure-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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable the TypeScript runtime |
path | string | /usr/bin/ts-node | Absolute path to the TypeScript runner |
shared_scope | bool | true | All blocks share one process per page |
display_errors | bool | (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()andprintln()helpers are identical to those in the JavaScript runtime. - If startup time matters, consider
tsxorbunas the runner -- they are typically faster thants-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
.sltfile being processed.
PHP Runtime
| Property | Value |
|---|---|
| Tag | <php> |
| Output method | echo |
| Shared scope | true (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 Binary | Execution Context | PHP Binary Used | Config Field |
|---|---|---|---|
salata (CLI) | Cli | php | cli_path |
salata-cgi | Cgi | php-cgi | cgi_path |
salata-fastcgi | FastCgi | php-fpm (socket/TCP) | fastcgi_socket / fastcgi_host |
salata-server | Server | php-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 runningsalata template.slt > output.htmlfrom the command line. -
php-cgifollows 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-fpmis 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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable the PHP runtime |
mode | string | "cgi" | PHP mode: "cgi" or "fastcgi" |
cli_path | string | /usr/bin/php | Path to php binary (used in CLI context) |
cgi_path | string | /usr/bin/php-cgi | Path to php-cgi binary (used in CGI context) |
fastcgi_socket | string | (unset) | Unix socket path for php-fpm |
fastcgi_host | string | (unset) | TCP host:port for php-fpm (e.g., 127.0.0.1:9000) |
shared_scope | bool | true | All blocks share one process per page |
display_errors | bool | (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 spawnsphporphp-cgias a child process."fastcgi"-- Used for FastCGI and Server contexts. Salata connects to an already-runningphp-fpmprocess 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), thecli_pathbinary is used. You do not needphp-cgiorphp-fpminstalled for CLI-only usage. - For production CGI setups, ensure
php-cgiis installed and thecgi_pathis correct. - The working directory during execution is the directory containing the
.sltfile being processed.
Shell Runtime
| Property | Value |
|---|---|
| Tag | <shell> |
| Output method | echo, printf |
| Default binary | /bin/bash |
| Shared scope | true (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:
| Pattern | Reason |
|---|---|
& | Background execution / job control |
| bash, | sh | Piping into a shell |
eval | Arbitrary code execution |
exec | Process replacement |
| Fork bombs | Denial of service |
/dev/tcp | Network access via bash pseudo-devices |
Blocked paths -- filesystem paths that should not be accessed from templates:
| Path | Reason |
|---|---|
/dev | Device files (includes /dev/null) |
/proc | Process information filesystem |
/sys | Kernel and hardware interface |
/etc | System 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:
| Pattern | Why 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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable the Shell runtime |
path | string | /bin/bash | Absolute path to the shell (must be whitelisted) |
shared_scope | bool | true | All blocks share one process per page |
display_errors | bool | (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:
| Module | Responsibility |
|---|---|
config.rs | TOML configuration parsing and validation |
context.rs | ExecutionContext 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.rs | Shared and isolated scope management |
cache.rs | Parsed file caching by path + mtime |
logging.rs | Log formatting and rotation |
error.rs | Error types and display_errors logic |
security.rs | Shell 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.sltfile 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.sltfile, 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.sltfiles 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:
--config /path/to/config.tomlcommand-line flagconfig.tomlin the same directory as the binary- 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-cgibinary 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, usesalata-serverto serve.sltfiles 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:
.sltfiles -- 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:
--config /path/to/config.tomlflag (explicit path)config.tomlin the same directory as the binary- 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
| Binary | Context |
|---|---|
salata (CLI) | Cli |
salata-cgi | Cgi |
salata-fastcgi | FastCgi |
salata-server | Server |
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:
| Context | PHP Binary Used | Config Field |
|---|---|---|
Cli | php (CLI SAPI) | cli_path |
Cgi | php-cgi (CGI SAPI) | cgi_path |
FastCgi | php-fpm via socket or TCP | fastcgi_socket / fastcgi_host |
Server | php-fpm via socket or TCP | fastcgi_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:
| Variable | Description |
|---|---|
REQUEST_METHOD | HTTP method (GET, POST, etc.) |
QUERY_STRING | URL query parameters |
CONTENT_TYPE | Request body MIME type |
CONTENT_LENGTH | Request body size in bytes |
HTTP_HOST | Host header value |
HTTP_COOKIE | Cookie header value |
REMOTE_ADDR | Client IP address |
REQUEST_URI | Full request URI |
PATH_INFO | Extra path information |
SERVER_NAME | Server hostname |
SERVER_PORT | Server port number |
HTTP_AUTHORIZATION | Authorization 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-cgiwith CGI environment variables pointing to a.sltfile - Server:
salata-serverreceives an HTTP request for a.sltfile
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:
| Directive | Effect | Multiplicity |
|---|---|---|
#status 404 | Sets the HTTP response status code | Once per page |
#content-type application/json | Sets the Content-Type header | Once per page |
#header "X-Custom" "value" | Adds a custom response header | Multiple allowed |
#cookie "name" "value" flags | Sets a response cookie | Multiple allowed |
#redirect "/url" | Sets a redirect response | Once 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 positiondisplay_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
Configstruct - Parser --
.sltfile 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/#getexpansion 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_errorsresolution - Caching -- parsed file cache by path + mtime
- Context -- the
ExecutionContextenum
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-.sltfiles (HTML, CSS, JS, images, fonts, media) with correct MIME typeshot_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:
| Crate | Purpose |
|---|---|
serde | Serialization/deserialization framework |
toml | TOML configuration file parsing |
thiserror | Ergonomic error type definitions |
actix-web | HTTP 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.
| Pattern | Reason |
|---|---|
& (single ampersand) | Blocks backgrounding of processes. Note that && (logical AND) is allowed. |
| bash, | sh, | zsh, | dash, | fish | Prevents piping output into a shell interpreter. |
eval | Blocks dynamic code evaluation. |
exec | Blocks process replacement. |
source | Blocks 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 --decode | Blocks decoding of obfuscated payloads. |
xxd -r | Blocks hex-to-binary conversion. |
\x, \u00, $'\x | Blocks encoding-based bypass attempts. |
history, HISTFILE | Blocks shell history access and manipulation. |
export PATH, export LD_ | Blocks PATH and dynamic linker variable manipulation. |
LD_PRELOAD, LD_LIBRARY_PATH | Blocks library injection attacks. |
:(), bomb() | Blocks fork bomb function definitions. |
while true; do, while :; do | Blocks 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:
| Setting | Default | Description |
|---|---|---|
header_timeout | 5s | Maximum time allowed to receive all HTTP headers. If the client has not finished sending headers within this window, the connection is dropped. |
body_timeout | 30s | Maximum time allowed to receive the full request body. Applies after headers are received. |
min_data_rate | 100b/s | Minimum 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.
| Setting | Default | Description |
|---|---|---|
max_url_length | 2048 | Maximum length of the request URL in characters. Prevents extremely long URLs from consuming parser resources. |
max_header_size | 8KB | Maximum total size of all HTTP headers combined. |
max_header_count | 50 | Maximum number of individual HTTP headers. Prevents header flooding attacks. |
max_query_string_length | 2048 | Maximum length of the query string portion of the URL. |
max_body_size | 10MB | Maximum 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.
| Setting | Default | Description |
|---|---|---|
max_connections_per_ip | 20 | Maximum simultaneous connections from a single IP address. Limits the impact of a single attacker. |
max_total_connections | 200 | Maximum total simultaneous connections across all clients. Hard ceiling on concurrency. |
max_execution_time | 30s | Maximum time a single request is allowed to run, including all runtime block execution. Requests exceeding this are killed. |
max_memory_per_request | 128MB | Maximum memory a single request and its runtime processes may consume. |
max_response_size | 50MB | Maximum size of the generated response. Prevents runaway output from consuming disk or memory. |
response_timeout | 60s | Maximum 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.
| Setting | Default | Description |
|---|---|---|
block_path_traversal | true | Blocks any request URL containing ../ sequences. Prevents attackers from escaping the document root to access arbitrary files. |
block_dotfiles | true | Blocks 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.
| Setting | Default | Description |
|---|---|---|
block_null_bytes | true | Rejects 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_headers | true | Rejects requests with non-printable ASCII characters in HTTP headers. Prevents header injection and response splitting attacks. |
validate_content_length | true | Verifies 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.
| Setting | Default | Description |
|---|---|---|
max_child_processes | 10 | Maximum 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_network | true | Controls 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/#getmacro 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:
- Pre-execution static analysis -- the code is scanned for blocked commands, blocked patterns, and blocked paths before it runs.
- Environment hardening -- the process launches with a clean PATH, stripped environment variables, locked working directory, and ulimit enforcement.
- 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/etcfrom 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
| Protection | Shell | Python | Ruby | JS | TS | PHP |
|---|---|---|---|---|---|---|
| Process isolation | Yes | Yes | Yes | Yes | Yes | Yes |
| Timeout enforcement | Yes | Yes | Yes | Yes | Yes | Yes |
| Memory limits | Yes | Yes | Yes | Yes | Yes | Yes |
| Output size limits | Yes | Yes | Yes | Yes | Yes | Yes |
| Child process limits | Yes | Yes | Yes | Yes | Yes | Yes |
| Command-level scanning | Yes | No | No | No | No | No |
| Environment stripping | Yes | No | No | No | No | No |
| Blocked paths | Yes | No | No | No | No | No |
| Network control | Yes | No | No | No | No | No |
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 & Cookies — Salata</title>
</head>
<body>
<h1>Custom Headers & 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 withproductandtotalkeysgrand_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:
- 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. - 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. - Execution is strictly top-to-bottom. When the Ruby block runs, the Python block has already finished and its
#setdata 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:
| Data | Python type | Ruby type | JavaScript type |
|---|---|---|---|
raw_sales | list of dict | Array of Hash | Array of Object |
product_totals | list of dict | Array of Hash | Array of Object |
grand_total | int | Integer | Number |
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?
-
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 thedisplay_errorssetting. -
Enable inline errors. Set
display_errors = truein the[salata]section ofconfig.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. -
Test with the CLI. Use the
salataCLI binary to process individual.sltfiles. This eliminates HTTP and server variables from the equation and shows you the raw output. -
Simplify. If a multi-runtime file is failing, isolate the problem by testing each runtime block in its own
.sltfile.
salata refuses to start -- "no config found"
All four Salata binaries require a config.toml file. They look for it in two places:
- The path specified by the
--configflag. - A file named
config.tomlin 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/devpath check) - Using
2>&1(blocked by the&character check) - Using
command &for backgrounding (blocked by the&check) - Referencing
/etc/hostnameor other/etcpaths (blocked by the/etcpath check)
Consider using a Python or Ruby block instead for tasks that need these capabilities.
Cross-runtime data is not working
Verify that:
- The
#setblock executes before the#getblock (execution is top-to-bottom). - The key names match exactly (they are case-sensitive).
- The data is JSON-serializable (strings, numbers, booleans, arrays, objects, null).
- You are not using
#set/#getin 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/nullredirects -- the/devpath block prevents>/dev/null,2>/dev/null, and any other reference to device files. This is collateral damage from blocking/dev/tcpand/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
/etcaccess -- reading configuration files like/etc/hostnameor/etc/os-releaseis 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.) andulimit. These do not apply on Windows. - Runtime binary paths in the default
config.tomluse 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/#getmacros 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()andprintln()functions - Hot reload: File watcher in
salata-serverfor development - Project scaffolding:
salata initdetects 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/#getmacros 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
- Fork the repository on GitHub.
- Clone your fork locally:
git clone https://github.com/<your-username>/salata.git cd salata - Create a branch for your changes:
git checkout -b my-feature - Make your changes, following the code standards below.
- Push your branch and open a pull request against the
mainbranch.
Code Standards
All contributions must follow these rules:
- Format before committing. Run
cargo fmtto ensure consistent code formatting. Unformatted code will not be accepted. - Zero clippy warnings. Run
cargo clippyand fix all warnings before submitting. The CI pipeline rejects code with clippy warnings. - No
unwrap()in production code. Use proper error handling with thethiserrorcrate.unwrap()is acceptable only in test code (#[cfg(test)]modules). - Use
serde+tomlfor configuration. All config parsing goes through serde deserialization. - Use
std::path::PathBuffor 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:
| Crate | Purpose |
|---|---|
salata-core | Shared library: config parsing, .slt parser, runtime execution, security, macros, directives |
salata-cli | The salata binary (CLI interpreter) |
salata-cgi | The salata-cgi binary (CGI bridge with attack protections) |
salata-fastcgi | The salata-fastcgi binary (stub, not yet implemented) |
salata-server | The 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
#includedirective#set/#getmacros- 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 testandcargo 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 issueon GitHub
For larger contributions (new runtime, FastCGI implementation, Uniform AST), please open an issue first to discuss the approach before writing code.