# Blogex A multi-blog engine for Phoenix apps, powered by [NimblePublisher](https://github.com/dashbitco/nimble_publisher). Host an engineering blog **and** release notes (or any number of blogs) from markdown files in your repo. Posts compile into the BEAM at build time — zero database, zero runtime I/O, sub-millisecond reads. ## Features - **Multi-blog support** — run separate blogs from one app (engineering, release notes, etc.) - **Markdown + frontmatter** — write posts in your editor, version-control in git - **Compile-time indexing** — NimblePublisher bakes posts into module attributes - **Tagging & categorization** — filter posts by tag, per-blog or across all blogs - **RSS & Atom feeds** — auto-generated feeds per blog - **SEO helpers** — meta tags, OpenGraph, sitemaps - **Phoenix components** — unstyled function components you wrap in your layout - **Mountable router** — forward routes to Blogex, it handles the rest - **Live reload** — edit a `.md` file, see it instantly in dev ## Repository layout Blogex is designed to live alongside your Phoenix app in a monorepo: ``` firehose/ ← git root ├── app/ ← your Phoenix SaaS (OTP app: :firehose) │ ├── lib/ │ │ ├── firehose/ │ │ └── firehose_web/ │ ├── priv/ │ │ └── blog/ ← markdown posts live here │ │ ├── engineering/ │ │ └── release-notes/ │ └── mix.exs └── blogex/ ← this library ├── lib/ ├── test/ └── mix.exs ``` ## Installation In `app/mix.exs`, add blogex as a path dependency: ```elixir defp deps do [ {:blogex, path: "../blogex"}, # Optional: syntax highlighting for code blocks {:makeup_elixir, ">= 0.0.0"}, {:makeup_erlang, ">= 0.0.0"} ] end ``` ## Setup ### 1. Create your posts directory Inside your Phoenix app: ``` app/priv/blog/ ├── engineering/ │ └── 2026/ │ ├── 03-10-our-new-architecture.md │ └── 02-15-scaling-postgres.md └── release-notes/ └── 2026/ └── 03-01-v2-launch.md ``` ### 2. Write posts with frontmatter ```markdown %{ title: "Our New Architecture", author: "Jane Doe", tags: ~w(elixir otp architecture), description: "How we rebuilt our platform on OTP" } --- ## The problem Our monolith was getting unwieldy... ## The solution We broke it into an umbrella of focused OTP apps... ``` The filename encodes the date: `YYYY/MM-DD-slug.md` ### 3. Define blog modules ```elixir # lib/firehose/blogs/engineering_blog.ex defmodule Firehose.EngineeringBlog do use Blogex.Blog, blog_id: :engineering, app: :firehose, from: "priv/blog/engineering/**/*.md", title: "Engineering Blog", description: "Deep dives into our tech stack", base_path: "/blog/engineering" end # lib/firehose/blogs/release_notes.ex defmodule Firehose.ReleaseNotes do use Blogex.Blog, blog_id: :release_notes, app: :firehose, from: "priv/blog/release-notes/**/*.md", title: "Release Notes", description: "What's new in Firehose", base_path: "/blog/releases" end ``` ### 4. Configure ```elixir # config/config.exs config :blogex, blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes] ``` ### 5. Mount routes ```elixir # lib/firehose_web/router.ex scope "/blog" do pipe_through :browser forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes end ``` ### 6. Enable live reload (dev only) ```elixir # config/dev.exs live_reload: [ patterns: [ ..., ~r"priv/blog/*/.*(md)$" ] ] ``` ## Usage ### Querying a single blog ```elixir Firehose.EngineeringBlog.all_posts() Firehose.EngineeringBlog.recent_posts(5) Firehose.EngineeringBlog.get_post!("our-new-architecture") Firehose.EngineeringBlog.posts_by_tag("elixir") Firehose.EngineeringBlog.all_tags() Firehose.EngineeringBlog.paginate(1, 10) ``` ### Querying across all blogs ```elixir Blogex.all_posts() # all posts from all blogs, newest first Blogex.all_tags() # all unique tags across blogs Blogex.get_blog!(:engineering) # get the blog module ``` ### Using Phoenix components ```heex import Blogex.Components <.post_index posts={@posts} base_path="/blog/engineering" /> <.post_show post={@post} /> <.tag_list tags={@tags} base_path="/blog/engineering" current_tag={@tag} /> <.pagination page={@page} total_pages={@total_pages} base_path="/blog/engineering" /> ``` ### Generating feeds The mounted router serves feeds automatically at `/feed.xml` and `/atom.xml`. You can also generate them manually: ```elixir Blogex.Feed.rss(Firehose.EngineeringBlog, "https://firehose.dev") Blogex.Feed.atom(Firehose.EngineeringBlog, "https://firehose.dev") ``` ### SEO ```elixir # Meta tags for a post meta = Blogex.SEO.meta_tags(post, "https://firehose.dev", Firehose.EngineeringBlog) # Sitemap across all blogs xml = Blogex.SEO.sitemap(Blogex.blogs(), "https://firehose.dev") ``` ## Architecture Blogex follows the **poncho pattern** — it wraps NimblePublisher and presents a clean API to your Phoenix app. There are no GenServers or processes; all post data is compiled into BEAM bytecode via module attributes. This means: - Reads are instant (no I/O, no database) - Posts update on recompilation (or live reload in dev) - The host app owns the layout, styling, and routing - Blogex is a sibling library, not an umbrella child — it has its own `mix.exs` and test suite - When ready to open-source or publish to Hex, swap `path: "../blogex"` for a version number ## License MIT