Sayfa
A simple, extensible static site generator built in Elixir. Sayfa means "page" in Turkish.
Turkce README / Turkish README
Table of Contents
- 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
What is Sayfa?
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 │
└──────────────────────────────────────────────────────┘Design Philosophy
- 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.
Features
Core
- Markdown with syntax highlighting (MDEx, Rust NIF)
-
YAML front matter with typed fields +
metacatch-all -
Two-struct content pipeline (
Raw->Content) for maximum flexibility
Content Organization
- 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)
Templates & Theming
- 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)
Internationalization
- 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
SEO & Feeds
- Atom feed generation
- Sitemap XML
- SEO meta tags (Open Graph, description)
Developer Experience
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
Requirements
| 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.
Quick Start
# 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.serve
Your site will be generated in the dist/ directory. The dev server runs at http://localhost:4000 with hot reload.
Content Types
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.
Filename Convention
# 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/Custom Content Types
Scaffold a new content type with:
mix sayfa.gen.content_type Recipe # → lib/content_types/recipe.ex
Or 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]
endFront Matter
Content 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 Reference
| 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"].
Layouts & Templates
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
Selecting a Layout
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)
Default Layouts
| 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 |
Template Variables
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
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) %>Built-in Blocks
| 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) |
Custom Blocks
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!") %>Themes
Default Theme
Sayfa ships with a minimal, documentation-style default theme. It includes all 5 layouts and basic CSS.
Custom Themes
Create a theme directory in your project:
themes/
my_theme/
layouts/
article.html.eex # Override specific layouts
assets/
css/
custom.cssSet it in config:
config :sayfa, :site,
theme: "my_theme"Theme Inheritance
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"Multilingual Support
Sayfa uses a directory-based approach for multilingual content:
content/
articles/
hello-world.md # English (default)
tr/
articles/
merhaba-dunya.md # TurkishConfiguration
config :sayfa, :site,
default_lang: :en,
languages: [
en: [name: "English"],
tr: [name: "Türkçe"]
]URL Patterns
English (default): /articles/hello-world/
Turkish: /tr/articles/merhaba-dunya/Linking Translations
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,trTranslation Function
Templates 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
Per-Language Config Overrides
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"]
]RTL Support
Sayfa automatically sets dir="rtl" on the <html> tag for right-to-left languages: Arabic (ar), Hebrew (he), Farsi (fa), and Urdu (ur).
Feeds & SEO
Atom Feeds
Sayfa generates Atom XML feeds automatically:
/feed.xml # All content
/feed/articles.xml # Articles only
/feed/notes.xml # Notes onlySitemap
A sitemap.xml is generated at the root of the dist/ directory containing all published pages.
SEO Meta Tags
Templates automatically include Open Graph and description meta tags based on front matter fields.
Configuration
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: falseConfiguration Reference
| 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) |
CLI Commands
mix sayfa.new
Generate a new Sayfa site:
mix sayfa.new my_blog
mix sayfa.new my_blog --theme minimal --lang en,trmix sayfa.build
Build 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 directorymix sayfa.gen.content
Generate 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 types
Options: --date, --tags, --categories, --draft, --lang, --slug.
mix sayfa.gen.block
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.ex
Generates a module implementing Sayfa.Behaviours.Block and prints the registration snippet for config/config.exs.
mix sayfa.gen.content_type
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.ex
Generates a module implementing Sayfa.Behaviours.ContentType and prints registration and mkdir instructions.
mix sayfa.serve
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.
Project Structure
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.exsDeployment
mix sayfa.new generates a nixpacks.toml and a GitHub Actions workflow so you can deploy immediately.
GitHub Pages
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.
Nixpacks (Railway / Coolify)
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.
VPS (rsync)
Build locally and sync to your server:
mix sayfa.build
rsync -avz --delete dist/ user@server:/var/www/my-site/Extensibility
Sayfa is designed to be extended via three behaviours:
Blocks
Reusable template components. See the Blocks section.
Hooks
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 |
Content Types
Define how content is organized. See Custom Content Types.
Roadmap
Future plans for Sayfa:
- Search functionality (client-side search with indexing)
- Plugin system for third-party extensions
Contributing
Contributions are welcome! See CONTRIBUTING.md for guidelines.
git clone https://github.com/furkanural/sayfa.git
cd sayfa
mix deps.get
mix testLicense
MIT License. See LICENSE for details.