A simple, extensible static site generator built in Elixir. Sayfa means "page" in Turkish.
Turkce README / Turkish README
- What is Sayfa?
- Features
- Requirements
- Quick Start
- Content Types
- Front Matter
- Layouts & Templates
- Blocks
- Themes
- Multilingual Support
- Feeds & SEO
- Configuration
- CLI Commands
- Project Structure
- Deployment
- Extensibility
- Roadmap
- Contributing
- License
Sayfa follows a two-layer architecture:
- Sayfa (this package) — A reusable Hex package with the core static site generation engine: markdown parsing, template rendering, feed generation, block system, and more.
- Your site — A project that depends on Sayfa via
{:sayfa, "~> 0.1"}. You bring your content, theme, and configuration; Sayfa handles the build.
┌──────────────────────────────────────────────────────┐
│ YOUR WEBSITE │
│ content/ themes/ lib/blocks/ config/ │
└──────────────────────────┬───────────────────────────┘
│ {:sayfa, "~> 0.1"}
▼
┌──────────────────────────────────────────────────────┐
│ SAYFA (Hex Package) │
│ Builder, Content, Markdown, Feed, Sitemap, Blocks │
└──────────────────────────────────────────────────────┘
- Simple — Convention over configuration. Sensible defaults, minimal boilerplate.
- Extensible — Blocks, hooks, content types, and themes are all pluggable via behaviours.
- Fast — Markdown parsing powered by MDEx (Rust NIF). Incremental builds with caching.
- No Node.js — TailwindCSS is auto-downloaded via the
tailwindhex package. Pure Elixir + Rust.
- Markdown with syntax highlighting (MDEx, Rust NIF)
- YAML front matter with typed fields +
metacatch-all - Two-struct content pipeline (
Raw->Content) for maximum flexibility
- 5 built-in content types (articles, notes, projects, talks, pages)
- Categories and tags with auto-generated archive pages
- Pagination with configurable page size
- Collections API (filter, sort, group, recent)
- Three-layer template composition (content -> layout -> base)
- 16 built-in blocks (header, footer, social links, TOC, recent articles, tag cloud, category cloud, reading time, code copy, copy link, breadcrumb, recent content, language switcher, related articles, related content, analytics) with 24 platform icons including GitHub, X/Twitter, Mastodon, LinkedIn, Bluesky, YouTube, Instagram, and more
- Theme inheritance (custom -> parent -> default)
- EEx templates with
@blockhelper - Configurable syntax highlighting theme (
highlight_theme) - View Transitions API support (
view_transitions: true) - Print-friendly styles built in (
@media print)
- Directory-based multilingual support
- Per-language URL prefixes (
/tr/articles/...) - 14 pre-built UI translations (en, tr, de, es, fr, it, pt, ja, ko, zh, ar, ru, nl, pl)
- Language switcher block with auto-detection of available translations
- RTL language support (Arabic, Hebrew, Farsi, Urdu)
- Auto-linked translations between content files
- Translation function
@t.("key")in templates
- Atom feed generation
- Sitemap XML
- SEO meta tags (Open Graph, description)
mix sayfa.newproject generator- Dev server with file watching and hot reload
- Draft preview mode
- Build caching for incremental rebuilds
- Verbose logging with per-stage timing
| Requirement | Version | Notes |
|---|---|---|
| Elixir | 1.19.5+ | OTP 27+ |
| Rust | Latest stable | Required for MDEx NIF compilation |
Rust is a hard requirement — MDEx compiles a native extension for fast markdown parsing.
# Install Sayfa's archive (for mix sayfa.new)
mix archive.install hex sayfa
# Create a new site
mix sayfa.new my_blog
cd my_blog
mix deps.get
# Build the site
mix sayfa.build
# Or start the dev server
mix sayfa.serveYour site will be generated in the dist/ directory. The dev server runs at http://localhost:4000 with hot reload.
Sayfa ships with 5 built-in content types. Each maps to a directory under content/ and a URL prefix:
| Type | Directory | URL Pattern | Default Layout |
|---|---|---|---|
| Article | content/articles/ |
/articles/{slug}/ |
article |
| Note | content/notes/ |
/notes/{slug}/ |
article |
| Project | content/projects/ |
/projects/{slug}/ |
page |
| Talk | content/talks/ |
/talks/{slug}/ |
page |
| Page | content/pages/ |
/{slug}/ |
page |
No dates in URLs — keeps them clean and evergreen.
# Dated content (articles, notes)
2024-01-15-my-article-title.md → /articles/my-article-title/
# Undated content (projects, pages)
my-project.md → /projects/my-project/
about.md → /about/
Scaffold a new content type with:
mix sayfa.gen.content_type Recipe # → lib/content_types/recipe.exOr implement the Sayfa.Behaviours.ContentType behaviour manually:
defmodule MyApp.ContentTypes.Recipe do
@behaviour Sayfa.Behaviours.ContentType
@impl true
def name, do: :recipe
@impl true
def directory, do: "recipes"
@impl true
def url_prefix, do: "recipes"
@impl true
def default_layout, do: "page"
@impl true
def required_fields, do: [:title]
endContent files use YAML front matter delimited by ---:
---
title: "Building a Static Site Generator" # Required
date: 2024-01-15 # Required for articles/notes
slug: custom-slug # Optional (default: from filename)
lang: en # Optional (default: site default)
description: "A brief description" # Optional, used for SEO
categories: [elixir, tutorial] # Optional
tags: [static-site, beginner] # Optional
draft: false # Optional (default: false)
layout: custom_layout # Optional (default: content type's default)
---
Your markdown content here.| Field | Type | Default | Description |
|---|---|---|---|
title |
String | required | Page title |
date |
Date | nil |
Publication date (YYYY-MM-DD) |
slug |
String | from filename | URL slug |
lang |
Atom | site default | Content language |
description |
String | "" |
SEO description |
categories |
List | [] |
Category names |
tags |
List | [] |
Tag names |
draft |
Boolean | false |
Exclude from production builds |
layout |
String | type default | Layout template name |
Any unrecognized fields are stored in the meta map and accessible in templates via @content.meta["field_name"].
Sayfa uses a three-layer composition model:
- Content body — Markdown rendered to HTML
- Layout template — Wraps the content, places blocks (e.g.,
article.html.eex) - Base template — HTML shell (
<html>,<head>, etc.), inserts@inner_content
A page selects its layout via front matter:
---
title: "Welcome"
layout: home
---Resolution order:
layoutfield in front matter- Content type's
default_layout page(fallback)
| Layout | Used For | Typical Blocks |
|---|---|---|
home.html.eex |
Homepage | recent_content |
article.html.eex |
Single article | reading_time, toc, social_links |
note.html.eex |
Single note | reading_time, copy_link |
page.html.eex |
Static pages | content only |
list.html.eex |
Content listings | pagination |
base.html.eex |
HTML wrapper | header, footer |
All templates receive these assigns:
| Variable | Type | Description |
|---|---|---|
@content |
Sayfa.Content.t() |
Current content (nil on list pages) |
@contents |
[Sayfa.Content.t()] |
All site contents |
@site |
map() |
Resolved site configuration |
@block |
function |
Block rendering helper |
@t |
function |
Translation function (@t.("key")) |
@lang |
atom() |
Current content language |
@dir |
String.t() |
Text direction ("ltr" or "rtl") |
@inner_content |
String.t() |
Rendered inner HTML (base layout only) |
Blocks are reusable EEx components invoked via the @block helper:
<%= @block.(:recent_articles, limit: 5) %>
<%= @block.(:tag_cloud) %>
<%= @block.(:language_switcher, variant: :desktop) %>
<%= @block.(:breadcrumb) %>| Block | Atom | Description |
|---|---|---|
| Header | :header |
Site header with navigation; renders a logo image when logo: is set in config |
| Footer | :footer |
Site footer |
| Social Links | :social_links |
Social media link icons |
| Table of Contents | :toc |
Auto-generated TOC from headings |
| Recent Articles | :recent_articles |
List of recent articles |
| Tag Cloud | :tag_cloud |
Tag cloud with counts |
| Category Cloud | :category_cloud |
Category cloud with counts |
| Reading Time | :reading_time |
Estimated reading time |
| Code Copy | :code_copy |
Copy button for code blocks |
| Copy Link | :copy_link |
Copy page URL to clipboard |
| Breadcrumb | :breadcrumb |
Back link to section with JSON-LD BreadcrumbList structured data for SEO |
| Recent Content | :recent_content |
Recent items from any content type |
| Language Switcher | :language_switcher |
Switch between content translations; supports variant: assign (:desktop, :mobile) for multiple instances on the same page |
| Related Articles | :related_articles |
Articles related by tags/categories |
| Related Content | :related_content |
Content related by tags/categories (auto-detects type; accepts type: assign) |
Scaffold a new block with:
mix sayfa.gen.block MyBanner # → lib/blocks/my_banner.ex
mix sayfa.gen.block MyApp.Blocks.Banner # → lib/blocks/banner.ex (last segment used)Or implement the Sayfa.Behaviours.Block behaviour manually:
defmodule MyApp.Blocks.Banner do
@behaviour Sayfa.Behaviours.Block
@impl true
def name, do: :banner
@impl true
def render(assigns) do
text = Map.get(assigns, :text, "Welcome!")
~s(<div class="banner">#{text}</div>)
end
endRegister custom blocks in your site config:
config :sayfa, :blocks, [MyApp.Blocks.Banner | Sayfa.Block.default_blocks()]Then use it in templates:
<%= @block.(:banner, text: "Hello from my custom block!") %>Sayfa ships with a minimal, documentation-style default theme. It includes all 5 layouts and basic CSS.
Create a theme directory in your project:
themes/
my_theme/
layouts/
article.html.eex # Override specific layouts
assets/
css/
custom.css
Set it in config:
config :sayfa, :site,
theme: "my_theme"Custom themes inherit from a parent. Any layout not overridden falls back to the parent theme:
config :sayfa, :site,
theme: "my_theme",
theme_parent: "default"Sayfa uses a directory-based approach for multilingual content:
content/
articles/
hello-world.md # English (default)
tr/
articles/
merhaba-dunya.md # Turkish
config :sayfa, :site,
default_lang: :en,
languages: [
en: [name: "English"],
tr: [name: "Türkçe"]
]English (default): /articles/hello-world/
Turkish: /tr/articles/merhaba-dunya/
Use the translations front matter key to link content across languages. The builder also auto-links translations by matching slugs across language directories.
---
title: "Hello World"
lang: en
translations:
tr: merhaba-dunya
---Generate pre-linked multilingual content in one command:
mix sayfa.gen.content article "Hello World" --lang=en,trTemplates receive a @t function for translating UI strings:
<%= @t.("recent_articles") %> <%# "Recent Articles" in English, "Son Makaleler" in Turkish %>
<%= @t.("min_read") %> <%# "min read" / "dk okuma" %>Sayfa ships with 14 built-in translation files covering common UI strings:
en, tr, de, es, fr, it, pt, ja, ko, zh, ar, ru, nl, pl
Translation lookup chain:
- Per-language overrides in config (
languages: [tr: [translations: %{"key" => "value"}]]) - YAML file for the content language (
priv/translations/{lang}.yml) - YAML file for the default language (fallback)
- The key itself
Override any site config per language:
config :sayfa, :site,
title: "My Blog",
default_lang: :en,
languages: [
en: [name: "English"],
tr: [name: "Türkçe", title: "Blogum", description: "Kişisel blogum"]
]Sayfa automatically sets dir="rtl" on the <html> tag for right-to-left languages: Arabic (ar), Hebrew (he), Farsi (fa), and Urdu (ur).
Sayfa generates Atom XML feeds automatically:
/feed.xml # All content
/feed/articles.xml # Articles only
/feed/notes.xml # Notes only
A sitemap.xml is generated at the root of the dist/ directory containing all published pages.
Templates automatically include Open Graph and description meta tags based on front matter fields.
Site configuration lives in config/config.exs:
import Config
config :sayfa, :site,
# Basic
title: "My Site",
description: "A site built with Sayfa",
author: "Your Name",
base_url: "https://example.com",
# Content
content_dir: "content",
output_dir: "dist",
articles_per_page: 10,
drafts: false,
# Language
default_lang: :en,
languages: [en: [name: "English"]],
# Theme
theme: "default",
theme_parent: "default",
# Logo (optional — replaces the text title in the header)
# logo: "/images/logo.svg",
# logo_dark: "/images/logo-dark.svg", # shown in dark mode instead of logo
# Syntax highlighting theme for code blocks (uses MDEx/syntect themes)
# highlight_theme: "github_light",
# View Transitions API for smooth page navigation
# view_transitions: false,
# Dev server
port: 4000,
verbose: false| Key | Type | Default | Description |
|---|---|---|---|
title |
String | "My Site" |
Site title |
description |
String | "" |
Site description |
author |
String | nil |
Site author |
base_url |
String | "http://localhost:4000" |
Production URL |
content_dir |
String | "content" |
Content source directory |
output_dir |
String | "dist" |
Build output directory |
articles_per_page |
Integer | 10 |
Pagination size |
drafts |
Boolean | false |
Include drafts in build |
default_lang |
Atom | :en |
Default content language |
languages |
Keyword | [en: [name: "English"]] |
Available languages |
theme |
String | "default" |
Active theme name |
theme_parent |
String | "default" |
Parent theme for inheritance |
static_dir |
String | "static" |
Directory for static assets |
tailwind_version |
String | "4.1.12" |
TailwindCSS version to use |
logo |
String | nil |
Path to logo image (replaces text title in header) |
logo_dark |
String | nil |
Path to dark-mode logo (shown instead of logo in dark mode) |
social_links |
Map | %{} |
Social media links (github, twitter, etc.) |
highlight_theme |
String | "github_light" |
Syntax highlighting theme for code blocks |
view_transitions |
Boolean | false |
Enable View Transitions API for smooth page navigation |
port |
Integer | 4000 |
Dev server port |
verbose |
Boolean | false |
Verbose build logging |
fingerprint |
Boolean | true |
Enable asset fingerprinting (automatically false in dev server) |
Generate a new Sayfa site:
mix sayfa.new my_blog
mix sayfa.new my_blog --theme minimal --lang en,trBuild the site:
mix sayfa.build
mix sayfa.build --drafts # Include draft content
mix sayfa.build --verbose # Detailed logging
mix sayfa.build --output _site # Custom output directory
mix sayfa.build --source ./my_site # Custom source directoryGenerate a new content file:
mix sayfa.gen.content article "My First Article"
mix sayfa.gen.content note "Quick Tip" --tags=elixir,tips
mix sayfa.gen.content article "Hello World" --lang=en,tr # Multilingual
mix sayfa.gen.content --list # List content typesOptions: --date, --tags, --categories, --draft, --lang, --slug.
Scaffold a custom block module:
mix sayfa.gen.block MyBanner # → lib/blocks/my_banner.ex
mix sayfa.gen.block MyApp.Blocks.Banner # → lib/blocks/banner.exGenerates a module implementing Sayfa.Behaviours.Block and prints the registration snippet for config/config.exs.
Scaffold a custom content type module:
mix sayfa.gen.content_type Recipe # → lib/content_types/recipe.ex
mix sayfa.gen.content_type MyApp.ContentTypes.Video # → lib/content_types/video.exGenerates a module implementing Sayfa.Behaviours.ContentType and prints registration and mkdir instructions.
Start the development server:
mix sayfa.serve
mix sayfa.serve --port 3000 # Custom port
mix sayfa.serve --drafts # Preview draftsThe dev server watches for file changes and rebuilds automatically.
A generated Sayfa site looks like this:
my_site/
├── config/
│ ├── config.exs
│ └── site.exs # Site configuration
│
├── content/
│ ├── articles/ # Articles
│ │ └── 2024-01-15-hello-world.md
│ ├── notes/ # Quick notes
│ ├── projects/ # Portfolio projects
│ ├── talks/ # Talks/presentations
│ ├── pages/ # Static pages
│ │ └── about.md
│ └── tr/ # Turkish translations
│ └── articles/
│
├── themes/
│ └── my_theme/ # Custom theme (optional)
│ └── layouts/
│
├── static/ # Copied as-is to dist/
│ ├── images/
│ └── favicon.ico
│
├── lib/ # Custom blocks, hooks, content types
│
├── dist/ # Generated site (git-ignored)
│
└── mix.exs
mix sayfa.new generates a nixpacks.toml and a GitHub Actions workflow so you can deploy immediately.
Your generated project includes .github/workflows/deploy.yml. Enable GitHub Pages in your repo settings (set Source to GitHub Actions), and every push to main will build and deploy your site automatically.
A nixpacks.toml is included that builds your site using Nixpacks. This works out of the box with platforms like Railway and Coolify.
- Railway: Connect your repo and Railway will detect
nixpacks.tomlautomatically. Set the publish directory todist/for static site serving. - Coolify: Select the Nixpacks build pack and point it at your repo.
Build locally and sync to your server:
mix sayfa.build
rsync -avz --delete dist/ user@server:/var/www/my-site/Sayfa is designed to be extended via three behaviours:
Reusable template components. See the Blocks section.
Inject custom logic into the build pipeline at 4 stages:
defmodule MyApp.Hooks.InjectAnalytics do
@behaviour Sayfa.Behaviours.Hook
@impl true
def stage, do: :after_render
@impl true
def run({content, html}, _opts) do
{:ok, {content, html <> "<script>/* analytics */</script>"}}
end
endRegister hooks in config:
config :sayfa, :hooks, [MyApp.Hooks.InjectAnalytics]Hook stages:
| Stage | Input | Description |
|---|---|---|
:before_parse |
Content.Raw |
Before markdown rendering |
:after_parse |
Content |
After parsing, before template |
:before_render |
Content |
Before template rendering |
:after_render |
{Content, html} |
After template rendering |
Define how content is organized. See Custom Content Types.
Future plans for Sayfa:
- Search functionality (client-side search with indexing)
- Plugin system for third-party extensions
Contributions are welcome! See CONTRIBUTING.md for guidelines.
git clone https://github.com/furkanural/sayfa.git
cd sayfa
mix deps.get
mix testMIT License. See LICENSE for details.