A fast, easy blog site builder — write Markdown, preview live, deploy to GitHub Pages.
tles turns a folder of Markdown files into a complete static blog. It ships a development
server with hot reload, a polished theme with dark mode and LLM translations, and a scaffolded
GitHub Actions workflow.
- Tera templates — tera 2 layouts with built-in helpers for TOC, pagination, Open Graph, tag/category lists and more.
- Themes — Design your theme.
- Taxonomies — tag and category pages (plus index pages) are generated automatically.
- Translations —
tles site -ttranslates every post with an LLM into the languages listed in[i18n] target_lang. - Feeds & SEO —
atom.xml,sitemap.xmlandrobots.txtare built in. - Rhai helper scripts — extend your templates with sandboxed scripts dropped into
helper/. - Deploy-ready scaffold —
tles site -iwrites the config, theme,.gitignoreand the GitHub Pages workflow for using.
Requires Rust 1.85+ (edition 2024).
# install
cargo install --git https://github.com/iskyrainy/tless
# scaffold a new site
mkdir my-blog && cd my-blog
tles site -i
# write your first post
tles post add hello
tles post publish hello
# preview at http://127.0.0.1:8917
tles server -r
# build the static site into public/
tles site -gtles site -i produces a self-contained, deploy-ready repository:
.github/workflows/deploy.yml # GitHub Pages deployment
.gitignore
tless.toml # site configuration
helper/ # Rhai helper scripts
plugin/
public/ # build output (gitignored)
source/
draft/ # work-in-progress posts (never built)
post/ # published posts
page/ # standalone pages
i18n/<lang>/ # translations of posts, written by `tles site -t`
robots.txt
theme/base/
layout/ # base.html, index.html, post.html, page.html, tag*.html, category*.html
assets/ # style.css, highlight.css, highlight.js, giscus*.css
Names are slugified (First Post -> first-post.md), and every source file is rendered to a
pretty URL: source/post/hello.md -> /post/hello/.
[site]
title = "My Tless Site"
subtitle = "Notes on what I build, and how."
description = "A fast blog powered by Tless."
rights = "My Tless Site"
author = "Your Name"
url = "http://127.0.0.1:8917"
zone = "Asia/Shanghai"
theme = "base"
favicon = ""
menu = [
{ name = "Home", link = "/index.html" },
{ name = "Tags", link = "/tag" },
{ name = "Categories", link = "/category" }
]
[i18n] # optional; see "Translations" below
provider = "deepseek" # deepseek | kimi | glm
api_key = "..."
model = "deepseek-flash"
target_lang = ["zh-CN", "en"]
[giscus] # optional; see "Comments" below
repo = "owner/name"
repo_id = "R_kgDO…"
category = "Announcements"
category_id = "DIC_kwDO…"
mapping = "pathname"
lang = "en"tless.toml is read at startup and on every rebuild — restart the dev server after editing it.
Layouts live in theme/<theme>/layout/ and are plain Tera templates. The included base theme
extends base.html from each page:
| Layout | Used for |
|---|---|
base.html |
Shared shell: head, header (nav, language switcher, RSS, dark-mode toggle), footer, back-to-top. |
index.html |
Home feed: the newest post full width, then two per row. |
post.html |
Single post; the default for files in source/post/. |
page.html |
Single page; the default for files in source/page/. |
tag.html / category.html |
One page per term with that term's posts. |
tag-index.html / category-index.html |
Overview of all terms. |
A source file can override its layout through frontmatter (layout: post.html).
Every layout gets site, the whole site model. Templates also receive:
| Variable | On | Value |
|---|---|---|
title |
posts, pages, terms | Post/page title; falls back to the source file name. |
date |
posts, pages | Frontmatter date, verbatim — feed it to date(ts=…). |
content |
posts, pages | Rendered article HTML. |
markdown |
posts, pages | Raw Markdown body (what toc() wants). |
tag / category |
posts | Lists of terms, or null. |
prev_post / next_post |
posts | The post published earlier / later, or null. |
translations |
posts | Every published version of this post; see Translations. Empty elsewhere. |
name / posts |
term pages | The term and its posts. |
recent_posts |
home | All posts, newest first. |
prev_post and next_post are { title, date, path, … }; render a link with
/post/{{ slugify(str=prev_post.path, is_path=true) }}/.
Built-in helper functions:
| Group | Functions |
|---|---|
| Text & URLs | date(ts, fmt), url_for(path, relative), full_url_for(path), gravatar(mail), number_format(value, separator), slugify(str, is_path) |
| HTML tags | css, js, link, image, mail, favicon, feed, meta — each takes a path string or an attribute map |
| Layout | partial(name), paginator(current, total, window, base, prev_text, next_text), open_graph(title, description, image, url, type), toc(content, min_level, max_level) |
| Taxonomies | list_category, list_tag, list_post, list_page with orderby, order, amount, list, separator, show_count, tag_class |
<article class="post-content">{{ content | safe }}</article>
{{ toc(content=markdown, min_level=2, max_level=3) | safe }}
{{ list_tag(orderby="count", order=-1, amount=10) | safe }}
{{ css(path=["/assets/style.css", "/assets/highlight.css"]) }}
{{ feed(path="/atom.xml", type="application/atom+xml", title=site.config.title) }}Pass a map instead of a string to set any attribute — keys are written in sorted order, so the output is reproducible:
{{ link(path={"href": "/tag/" ~ t, "class": "rail-tag"}, text=t) }}
{{ js(path={"src": "/assets/highlight.js", "defer": ""}) }}slugify(str, is_path=true) slugifies the file name of a path, which is what post URLs are
built from. date(ts=…) accepts an epoch number, RFC 3339 or the %Y-%m-%d %H:%M:%S the CLI
writes.
Build every link with url_for(path, relative=false). It returns a site-root relative path
carrying the sub-path the site is served from, so the same theme works whether [site] url
points at a domain root or at a sub-directory:
[site] url |
url_for(path="/assets/style.css", relative=false) |
|---|---|
https://example.com |
/assets/style.css |
https://example.com/blog |
/blog/assets/style.css |
Absolute URLs, //host/…, mailto: and #fragment are returned untouched, so a menu entry
pointing at another site keeps working.
Use full_url_for(path) when the URL has to be absolute — feeds and sitemaps, and the giscus
theme files, which a third-party iframe fetches.
url_for without relative=false returns ./path, which only resolves on pages at the site
root; the base theme never uses it.
Headings get anchor ids while rendering, so the generated table of contents links jump to the right spot.
A simple example for nginx proxy:
location /blog/ {
add_header Content-Security-Policy upgrade-insecure-requests;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Prefix /blog;
proxy_pass http://127.0.0.1:8917/;
}Drop a .rhai script into helper/ and it becomes a template function named after the file.
Each script must define fn main(args) (call is a reserved keyword in Rhai):
// helper/greeting.rhai → {{ greeting(name="World") }}
fn main(args) {
"Hello, " + args["name"] + "!"
}Scripts run sandboxed: no loops, no eval/import, with operation, depth and call-level limits.
Adding or editing a script reloads the dev server automatically.
tles site -t reads every post in source/post/, asks the configured LLM to translate it, and
writes the result to source/i18n/<lang>/<name>.md — one file per language in
target_lang. A post is only re-translated when its Markdown changed, tracked by an md5 of the
source in source/post/.post_hash.json.
[i18n]
provider = "deepseek" # deepseek | kimi | glm
api_key = "sk-…"
model = "deepseek-flash"
target_lang = ["zh-CN", "en"] # BCP 47 codesImportant
Supported target_lang code info see more in google language code.
The build (tles site -g) picks the translations up and publishes them next to the original:
source/post/hello.md → public/post/hello/index.html
source/i18n/zh-CN/hello.md → public/post/zh-CN/hello/index.html
source/i18n/en/hello.md → public/post/en/hello/index.html
Post pages get a translations variable listing every version that exists, which is what the
base theme's language switcher is built from:
[ { "lang": "", "url": "/post/hello/", "current": true },
{ "lang": "zh-CN", "url": "/post/zh-CN/hello/", "current": false } ]lang is empty for the original: originals have no fixed language, so nothing labels them but
their own path. current marks the page being rendered, so a theme can highlight it.
Code blocks, inline code and frontmatter keys pass through untouched; only title,
description, summary and excerpt are translated. Translation prompt see SYSTEM_PROMPT.
Post pages carry a giscus thread when [giscus] repo is set; leave it
empty and the section disappears. Enable Discussions on the repository, install the giscus app,
then copy repo_id, category and category_id from the configuration box on giscus.app.
[giscus]
repo = "owner/name"
repo_id = "R_kgDO…"
category = "Announcements"
category_id = "DIC_kwDO…"
mapping = "pathname" # how a page maps to a discussion
lang = "en" # language of the giscus interfaceassets/giscus.css for light, assets/giscus-dark.css for dark. The domain https://giscus.app has
been allowed by tles.
The bundled base theme follows the layout of the Cloudflare blog.
Code blocks use the Tokyo Night palette — Tokyo
Night Day in light mode, Tokyo Night in dark mode. The token colours are --hl-* custom
properties at the top of assets/highlight.css, so retheming highlighting means editing that
one block; the block background still comes from --code-bg in style.css.
The scaffolded workflow builds the site and publishes public/ to GitHub Pages on every push
to main. One-time setup in your repository:
Important
Set Settings → Pages → Source to GitHub Actions, and update url in tless.toml to
your final site URL so feeds, sitemap and absolute links are correct.
The workflow installs tles from this repository — point it at your fork if you maintain one.
cargo build --release # build
cargo test # unit tests
cargo clippy # lints
cargo fmt # formatting