Hex Version License: MIT Elixir

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.5"}. You bring your content, theme, and configuration; Sayfa handles the build.

                  YOUR WEBSITE                        
   content/     themes/     lib/blocks/    config/    

                            {:sayfa, "~> 0.5"}
                           

                  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 tailwind hex package. Pure Elixir + Rust.

Features

Core

  • Markdown with syntax highlighting (MDEx, Rust NIF)
  • YAML front matter with typed fields + meta catch-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 @block helper
  • 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.new project generator
  • Dev server with file watching and hot reload
  • Draft preview mode
  • Build caching for incremental rebuilds
  • Verbose logging with per-stage timing

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
Recent Content:recent_contentRecent items from any content type
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)
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 (uses MDEx/syntect themes)
  # highlight_theme: "github_light",

  # 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.1.12"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"github_light"Syntax highlighting theme for code blocks
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.

  • Railway: Connect your repo and Railway will detect nixpacks.toml automatically. Set the publish directory to dist/ 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
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:

  • 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 test

License

MIT License. See LICENSE for details.