Specification (v0.7)
| Authors: | Sebastian Faubel using claude.ai |
| Version: | 0.7 |
| Status: | Draft — the language surface may still change before 1.0. |
| Date: | 2026-07-24 |
Introduction
Section titled “Introduction”Triplate is a templating language for RDF query and data languages. A template
declares its inputs in a mandatory --- frontmatter header and uses ${ }
substitutions, $"…" / $<…> constructs, and {% for %} / {% if %}
directives in the body. Every value is validated and escaped according to its declared RDF
type, so rendered output is injection-safe by construction. The template syntax
is not valid SPARQL/Turtle/N-Triples, so an unprocessed template fails to
parse if it reaches an endpoint by mistake (fail-fast).
This page is the human-readable specification. The formal grammar is in
spec/grammar.ebnf;
the executable conformance suite is in
spec/conformance/.
Every conforming implementation must produce byte-identical output for the
fixtures and raise the named error for every must-throw case.
1. Host-Agnostic by Design
Section titled “1. Host-Agnostic by Design”Triplate’s only special tokens are ${, $", $<, and {%. None is a valid
token in SPARQL, Turtle, TriG, or N-Triples, so fail-fast holds in all of them.
A bare $name (a SPARQL variable) and anything @… (language tags, Turtle
@prefix/@base) pass through untouched. Two regions are inert — a $
or { inside them is literal text:
- IRI references — a complete
<…>(protects percent-encodings like%C3%A9). Build IRIs from variables with$<…>(§5). - String literals —
"…",'…',"""…""",'''…'''. Build strings from variables with$"…"(§4).
Term serialization profiles. SPARQL, Turtle, and TriG share term syntax (bare
42/true, prefixed names), so the default serializers target them. N-Triples/N-Quads require typed literals and forbid prefixed names; that is a per-dialect term profile (planned), not a syntax difference.
Keywords and type names fold ASCII case ({% FOR %}, iri/IRI); variable
names, IRIs, string content, language tags, and the true/false term
literals (RDF term syntax, §2.3) are case-sensitive.
2. The Frontmatter
Section titled “2. The Frontmatter”A template begins with a ----delimited frontmatter block. The whole block,
through the closing --- and its trailing newline, is consumed and never
emitted — so nothing in the header (comments, blank lines) leaks into the
output. Sections are brace-delimited, which keeps parameter names
unrestricted.
---params { service: iri endpoint: iri optional classes: iri[] min 1 tags: string[] optional max 5 people: { id: iri, name: string optional }[] min 1 limit: int}
# a comment inside the frontmatter is metadata (never emitted)example dbpedia "DBpedia — people" { service: <http://dbpedia.org/sparql> classes: [ foaf:Person, foaf:Organization ] limit: 10}---PREFIX foaf: <http://xmlns.com/foaf/0.1/>SELECT ?s WHERE { … }- The frontmatter has a mandatory
params { … }section and zero or moreexample … { … }sections (§2.3). Whitespace and#comments (§2.2) inside---do not affect parsing or output. Both declarations and bindings usename: …. - Types:
iri,pname,string,int,decimal,double,bool,date,dateTime,time,literal(<dt>),term,raw— see §2.1 for the full semantics of each. - Modifiers (fixed order):
<type> ['[]'] ['optional'] ['min' N] ['max' N].[]marks an array;min/maxbound its length (valid only after[]);optionalmarks that a value may be absent. - Records:
{ field: type, … }; fields may be optional, arrays, or nested records. rawinserts a value verbatim everywhere it is used (no validation or escaping) — the single, auditable unsafe escape hatch.- At render the engine validates the whole context up front — missing required parameter, unknown key, wrong type, out-of-range cardinality — before producing any output.
paramsandexampleare keywords only at the frontmatter top level; every parameter name lives inside a brace block, so any name is allowed.
The leading --- is also a positive “this is a Triplate template” marker
for tooling.
2.1 Built-in Types
Section titled “2.1 Built-in Types”Every scalar type owns its own validation and serialization — there is no
generic “string” fallback. ${x} (§3) always serializes per the declared
type; the table below is what a standalone ${x} becomes.
| Type | Host value | Serializes as | Notes |
|---|---|---|---|
iri |
string (absolute IRI) | <…> |
Rejects anything that is not a syntactically valid absolute IRI. |
pname |
string (prefix:local) |
bare prefix:local |
Validated against a conservative prefixed-name grammar; never injects a PREFIX declaration. |
string |
string | "…" |
Escapes \, ", newline, CR, tab. |
int |
integer | bare 42 |
Strict: a numeric string ("10") is not an int. |
decimal |
number | canonical decimal, e.g. 4 → 4.0 |
Always includes a decimal point; a value needing exponential notation is out of range. |
double |
number | canonical scientific notation, e.g. 1500000 → 1.5E6 |
NaN/Infinity/-Infinity serialize as typed literals ("NaN"^^xsd:double, …). |
bool |
boolean | bare true/false |
|
date |
a host date value or ISO YYYY-MM-DD string |
"…"^^xsd:date |
|
dateTime |
a host date value or ISO 8601 string | "…"^^xsd:dateTime |
|
time |
a host date value or ISO HH:MM:SS string |
"…"^^xsd:time |
|
literal(<dt>) |
string | "value"^^<dt> |
<dt> is the exact IRI or prefixed name declared in the header — the general escape hatch for custom datatypes. |
term |
a host-native RDF term object (RDF/JS in TypeScript; rdflib in Python; a Jena RDFNode in Java) |
the term’s own lexical form: <iri> (IRI/NamedNode), _:label (blank node), or "value"[@lang | ^^datatype] (literal) |
Host-library-specific; Python’s term requires the optional rdflib extra. |
raw |
string | inserted verbatim | No validation or escaping — the single, auditable unsafe escape hatch (§9.2). |
Arrays (type[]) and records ({ field: type, … }) compose these scalars —
see Modifiers and Records above — but are not themselves types with
their own serialization: an array is consumed by {% for %} (§6) or spread
(§3.1); a record’s fields are referenced individually (${u.id}).
2.2 Comments
Section titled “2.2 Comments”---params { service: iri # the target endpoint}# a comment inside the frontmatter is metadata (never emitted)example dbpedia "DBpedia" { service: <http://dbpedia.org/sparql>}---# to the end of the line is a comment — standalone on its own line, or
trailing after a declaration or binding on the same line. Comments are
consumed like the rest of the frontmatter (never emitted) and retained as
positioned comment symbols so tooling (e.g. formatters) can preserve and
re-indent them. A # inside a quoted string (e.g. an example description)
is not a comment — the same inert-string rule as the body (§1) applies.
Comments are frontmatter-only. In the body, # is ordinary text: it is
not a comment and does not suppress interpolation. # ${title} in the body
renders as a normal Markdown ATX heading with ${title} interpolated — see
the Publishing examples.
2.3 Example Blocks
Section titled “2.3 Example Blocks”example dbpedia "DBpedia — people" { service: <http://dbpedia.org/sparql> classes: [ foaf:Person, foaf:Organization ] limit: 10}example <id> ["<description>"] { … } — <id> is a unique slug; the
description is optional. Bindings use name: value, where values are RDF term
syntax (<…>, prefix:local, "…"/@lang/^^dt, numbers, bools, [ … ],
{ … }) and are validated against params. Example sets are
development/preview fixtures, not production defaults: render(context)
still requires real values, while previewExample(id) renders with a set.
Prefixed names in examples are resolved against the template’s PREFIX
declarations for preview.
3. Values — ${ … }
Section titled “3. Values — ${ … }”A reference is ${ path }, where path is Ident('.'Ident)*. Its type comes
from the declaration; there are no inline types. The same reference serializes
differently by construct:
| Construct | ${x} becomes |
|---|---|
standalone ${x} |
serialized per its declared type (iri→<…>, string→"…", int→42, …) |
inside $"…" |
the value’s lexical content, string-escaped |
inside $<…> |
the value’s lexical content, percent-encoded; the assembled IRI is validated absolute |
raw values are inserted verbatim in all three.
3.1 Spread — ${ ... path }
Section titled “3.1 Spread — ${ ... path }”VALUES ?g { ${...graphs} } → VALUES ?g { <…a> <…b> <…c> }FILTER(?o IN (${...ids join ","})) → FILTER(?o IN (1 , 2 , 3))FILTER(?o IN (${...ids join "," explicit}))→ FILTER(?o IN (1,2,3))${ ...path } expands an array of a serializable scalar, serializing each
element exactly as a standalone ${element} would, then joining them. path
must resolve to an array (a non-array is a type error) of a scalar — a record
array is a type error; loop over it with {% for %} instead. An empty array
emits nothing.
The optional join "<text>" [explicit] clause is identical to the loop’s (§6):
the text is padded with one space each side unless explicit. Unlike the loop,
the default separator (no join) is a single space, since adjacent terms
need a delimiter to be valid (<a> <b>, not <a><b>). join/explicit are
only valid after ....
4. Strings — $"…"
Section titled “4. Strings — $"…"”$"Hello ${name}" → "Hello World"$"Result #${index}: ${label}" → "Result #2: Acme"$"localized"@en → "localized"@en$"Hello ${name}"@${lang} → "Hello World"@de (dynamic tag)$"42"^^xsd:int → "42"^^xsd:intHoles are ${ … }. Author escapes \\, \", \n, \r, \t are recognized;
output is re-escaped. A ${ } whose value is raw is inserted verbatim
(unsafe). The suffix is a static @lang, a dynamic @${lang}, or a static
^^<iri> / ^^prefix:name.
5. IRIs — $<…>
Section titled “5. IRIs — $<…>”$<http://example.org/person/${id}> → <http://example.org/person/42>$<http://example.org/${ns}/item> → <http://example.org/core/item> (ns: raw)Holes are percent-encoded to the unreserved set A–Z a–z 0–9 - . _ ~
(everything else, including / ? # : and non-ASCII, → UTF-8 %XX), so each
hole is one opaque component. A raw value is inserted verbatim. The assembled
string is validated as an absolute IRI — so even raw cannot break out.
6. Loops — {% for %}
Section titled “6. Loops — {% for %}”{% for c in classes join "UNION" %} { ?s a ${c} }{% endfor %}{% for <item> in <source> [join "<text>" [explicit]] %} … {% endfor %}.
<source>is a declared array parameter (classes) or a path into a loop variable (g.members) for nesting.- The element type comes from the source; cardinality is declared in the
header (no
+at the loop). - Join: the separator is emitted between iterations only; boundary
whitespace is merged. By default the join text is padded with one space each
side (
join "UNION"→… } UNION { …);explicitinserts it verbatim. - Block trimming: a directive alone on its line has its line (and newline) removed; an inline tag renders in place.
7. Conditionals — {% if %}
Section titled “7. Conditionals — {% if %}”{% if nameFilter %}FILTER(CONTAINS(?n, ${nameFilter})){% endif %}{% if limit %}LIMIT ${limit}{% else %}LIMIT 100{% endif %}{% if <cond> %} [{% elif <cond> %}] [{% else %}] {% endif %}. Conditions are
type-directed — well-defined because everything is declared:
| Declared as | {% if x %} tests |
|---|---|
bool |
its value |
anything optional |
whether it is present |
| array | whether it is non-empty |
| required scalar | compile error (always true) |
not negates. There are no comparison operators. {% if %} is what makes
optional parameters consumable.
8. API
Section titled “8. API”compile(template) -> CompiledTemplate (parse once)CompiledTemplate.render(context) -> string (render many)CompiledTemplate.schema, .examples, .previewExample(id)render(template, context) -> string (one-shot)Errors: TriplateError → TriplateSyntaxError (compile),
TriplateBindingError, TriplateTypeError, TriplateCardinalityError
(render), with line/column where applicable.
9. Security Model
Section titled “9. Security Model”Every value is declared with an RDF type in the --- frontmatter and validated + escaped when rendered. A value that does not satisfy its type throws
instead of being emitted:
irirejects anything that is not a syntactically valid absolute IRI, so a value likehttp://x/> . } DROP GRAPH <gthrows instead of breaking out of<…>.stringand$"…"content escape\,", newlines, CR, tab.- Numeric/
bool/date types accept only matching host values and emit canonical forms;"10"is not anint. pnameenforces a conservative prefixed-name subset and never injectsPREFIXdeclarations.$<…>percent-encodes each hole and validates the assembled IRI as absolute.
9.1 Up-front validation & fail-fast
Section titled “9.1 Up-front validation & fail-fast”The render context is validated against the header before any output is
produced. And the ${ } / $"…" / $<…> / {% … %} syntax and the leading
--- are invalid in SPARQL/Turtle/N-Triples, so a template that reaches a
parser unrendered fails loudly. The conformance suite checks both directions
with a real SPARQL parser.
9.2 The raw type
Section titled “9.2 The raw type”raw inserts a value verbatim, unescaped. It is the single, auditable
unsafe path — declared in the frontmatter, so a reviewer greps one place. Never
feed user input into a raw parameter.
10. Deferred
Section titled “10. Deferred”Production defaults for optional params (?=), {% elif %} is supported but
comparison conditions are not, value filters, {% include %}, the N-Triples
term profile, and host-language type generation from the header.