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


Features

Core

Content Organization

Templates & Theming

Internationalization

SEO & Feeds

Developer Experience


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
Post content/posts//posts/{slug}/post
Note content/notes//notes/{slug}/post
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 (posts, notes)
2024-01-15-my-post-title.md  →  /posts/my-post-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 posts/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:

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

Layout Used For Typical Blocks
home.html.eex Homepage hero, recent_posts, tag_cloud
post.html.eex Single post 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
@contentSayfa.Content.t() Current content (nil on list pages)
@contents[Sayfa.Content.t()] All site contents
@sitemap() Resolved site configuration
@blockfunction Block rendering helper
@tfunction Translation 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.(:hero, title: "Welcome", subtitle: "My Elixir Blog") %>
<%= @block.(:recent_posts, limit: 5) %>
<%= @block.(:tag_cloud) %>

Built-in Blocks

Block Atom Description
Hero :hero Hero section with title and subtitle
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 Posts :recent_posts List of recent posts
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 Breadcrumb navigation
Recent Content :recent_content Recent items from any content type
Language Switcher :language_switcher Switch between content translations
Related Posts :related_posts Posts 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.Hero # → lib/blocks/hero.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/
      post.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/
  posts/
    hello-world.md          # English (default)
  tr/
    posts/
      merhaba-dunya.md      # Turkish

Configuration

config :sayfa, :site,
  default_lang: :en,
  languages: [
    en: [name: "English"],
    tr: [name: "Türkçe"]
  ]

URL Patterns

English (default):  /posts/hello-world/
Turkish:            /tr/posts/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 post "Hello World" --lang=en,tr

Translation Function

Templates receive a @t function for translating UI strings:

<%= @t.("recent_posts") %>   <%# "Recent Posts" in English, "Son Yazılar" 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/posts.xml        # Posts 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",
  posts_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

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
posts_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

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 post "My First Post"
mix sayfa.gen.content note "Quick Tip" --tags=elixir,tips
mix sayfa.gen.content post "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.Hero # → lib/blocks/hero.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/
│   ├── posts/                  # Blog posts
│   │   └── 2024-01-15-hello-world.md
│   ├── notes/                  # Quick notes
│   ├── projects/               # Portfolio projects
│   ├── talks/                  # Talks/presentations
│   ├── pages/                  # Static pages
│   │   └── about.md
│   └── tr/                     # Turkish translations
│       └── posts/
│
├── 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:

Stage Input Description
:before_parseContent.Raw Before markdown rendering
:after_parseContent After parsing, before template
:before_renderContent 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:


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.