Skip to content

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

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.

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.

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 more example … { … } sections (§2.3). Whitespace and # comments (§2.2) inside --- do not affect parsing or output. Both declarations and bindings use name: ….
  • 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/max bound its length (valid only after []); optional marks that a value may be absent.
  • Records: { field: type, … }; fields may be optional, arrays, or nested records.
  • raw inserts 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.
  • params and example are 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.

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. 44.0 Always includes a decimal point; a value needing exponential notation is out of range.
double number canonical scientific notation, e.g. 15000001.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}).

---
params {
service: iri # the target endpoint
people: {
id: iri # a comment nests as deep as the header does
name: string
}[]
}
# a comment inside the frontmatter is metadata (never emitted)
example dbpedia "DBpedia" {
service: <http://dbpedia.org/sparql>
people: [
{ id: <http://dbpedia.org/resource/Ada_Lovelace>, # the first entry
name: "Ada Lovelace" }
]
}
---

# to the end of the line is a comment. It may appear wherever an item may start — at any nesting depth — standalone on its own line, or trailing an item on the same line: between params/example sections, between declarations or bindings, between the fields of a record type, and between the elements of an example list or record.

A comment may not split an item: # between a name and its :, between : and the type or value, or between example and its id, is a syntax error.

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.

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.

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"…", int42, …)
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.

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 ....

$"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:int

Holes 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.

$<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.

{% 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 { …); explicit inserts it verbatim.
  • Block trimming: a directive alone on its line has its line (and newline) removed; an inline tag renders in place.
{% 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.

compile(template) -> CompiledTemplate (parse once)
CompiledTemplate.render(context) -> string (render many)
CompiledTemplate.schema, .examples, .previewExample(id)
render(template, context) -> string (one-shot)

Errors: TriplateErrorTriplateSyntaxError (compile), TriplateBindingError, TriplateTypeError, TriplateCardinalityError (render), with line/column where applicable.

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:

  • iri rejects anything that is not a syntactically valid absolute IRI, so a value like http://x/> . } DROP GRAPH <g throws instead of breaking out of <…>.
  • string and $"…" content escape \, ", newlines, CR, tab.
  • Numeric/bool/date types accept only matching host values and emit canonical forms; "10" is not an int.
  • pname enforces a conservative prefixed-name subset and never injects PREFIX declarations.
  • $<…> percent-encodes each hole and validates the assembled IRI as absolute.

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.

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.

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.