Current section
Files
Jump to
Current section
Files
lib/mix/tasks/sayfa.gen.content.ex
defmodule Mix.Tasks.Sayfa.Gen.Content do
@moduledoc """
Generates a new content file with front matter scaffolding.
## Usage
mix sayfa.gen.content TYPE TITLE [options]
mix sayfa.gen.content --list
## Examples
mix sayfa.gen.content post "My First Post"
mix sayfa.gen.content note "Quick Tip"
mix sayfa.gen.content project "My Project" --tags=elixir,oss --draft
mix sayfa.gen.content talk "Intro to Elixir" --date=2026-03-15
mix sayfa.gen.content post "Hello World" --lang=en,tr
mix sayfa.gen.content --list
## Options
* `--list` - List available content types
* `--date` - Override date (default: today), format YYYY-MM-DD
* `--tags` - Comma-separated tags
* `--categories` - Comma-separated categories
* `--draft` - Mark as draft
* `--lang` - Language code or comma-separated codes (e.g., `en` or `en,tr`)
* `--slug` - Custom slug (default: slugified from title)
When multiple languages are provided, one file is generated per language
with `translations` front matter pre-wired to link them together.
Files are created under `content/{type_directory}/` with appropriate
front matter based on the content type's required fields.
"""
use Mix.Task
@shortdoc "Generate a new content file with front matter"
@switches [
list: :boolean,
date: :string,
tags: :string,
categories: :string,
draft: :boolean,
lang: :string,
slug: :string
]
@impl Mix.Task
def run(args) do
{opts, argv, _} = OptionParser.parse(args, switches: @switches)
cond do
Keyword.get(opts, :list, false) ->
list_content_types()
length(argv) < 2 ->
Mix.shell().error("Expected TYPE and TITLE. Usage: mix sayfa.gen.content TYPE \"Title\"")
Mix.shell().info("")
list_content_types()
exit({:shutdown, 1})
true ->
[type_name | title_parts] = argv
title = Enum.join(title_parts, " ")
generate_content(type_name, title, opts)
end
end
defp generate_content(type_name, title, opts) do
type_atom = String.to_atom(type_name)
case Sayfa.ContentType.find_by_name(type_atom) do
nil ->
Mix.shell().error("Unknown content type \"#{type_name}\".")
Mix.shell().info("")
list_content_types()
exit({:shutdown, 1})
type_mod ->
do_generate(type_mod, title, opts)
end
end
defp do_generate(type_mod, title, opts) do
slug = Keyword.get(opts, :slug) || Slug.slugify(title)
date = parse_date_option(Keyword.get(opts, :date))
tags = parse_list_option(Keyword.get(opts, :tags))
categories = parse_list_option(Keyword.get(opts, :categories))
draft = Keyword.get(opts, :draft, false)
langs = opts |> Keyword.get(:lang) |> parse_lang_option()
required = type_mod.required_fields()
has_date = :date in required
default_lang = List.first(langs)
Enum.each(langs, fn lang ->
translations = build_translations(lang, langs, slug)
front_matter =
build_front_matter(title, date, tags, categories, draft, has_date, lang, translations)
filename = build_filename(slug, date, has_date)
path_lang = if lang == default_lang, do: nil, else: lang
path = build_path(type_mod.directory(), filename, path_lang)
if File.exists?(path) do
Mix.shell().info("#{IO.ANSI.yellow()}Skipping#{IO.ANSI.reset()} #{path} (already exists)")
else
content = render_content(front_matter, title)
path |> Path.dirname() |> File.mkdir_p!()
File.write!(path, content)
Mix.shell().info("#{IO.ANSI.green()}Created#{IO.ANSI.reset()} #{path}")
end
end)
end
defp build_front_matter(title, date, tags, categories, draft, has_date, lang, translations) do
lines = [~s(title: "#{title}")]
lines =
if has_date do
lines ++ ["date: #{Date.to_iso8601(date)}"]
else
lines
end
lines = lines ++ [~s(description: "")]
lines =
if categories != [],
do: lines ++ ["categories: [#{Enum.join(categories, ", ")}]"],
else: lines ++ ["categories: []"]
lines =
if tags != [],
do: lines ++ ["tags: [#{Enum.join(tags, ", ")}]"],
else: lines ++ ["tags: []"]
lines = if draft, do: lines ++ ["draft: true"], else: lines ++ ["draft: false"]
lines =
case lang do
nil -> lines
l -> lines ++ ["lang: #{l}"]
end
lines =
if translations != %{} do
translation_lines =
translations
|> Enum.sort()
|> Enum.map(fn {k, v} -> " #{k}: #{v}" end)
lines ++ ["translations:"] ++ translation_lines
else
lines
end
Enum.join(lines, "\n")
end
defp build_filename(slug, date, true = _has_date) do
"#{Date.to_iso8601(date)}-#{slug}.md"
end
defp build_filename(slug, _date, false = _has_date) do
"#{slug}.md"
end
defp build_path(directory, filename, nil) do
Path.join(["content", directory, filename])
end
defp build_path(directory, filename, lang) do
Path.join(["content", lang, directory, filename])
end
defp render_content(front_matter, title) do
"""
---
#{front_matter}
---
# #{title}
Write your content here.
"""
end
defp parse_lang_option(nil), do: [nil]
defp parse_lang_option(str), do: str |> String.split(",") |> Enum.map(&String.trim/1)
defp build_translations(_current_lang, langs, _slug) when length(langs) <= 1, do: %{}
defp build_translations(current_lang, langs, slug) do
langs
|> Enum.reject(&(&1 == current_lang))
|> Map.new(fn lang -> {lang, slug} end)
end
defp parse_date_option(nil), do: Date.utc_today()
defp parse_date_option(date_str) do
case Date.from_iso8601(date_str) do
{:ok, date} ->
date
{:error, _} ->
Mix.shell().error("Invalid date format \"#{date_str}\". Expected YYYY-MM-DD.")
exit({:shutdown, 1})
end
end
defp parse_list_option(nil), do: []
defp parse_list_option(str), do: str |> String.split(",") |> Enum.map(&String.trim/1)
defp list_content_types do
types = Sayfa.ContentType.all()
Mix.shell().info("Available content types:")
Mix.shell().info("")
Enum.each(types, fn mod ->
name = mod.name()
dir = mod.directory()
Mix.shell().info(" #{name}\t(content/#{dir}/)")
end)
end
end