Redirect Language (RDL)
RDL (Redirect Language) is the configuration format used by liblin. It maps an incoming host and path to the location of a text file. The first line of that text file is the URL the visitor is redirected to.
An RDL file (also called a domain-slurps file) is a single EDN map:
- each key is the host and port of an incoming request
(
"example.com:80"); - each value is a Liquid-style URL template rendered with the request’s domain and path segments.
The file is read on every request, so entries can be changed without restarting the server. For deployment and environment setup, see Hosting.
Quick example
{"example.com:80"
"https://example.com/links/{{ paths[0] | index-if-empty }}.txt"}
A request for http://example.com:80/hello is resolved as follows:
| Value | Result |
|---|---|
| Matched key | example.com:80 |
paths | ["hello"] |
| Rendered location | https://example.com/links/hello.txt |
liblin fetches that location. If the file begins with:
https://example.com/welcome
the response is 302 Found with Location: https://example.com/welcome.
Only the first line of the file is used.
Configuration file
The RDL file is read from the path in the LIBLIN_DOMAINS environment
variable:
export LIBLIN_DOMAINS=/etc/redirect/domain-slurps.edn
domain-slurps.example.edn in the liblin source repository is a
starting-point template. An RDL file must:
- contain exactly one EDN map;
- use string keys of the form
host:port; - use string values containing the URL template;
- have unique keys.
In EDN, ; starts a comment and commas are optional whitespace.
LIBLIN_DOMAINS is required; if it is unset, liblin cannot read any RDL
entries.
How a request is matched
For an incoming URL, liblin derives the lookup key as follows:
- Remove the scheme (
http://orhttps://). - Remove the path, query string, and fragment.
- Remove a leading lowercase
www.. - Keep the port.
The resulting key is looked up with an exact, case-sensitive string match.
| Incoming URL | Lookup key |
|---|---|
http://example.com:80/alice | example.com:80 |
http://www.example.com:80/alice | example.com:80 |
https://sub.example.com:443/alice | sub.example.com:443 |
http://example.com:3000/alice | example.com:3000 |
Rules to keep in mind:
- The port is always part of the key. The request handler builds the URL
with the server port, so a default HTTP request looks like
http://example.com:80/...; useexample.com:80, notexample.com. HTTPS requests similarly use:443. - Do not put
www.in keys. It is stripped before lookup, so a key such aswww.example.com:80can never match. - Matching is exact. There are no wildcards and no fallback entry. Each subdomain needs its own key.
- The scheme is not part of the key. HTTP and HTTPS are distinguished by
their ports, not by an
http/httpsprefix. - Use the port the application sees. Behind a reverse proxy this may be an internal port unless forwarded headers are configured accordingly.
Path segments
The request path is split on /. The leading empty segment and a trailing
slash are discarded, and the result is stored in paths:
| Request path | paths |
|---|---|
/alice | ["alice"] |
/alice/software/libre | ["alice" "software" "libre"] |
/alice/ | ["alice"] |
/ | [] |
Query strings and fragments are removed before splitting:
/alice?ref=mail#top gives paths equal to ["alice"]. Repeated slashes
produce empty segments in paths; the join filter skips them.
paths is a vector. Segments are zero-based and can be read with bracket
indexing or collection lookups:
| Expression | Result for ["alice" "software" "libre"] |
|---|---|
{{ paths[0] }} | alice |
{{ paths[1] }} | software |
{{ paths.first }} | alice |
{{ paths.last }} | libre |
{{ paths.size }} | 3 |
A segment that does not exist renders as an empty string. Combine it with
index-if-empty when a fallback is needed.
In liblin, / is served by the landing page and /about is a fixed route, so
neither reaches RDL. The empty paths case is still useful when calling the
redirect functions directly or in tests.
Template variables
| Variable | Description | Example |
|---|---|---|
domain | The matched key: host and port | example.com:80 |
paths | The vector of path segments | ["alice" "software" "libre"] |
Filters
Templates use Liquid syntax: {{ ... }} outputs a value and | applies a
filter. liblin provides four filters:
| Filter | Behavior |
|---|---|
index-if-empty | Returns "index" when the input is nil, "", or an empty collection; otherwise returns the input unchanged. |
first | Returns the first element of a collection. |
rest | Returns all elements after the first. Pipe it into join before outputting it. |
join | Joins collection elements with /, skipping empty elements. |
With paths equal to ["alice" "software" "libre"]:
{{ paths | first }}producesalice{{ paths | rest | join }}producessoftware/libre{{ paths | join }}producesalice/software/libre{{ paths[3] | index-if-empty }}producesindex
index-if-empty is most often used to fall back to index.txt when a segment
is missing:
"https://example.com/{{ paths[0] }}/repo/raw/branch/main/{{ paths[1] | index-if-empty }}.txt"
A request for /alice renders .../main/index.txt, while /alice/0 renders
.../main/0.txt.
Notes:
joinis liblin’s own filter, not Liquid’sjoin. It always uses/and takes no separator argument;{{ paths | join: "," }}is an error.first,rest, andjoinoperate on collections.restreturns a sequence, so do not output it directly:{{ paths | rest }}renders a Clojure-style list.- Other Liquid filters from the underlying engine (for example
append,upcase,default, andsize) remain available. liblin’s filters take precedence when names collide.
Rendering and redirecting
Rendering a template produces the location of a text file, not the final redirect target. The full flow is:
- Derive the lookup key from the request.
- Find the matching template.
- Render it with
domainandpaths. - Read the rendered location with
slurp. It can be an HTTP(S) URL or a local file path. - Take the first line, trim it, and use it as the redirect target.
- Send
302 Foundwith that target in theLocationheader.
Only the first line is used; later lines are ignored, so a target file can contain notes below the URL.
For example, if the rendered location is
https://example.com/alice/repo/raw/branch/main/software/libre.txt and that
file contains:
https://example.com/docs/libre
the client receives:
HTTP/1.1 302 Found
Location: https://example.com/docs/libre
Both the RDL file and the rendered target are read on every request; nothing is cached.
Failure behavior:
- No entry for the key:
404. - The rendered location cannot be read (missing file, network error, invalid
URL):
404. - The target file is empty: the redirect target is empty; keep at least one non-empty line in every target file.
- Malformed EDN, duplicate keys, or an invalid template: an exception
(normally HTTP
500), not a404.
Complete example
{
;; Username first; the remaining segments form the document path.
"example.com:80"
"https://example.com/{{ paths[0] }}/repo/raw/branch/main/{{ paths | rest | join | index-if-empty }}.txt"
;; Every path segment is part of the document path.
"sub.example.com:80"
"https://example.com/repo/raw/branch/main/{{ paths | join | index-if-empty }}.txt"
;; One flat document per first segment.
"api.example.com:80"
"https://example.com/raw/branch/main/{{ paths[0] | index-if-empty }}.txt"
;; Only the first two segments are used; extra segments are ignored.
"example.com:3000"
"https://example.com/{{ paths[0] }}/repo/raw/branch/main/{{ paths[1] | index-if-empty }}.txt"
;; Same request shape, different branch layout.
"alt.example.com:3000"
"https://example.com/{{ paths[0] }}/repo/refs/heads/main/{{ paths[1] | index-if-empty }}.txt"
;; One flat document per first segment, on a subdomain.
"alt.sub.example.com:3000"
"https://example.com/repo/raw/branch/main/{{ paths[0] | index-if-empty }}.txt"
;; `domain` renders as host:port, so it can be reused in the target.
"meta.example.com:80"
"http://{{ domain }}/repo/raw/branch/main/{{ paths | join | index-if-empty }}.txt"
}
The entries above produce these results:
| Request | paths | Rendered location |
|---|---|---|
http://example.com:80/alice | ["alice"] | https://example.com/alice/repo/raw/branch/main/index.txt |
http://example.com:80/alice/software/libre | ["alice" "software" "libre"] | https://example.com/alice/repo/raw/branch/main/software/libre.txt |
http://sub.example.com:80/software/libre | ["software" "libre"] | https://example.com/repo/raw/branch/main/software/libre.txt |
http://api.example.com:80/fs/ | ["fs"] | https://example.com/raw/branch/main/fs.txt |
http://example.com:3000/alice | ["alice"] | https://example.com/alice/repo/raw/branch/main/index.txt |
http://example.com:3000/alice/0 | ["alice" "0"] | https://example.com/alice/repo/raw/branch/main/0.txt |
http://alt.example.com:3000/alice/0 | ["alice" "0"] | https://example.com/alice/repo/refs/heads/main/0.txt |
http://alt.sub.example.com:3000/fs | ["fs"] | https://example.com/repo/raw/branch/main/fs.txt |
http://meta.example.com:80/a/b | ["a" "b"] | http://meta.example.com:80/repo/raw/branch/main/a/b.txt |
http://www.example.com:80/alice | ["alice"] | https://example.com/alice/repo/raw/branch/main/index.txt |
http://unknown.example.com:80/alice | ["alice"] | No entry: 404 |
Troubleshooting
- A valid-looking host returns
404. Check the port first::80,:443, and:3000are different keys. Also check that the key does not start withwww.and that the exact subdomain has an entry. - The wrong file is selected.
paths[0]is the first segment after the leading slash; the slash itself is not part ofpaths. Trailing slashes and query strings do not create segments. - Subfolders are dropped. A template that only uses
paths[0]orpaths[1]ignores later segments. Use{{ paths | rest | join }}or{{ paths | join }}to include them. - A missing segment becomes
indexwhen it should be empty. That is whatindex-if-emptydoes; remove the filter if an empty value is wanted. - Changes do not appear. Confirm
LIBLIN_DOMAINSpoints at the file the server is reading. There is no cache, so the next request should use the new value. - The server returns
500. Check for invalid EDN, duplicate keys, a non-string template, or an invalid Liquid expression such as{{ paths | join: "," }}. - Only the first line of the target file matters. A blank first line produces an empty redirect target.
RDL is trusted configuration: templates can read local files as well as remote URLs, so only trusted operators should be able to edit the RDL file.
See also
- Hosting - deploying liblin and setting
LIBLIN_DOMAINS. - Getting Started - using liblin as a user.