Skip to content

furkanural/sayfa

Repository files navigation

Sayfa

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

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

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

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

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

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 test

License

MIT License. See LICENSE for details.

About

A simple, extensible static site generator built in Elixir

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Contributors