Sayfa

Hex VersionLicense: MITElixir

A simple, extensible static site generator built in Elixir. Sayfa means "page" in Turkish.

Turkce README / Turkish README


Table of Contents


What is Sayfa?

Sayfa follows a two-layer architecture:

  1. Sayfa (this package) — A reusable Hex package with the core static site generation engine: markdown parsing, template rendering, feed generation, block system, and more.
  2. Your site — A project that depends on Sayfa via {:sayfa, "~> 0.6"}. You bring your content, theme, and configuration; Sayfa handles the build.
┌──────────────────────────────────────────────────────┐
│ YOUR WEBSITE │
│ content/ themes/ lib/blocks/ config/ │
└──────────────────────────┬───────────────────────────┘
│ {:sayfa, "~> 0.6"}
┌──────────────────────────────────────────────────────┐
│ SAYFA (Hex Package) │
│ Builder, Content, Markdown, Feed, Sitemap, Blocks │
└──────────────────────────────────────────────────────┘

Design Philosophy


Features

Core

Content Organization

Templates & Theming

Internationalization

SEO & Feeds

Developer Experience


Requirements

RequirementVersionNotes
Elixir1.19.5+OTP 27+
RustLatest stableRequired 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:

TypeDirectoryURL PatternDefault Layout
Articlecontent/articles//articles/{slug}/article
Notecontent/notes//notes/{slug}/article
Projectcontent/projects//projects/{slug}/page
Talkcontent/talks//talks/{slug}/page
Pagecontent/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]
end

Front 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

FieldTypeDefaultDescription
titleStringrequiredPage title
dateDatenilPublication date (YYYY-MM-DD)
slugStringfrom filenameURL slug
langAtomsite defaultContent language
descriptionString""SEO description
categoriesList[]Category names
tagsList[]Tag names
draftBooleanfalseExclude from production builds
layoutStringtype defaultLayout 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:

  1. Content body — Markdown rendered to HTML
  2. Layout template — Wraps the content, places blocks (e.g., article.html.eex)
  3. 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:

  1. layout field in front matter
  2. Content type's default_layout
  3. page (fallback)

Default Layouts

LayoutUsed ForTypical Blocks
home.html.eexHomepagerecent_content
article.html.eexSingle articlereading_time, toc, social_links
note.html.eexSingle notereading_time, copy_link
page.html.eexStatic pagescontent only
list.html.eexContent listingspagination
base.html.eexHTML wrapperheader, footer

Template Variables

All templates receive these assigns:

VariableTypeDescription
@contentSayfa.Content.t()Current content (nil on list pages)
@contents[Sayfa.Content.t()]All site contents
@sitemap()Resolved site configuration
@blockfunctionBlock rendering helper
@tfunctionTranslation function (@t.("key"))
@langatom()Current content language
@dirString.t()Text direction ("ltr" or "rtl")
@inner_contentString.t()Rendered inner HTML (base layout only)

Blocks

Blocks are reusable EEx components invoked via the @block helper:

<%= @block.(:recent_content, limit: 5) %>
<%= @block.(:tag_cloud) %>
<%= @block.(:language_switcher, variant: :desktop) %>
<%= @block.(:breadcrumb) %>

Built-in Blocks

BlockAtomDescription
Header:headerSite header with navigation; renders a logo image when logo: is set in config
Footer:footerSite footer
Social Links:social_linksSocial media link icons
Table of Contents:tocAuto-generated TOC from headings
Recent Content:recent_contentRecent items from any content type
Tag Cloud:tag_cloudTag cloud with counts
Category Cloud:category_cloudCategory cloud with counts
Reading Time:reading_timeEstimated reading time
Code Copy:code_copyCopy button for code blocks
Copy Link:copy_linkCopy page URL to clipboard
Breadcrumb:breadcrumbBack link to section with JSON-LD BreadcrumbList structured data for SEO
Language Switcher:language_switcherSwitch between content translations; supports variant: assign (:desktop, :mobile) for multiple instances on the same page
Related Content:related_contentContent 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
end

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

Set 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 # Turkish

Configuration

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,tr

Translation 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:

  1. Per-language overrides in config (languages: [tr: [translations: %{"key" => "value"}]])
  2. YAML file for the content language (priv/translations/{lang}.yml)
  3. YAML file for the default language (fallback)
  4. 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 only

Sitemap

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 (single name or [light: ..., dark: ...] pair)
# highlight_theme: [light: "catppuccin_latte", dark: "catppuccin_mocha"],
# View Transitions API for smooth page navigation
# view_transitions: false,
# Dev server
port: 4000,
verbose: false

Configuration Reference

KeyTypeDefaultDescription
titleString"My Site"Site title
descriptionString""Site description
authorStringnilSite author
base_urlString"http://localhost:4000"Production URL
content_dirString"content"Content source directory
output_dirString"dist"Build output directory
articles_per_pageInteger10Pagination size
draftsBooleanfalseInclude drafts in build
default_langAtom:enDefault content language
languagesKeyword[en: [name: "English"]]Available languages
themeString"default"Active theme name
theme_parentString"default"Parent theme for inheritance
static_dirString"static"Directory for static assets
tailwind_versionString"4.3.0"TailwindCSS version to use
logoStringnilPath to logo image (replaces text title in header)
logo_darkStringnilPath to dark-mode logo (shown instead of logo in dark mode)
social_linksMap%{}Social media links (github, twitter, etc.)
highlight_themeString or keyword[light: "catppuccin_latte", dark: "catppuccin_mocha"]Syntax highlighting theme(s) for code blocks; accepts a single theme name or a [light: ..., dark: ...] pair
view_transitionsBooleanfalseEnable View Transitions API for smooth page navigation
portInteger4000Dev server port
verboseBooleanfalseVerbose build logging
fingerprintBooleantrueEnable 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,tr

mix 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 directory

mix 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 drafts

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

Deployment

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.

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
end

Register hooks in config:

config :sayfa, :hooks, [MyApp.Hooks.InjectAnalytics]

Hook stages:

StageInputDescription
:before_parseContent.RawBefore markdown rendering
:after_parseContentAfter parsing, before template
:before_renderContentBefore 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:


Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

git clone https://github.com/furkanural/sayfa.git
cd sayfa
mix deps.get
mix test

License

MIT License. See LICENSE for details.