# Sayfa
[](https://hex.pm/packages/sayfa)
[](LICENSE)
[](https://elixir-lang.org/)
A simple, extensible static site generator built in Elixir. **Sayfa** means "page" in Turkish.
[Turkce README / Turkish README](README.tr.md)
---
## Table of Contents
- [What is Sayfa?](#what-is-sayfa)
- [Features](#features)
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [Content Types](#content-types)
- [Front Matter](#front-matter)
- [Layouts & Templates](#layouts--templates)
- [Blocks](#blocks)
- [Themes](#themes)
- [Multilingual Support](#multilingual-support)
- [Feeds & SEO](#feeds--seo)
- [Configuration](#configuration)
- [CLI Commands](#cli-commands)
- [Project Structure](#project-structure)
- [Extensibility](#extensibility)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [License](#license)
---
## 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** — Uses standalone TailwindCSS CLI and Pagefind. 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 (posts, 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)
- 9 built-in blocks (hero, header, footer, social links, TOC, recent posts, tag cloud, reading time, code copy)
- Theme inheritance (custom -> parent -> default)
- EEx templates with `@block` helper
### Internationalization
- Directory-based multilingual support
- Per-language URL prefixes (`/tr/posts/...`)
### SEO & Feeds
- Atom/RSS feed generation
- Sitemap XML
- Pagefind static search integration
- 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.18 | 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
```bash
# 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 `output/` 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
Implement the `Sayfa.Behaviours.ContentType` behaviour:
```elixir
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 `---`:
```yaml
---
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:
```yaml
---
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/note | reading_time, toc, social_links |
| `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 |
| `@inner_content` | `String.t()` | Rendered inner HTML (base layout only) |
---
## Blocks
Blocks are reusable EEx components invoked via the `@block` helper:
```eex
<%= @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 |
| 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 |
| Reading Time | `:reading_time` | Estimated reading time |
| Code Copy | `:code_copy` | Copy button for code blocks |
### Custom Blocks
Implement the `Sayfa.Behaviours.Block` behaviour:
```elixir
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:
```elixir
config :sayfa, :site,
blocks: [
Sayfa.Blocks.Hero,
MyApp.Blocks.Banner
]
```
Then use it in templates:
```eex
<%= @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:
```elixir
config :sayfa, :site,
theme: "my_theme"
```
### Theme Inheritance
Custom themes inherit from a parent. Any layout not overridden falls back to the parent theme:
```elixir
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
```elixir
config :sayfa, :site,
default_lang: :en,
languages: [
en: [name: "English"],
tr: [name: "Turkce", path: "/tr"]
]
```
### URL Patterns
```
English (default): /posts/hello-world/
Turkish: /tr/posts/merhaba-dunya/
```
---
## 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 output directory containing all published pages.
### Search
Sayfa integrates with [Pagefind](https://pagefind.app/) for static search. Pagefind runs as a post-build step and generates a client-side search index — no server required.
### SEO Meta Tags
Templates automatically include Open Graph and description meta tags based on front matter fields.
---
## Configuration
Site configuration lives in `config/site.exs`:
```elixir
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: "output",
posts_per_page: 10,
drafts: false,
# Language
default_lang: :en,
languages: [en: [name: "English"]],
# Theme
theme: "default",
theme_parent: "default",
# 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 | `"output"` | 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 |
| `port` | Integer | `4000` | Dev server port |
| `verbose` | Boolean | `false` | Verbose build logging |
---
## CLI Commands
### `mix sayfa.new`
Generate a new Sayfa site:
```bash
mix sayfa.new my_blog
mix sayfa.new my_blog --theme minimal --lang en,tr
```
### `mix sayfa.build`
Build the site:
```bash
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.serve`
Start the development server:
```bash
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 output
│ ├── images/
│ └── favicon.ico
│
├── lib/ # Custom blocks, hooks, content types
│
├── output/ # Generated site (git-ignored)
│
└── mix.exs
```
---
## Extensibility
Sayfa is designed to be extended via three behaviours:
### Blocks
Reusable template components. See the [Blocks](#blocks) section.
### Hooks
Inject custom logic into the build pipeline at 4 stages:
```elixir
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:
```elixir
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](#custom-content-types).
---
## Roadmap
Future plans for Sayfa:
- Image optimization (automatic resizing, WebP conversion)
- TailwindCSS integration (build step)
- Dark mode toggle in default theme
- Deployment helpers (GitHub Pages, Netlify, Vercel)
- Plugin system for third-party extensions
- Asset fingerprinting
---
## Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
```bash
git clone https://github.com/furkanural/sayfa.git
cd sayfa
mix deps.get
mix test
```
---
## License
MIT License. See [LICENSE](LICENSE) for details.