commit bc14696f57dfe493cf46de5e905f5bce3274f691 Author: Your Name Date: Tue Mar 17 11:17:21 2026 +0000 Static blog with front page summary Goal: have a personal blog, and try out another point in the 'modular app design with elixir' space. Designing OTP systems with elixir had some interesting ideas. diff --git a/app/.formatter.exs b/app/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/app/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..b0686aa --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,37 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +firehose-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 0000000..6f52c21 --- /dev/null +++ b/app/AGENTS.md @@ -0,0 +1,334 @@ +This is a web application written using the Phoenix web framework. + +## Project guidelines + +- Use `mix precommit` alias when you are done with all changes and fix any pending issues +- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps + +### Phoenix v1.8 guidelines + +- **Always** begin your LiveView templates with `` which wraps all inner content +- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again +- Anytime you run into errors with no `current_scope` assign: + - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` + - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed +- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module +- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar +- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors +- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your +custom classes must fully style the input + +### JS and CSS guidelines + +- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces. +- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`: + + @import "tailwindcss" source(none); + @source "../css"; + @source "../js"; + @source "../../lib/my_app_web"; + +- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new` +- **Never** use `@apply` when writing raw css +- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design +- Out of the box **only the app.js and app.css bundles are supported** + - You cannot reference an external vendor'd script `src` or link `href` in the layouts + - You must import the vendor deps into app.js and app.css to use them + - **Never write inline tags within templates** + +### UI/UX & design guidelines + +- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles +- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions) +- Ensure **clean typography, spacing, and layout balance** for a refined, premium look +- Focus on **delightful details** like hover effects, loading states, and smooth page transitions + + + + + +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + + + +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + + +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct + + + +## Phoenix HTML guidelines + +- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` +- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated +- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` +- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) +- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) + +- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. + + **Never do this (invalid)**: + + <%= if condition do %> + ... + <% else if other_condition %> + ... + <% end %> + + Instead **always** do this: + + <%= cond do %> + <% condition -> %> + ... + <% condition2 -> %> + ... + <% true -> %> + ... + <% end %> + +- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+      Text
+
+  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+  and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+       ...
+      => Raises compile syntax error on invalid HEEx attr syntax
+
+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+  **Always** do this:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + + +## Phoenix LiveView guidelines + +- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews +- **Avoid LiveComponent's** unless you have a strong, specific need for them +- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` +- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute +- **Never** write embedded ` + + + + {@inner_content} + + diff --git a/app/lib/firehose_web/controllers/blog_controller.ex b/app/lib/firehose_web/controllers/blog_controller.ex new file mode 100644 index 0000000..c010fa5 --- /dev/null +++ b/app/lib/firehose_web/controllers/blog_controller.ex @@ -0,0 +1,50 @@ +defmodule FirehoseWeb.BlogController do + use FirehoseWeb, :controller + + def index(conn, %{"blog_id" => blog_id} = params) do + blog = resolve_blog!(blog_id) + page = String.to_integer(params["page"] || "1") + result = blog.paginate(page) + + render(conn, :index, + page_title: blog.title(), + blog_title: blog.title(), + blog_description: blog.description(), + posts: result.entries, + base_path: blog.base_path(), + page: result.page, + total_pages: result.total_pages + ) + end + + def show(conn, %{"blog_id" => blog_id, "slug" => slug}) do + blog = resolve_blog!(blog_id) + post = blog.get_post!(slug) + + render(conn, :show, + page_title: post.title, + post: post, + base_path: blog.base_path() + ) + end + + def tag(conn, %{"blog_id" => blog_id, "tag" => tag}) do + blog = resolve_blog!(blog_id) + posts = blog.posts_by_tag(tag) + + render(conn, :tag, + page_title: "#{blog.title()} — #{tag}", + blog_title: blog.title(), + tag: tag, + posts: posts, + base_path: blog.base_path() + ) + end + + defp resolve_blog!("engineering"), do: Firehose.EngineeringBlog + defp resolve_blog!("releases"), do: Firehose.ReleaseNotes + + defp resolve_blog!(id) do + raise Blogex.NotFoundError, "unknown blog: #{inspect(id)}" + end +end diff --git a/app/lib/firehose_web/controllers/blog_html.ex b/app/lib/firehose_web/controllers/blog_html.ex new file mode 100644 index 0000000..01e6353 --- /dev/null +++ b/app/lib/firehose_web/controllers/blog_html.ex @@ -0,0 +1,7 @@ +defmodule FirehoseWeb.BlogHTML do + use FirehoseWeb, :html + + import Blogex.Components + + embed_templates "blog_html/*" +end diff --git a/app/lib/firehose_web/controllers/blog_html/index.html.heex b/app/lib/firehose_web/controllers/blog_html/index.html.heex new file mode 100644 index 0000000..c96f07a --- /dev/null +++ b/app/lib/firehose_web/controllers/blog_html/index.html.heex @@ -0,0 +1,9 @@ +
+
+

{@blog_title}

+

{@blog_description}

+
+ + <.post_index posts={@posts} base_path={@base_path} /> + <.pagination page={@page} total_pages={@total_pages} base_path={@base_path} /> +
diff --git a/app/lib/firehose_web/controllers/blog_html/show.html.heex b/app/lib/firehose_web/controllers/blog_html/show.html.heex new file mode 100644 index 0000000..6bcfbd8 --- /dev/null +++ b/app/lib/firehose_web/controllers/blog_html/show.html.heex @@ -0,0 +1,4 @@ +
+ ← Back to posts + <.post_show post={@post} /> +
diff --git a/app/lib/firehose_web/controllers/blog_html/tag.html.heex b/app/lib/firehose_web/controllers/blog_html/tag.html.heex new file mode 100644 index 0000000..94faff2 --- /dev/null +++ b/app/lib/firehose_web/controllers/blog_html/tag.html.heex @@ -0,0 +1,10 @@ +
+
+

{@blog_title}

+

Posts tagged "{@tag}"

+
+ + <.post_index posts={@posts} base_path={@base_path} /> + + ← All posts +
diff --git a/app/lib/firehose_web/controllers/error_html.ex b/app/lib/firehose_web/controllers/error_html.ex new file mode 100644 index 0000000..5cd2245 --- /dev/null +++ b/app/lib/firehose_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule FirehoseWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use FirehoseWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/firehose_web/controllers/error_html/404.html.heex + # * lib/firehose_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/app/lib/firehose_web/controllers/error_json.ex b/app/lib/firehose_web/controllers/error_json.ex new file mode 100644 index 0000000..3371620 --- /dev/null +++ b/app/lib/firehose_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule FirehoseWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/app/lib/firehose_web/controllers/page_controller.ex b/app/lib/firehose_web/controllers/page_controller.ex new file mode 100644 index 0000000..6cc94d6 --- /dev/null +++ b/app/lib/firehose_web/controllers/page_controller.ex @@ -0,0 +1,13 @@ +defmodule FirehoseWeb.PageController do + use FirehoseWeb, :controller + + def home(conn, _params) do + recent_posts = + (Firehose.EngineeringBlog.recent_posts(3) ++ + Firehose.ReleaseNotes.recent_posts(3)) + |> Enum.sort_by(& &1.date, {:desc, Date}) + |> Enum.take(6) + + render(conn, :home, recent_posts: recent_posts) + end +end diff --git a/app/lib/firehose_web/controllers/page_html.ex b/app/lib/firehose_web/controllers/page_html.ex new file mode 100644 index 0000000..5241740 --- /dev/null +++ b/app/lib/firehose_web/controllers/page_html.ex @@ -0,0 +1,18 @@ +defmodule FirehoseWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use FirehoseWeb, :html + + embed_templates "page_html/*" + + defp blog_label(%{blog: :engineering}), do: "Engineering" + defp blog_label(%{blog: :release_notes}), do: "Releases" + defp blog_label(_), do: "Blog" + + defp post_base_path(%{blog: :engineering}), do: "/blog/engineering" + defp post_base_path(%{blog: :release_notes}), do: "/blog/releases" + defp post_base_path(_), do: "/blog" +end diff --git a/app/lib/firehose_web/controllers/page_html/home.html.heex b/app/lib/firehose_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..23745a6 --- /dev/null +++ b/app/lib/firehose_web/controllers/page_html/home.html.heex @@ -0,0 +1,36 @@ +
+
+

+ Drinking from the firehose +

+
+

+ I'm Willem van den Ende, + partner at QWAN. + This is where I write about AI-native consulting, shitty evals, + and whatever prototype I'm building this week. +

+
+
+ +
+

Recent posts

+ +
+
diff --git a/app/lib/firehose_web/endpoint.ex b/app/lib/firehose_web/endpoint.ex new file mode 100644 index 0000000..7c125d1 --- /dev/null +++ b/app/lib/firehose_web/endpoint.ex @@ -0,0 +1,54 @@ +defmodule FirehoseWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :firehose + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_firehose_key", + signing_salt: "gRqpcd8K", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :firehose, + gzip: not code_reloading?, + only: FirehoseWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :firehose + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug FirehoseWeb.Router +end diff --git a/app/lib/firehose_web/gettext.ex b/app/lib/firehose_web/gettext.ex new file mode 100644 index 0000000..2864a14 --- /dev/null +++ b/app/lib/firehose_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule FirehoseWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: FirehoseWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :firehose +end diff --git a/app/lib/firehose_web/router.ex b/app/lib/firehose_web/router.ex new file mode 100644 index 0000000..2a1e5ef --- /dev/null +++ b/app/lib/firehose_web/router.ex @@ -0,0 +1,54 @@ +defmodule FirehoseWeb.Router do + use FirehoseWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {FirehoseWeb.Layouts, :root} + plug :put_layout, html: {FirehoseWeb.Layouts, :app} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", FirehoseWeb do + pipe_through :browser + + get "/", PageController, :home + end + + scope "/blog", FirehoseWeb do + pipe_through :browser + + get "/:blog_id", BlogController, :index + get "/:blog_id/tag/:tag", BlogController, :tag + get "/:blog_id/:slug", BlogController, :show + end + + # JSON API + feeds (no Phoenix layout) + scope "/api/blog" do + forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog + forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes + end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:firehose, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: FirehoseWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/app/lib/firehose_web/telemetry.ex b/app/lib/firehose_web/telemetry.ex new file mode 100644 index 0000000..b49f89e --- /dev/null +++ b/app/lib/firehose_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule FirehoseWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("firehose.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("firehose.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("firehose.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("firehose.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("firehose.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {FirehoseWeb, :count_users, []} + ] + end +end diff --git a/app/mix.exs b/app/mix.exs new file mode 100644 index 0000000..4c83230 --- /dev/null +++ b/app/mix.exs @@ -0,0 +1,95 @@ +defmodule Firehose.MixProject do + use Mix.Project + + def project do + [ + app: :firehose, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Firehose.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.8.1"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.1.0"}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.2.0", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.16"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.26"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"}, + {:blogex, path: "../blogex"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["compile", "tailwind firehose", "esbuild firehose"], + "assets.deploy": [ + "tailwind firehose --minify", + "esbuild firehose --minify", + "phx.digest" + ], + precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/app/mix.lock b/app/mix.lock new file mode 100644 index 0000000..8693821 --- /dev/null +++ b/app/mix.lock @@ -0,0 +1,52 @@ +%{ + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "nimble_publisher": {:hex, :nimble_publisher, "1.1.1", "3ea4d4cfca45b11a5377bce7608367a9ddd7e717a9098161d8439eca23e239aa", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d67e15bddf07e8c60f75849008b78ea8c6b2b4ae8e3f882ccf0a22d57bd42ed0"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/app/priv/blog/engineering/2026/03-16-hello-world.md b/app/priv/blog/engineering/2026/03-16-hello-world.md new file mode 100644 index 0000000..123210f --- /dev/null +++ b/app/priv/blog/engineering/2026/03-16-hello-world.md @@ -0,0 +1,8 @@ +%{ + title: "Hello World", + author: "Firehose Team", + tags: ~w(elixir phoenix), + description: "Our first engineering blog post" +} +--- +Welcome to the Firehose engineering blog! We'll be sharing deep dives into our tech stack here. diff --git a/app/priv/blog/release-notes/2026/03-16-v0-1-0.md b/app/priv/blog/release-notes/2026/03-16-v0-1-0.md new file mode 100644 index 0000000..2abbf9d --- /dev/null +++ b/app/priv/blog/release-notes/2026/03-16-v0-1-0.md @@ -0,0 +1,13 @@ +%{ + title: "v0.1.0 Released", + author: "Firehose Team", + tags: ~w(release), + description: "Initial release of Firehose" +} +--- +We're excited to announce the initial release of Firehose v0.1.0! + +## What's included + +- Phoenix 1.8 application scaffold +- Blog engine powered by Blogex diff --git a/app/priv/gettext/en/LC_MESSAGES/errors.po b/app/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/app/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/app/priv/gettext/errors.pot b/app/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/app/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/app/priv/repo/migrations/.formatter.exs b/app/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/app/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/app/priv/repo/seeds.exs b/app/priv/repo/seeds.exs new file mode 100644 index 0000000..369044f --- /dev/null +++ b/app/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Firehose.Repo.insert!(%Firehose.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/app/priv/static/favicon.ico b/app/priv/static/favicon.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/app/priv/static/favicon.ico differ diff --git a/app/priv/static/favicon.svg b/app/priv/static/favicon.svg new file mode 100644 index 0000000..625a2b2 --- /dev/null +++ b/app/priv/static/favicon.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/priv/static/images/logo.svg b/app/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/app/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/app/priv/static/robots.txt b/app/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/app/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/app/test/firehose_web/controllers/blog_test.exs b/app/test/firehose_web/controllers/blog_test.exs new file mode 100644 index 0000000..378bc9c --- /dev/null +++ b/app/test/firehose_web/controllers/blog_test.exs @@ -0,0 +1,88 @@ +defmodule FirehoseWeb.BlogTest do + use FirehoseWeb.ConnCase + + describe "engineering blog (HTML)" do + test "GET /blog/engineering returns HTML index with layout", %{conn: conn} do + conn = get(conn, "/blog/engineering") + body = html_response(conn, 200) + assert body =~ "Engineering Blog" + assert body =~ "Hello World" + # Verify app layout is present (navbar) + assert body =~ "firehose" + end + + test "GET /blog/engineering/:slug returns HTML post with layout", %{conn: conn} do + conn = get(conn, "/blog/engineering/hello-world") + body = html_response(conn, 200) + assert body =~ "Hello World" + assert body =~ "firehose" + end + + test "GET /blog/engineering/tag/:tag returns HTML tag page", %{conn: conn} do + conn = get(conn, "/blog/engineering/tag/elixir") + body = html_response(conn, 200) + assert body =~ ~s(tagged "elixir") + assert body =~ "Hello World" + end + end + + describe "release notes blog (HTML)" do + test "GET /blog/releases returns HTML index", %{conn: conn} do + conn = get(conn, "/blog/releases") + body = html_response(conn, 200) + assert body =~ "Release Notes" + assert body =~ "v0.1.0 Released" + end + + test "GET /blog/releases/:slug returns HTML post", %{conn: conn} do + conn = get(conn, "/blog/releases/v0-1-0") + body = html_response(conn, 200) + assert body =~ "v0.1.0 Released" + end + end + + describe "engineering blog (JSON API)" do + test "GET /api/blog/engineering returns post index", %{conn: conn} do + conn = get(conn, "/api/blog/engineering") + assert %{"blog" => "engineering", "posts" => posts} = json_response(conn, 200) + assert is_list(posts) + assert length(posts) > 0 + end + + test "GET /api/blog/engineering/:slug returns a post", %{conn: conn} do + conn = get(conn, "/api/blog/engineering/hello-world") + assert %{"id" => "hello-world", "title" => "Hello World"} = json_response(conn, 200) + end + + test "GET /api/blog/engineering/:slug returns 404 for missing post", %{conn: conn} do + conn = get(conn, "/api/blog/engineering/nonexistent") + assert response(conn, 404) + end + + test "GET /api/blog/engineering/feed.xml returns RSS", %{conn: conn} do + conn = get(conn, "/api/blog/engineering/feed.xml") + assert response_content_type(conn, :xml) + assert response(conn, 200) =~ " "release_notes", "posts" => posts} = json_response(conn, 200) + assert is_list(posts) + assert length(posts) > 0 + end + + test "GET /api/blog/releases/:slug returns a post", %{conn: conn} do + conn = get(conn, "/api/blog/releases/v0-1-0") + assert %{"id" => "v0-1-0", "title" => "v0.1.0 Released"} = json_response(conn, 200) + end + + test "GET /api/blog/releases/feed.xml returns RSS", %{conn: conn} do + conn = get(conn, "/api/blog/releases/feed.xml") + assert response_content_type(conn, :xml) + assert response(conn, 200) =~ " Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/app/test/test_helper.exs b/app/test/test_helper.exs new file mode 100644 index 0000000..94fe0ca --- /dev/null +++ b/app/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Firehose.Repo, :manual) diff --git a/blogex/.formatter.exs b/blogex/.formatter.exs new file mode 100644 index 0000000..62752d4 --- /dev/null +++ b/blogex/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/blogex/.gitignore b/blogex/.gitignore new file mode 100644 index 0000000..c19f686 --- /dev/null +++ b/blogex/.gitignore @@ -0,0 +1,6 @@ +/_build/ +/deps/ +/doc/ +*.ez +*.beam +.elixir_ls/ diff --git a/blogex/README.md b/blogex/README.md new file mode 100644 index 0000000..6cddedf --- /dev/null +++ b/blogex/README.md @@ -0,0 +1,217 @@ +# 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 diff --git a/blogex/examples/blog_live_index.ex b/blogex/examples/blog_live_index.ex new file mode 100644 index 0000000..2e16042 --- /dev/null +++ b/blogex/examples/blog_live_index.ex @@ -0,0 +1,77 @@ +defmodule FirehoseWeb.BlogLive.Index do + @moduledoc """ + Example LiveView for the blog index page. + + Copy this into your host app and customize the layout/styling. + Replace `Firehose.EngineeringBlog` with your actual blog module. + """ + use MyAppWeb, :live_view + + import Blogex.Components + + @impl true + def mount(%{"blog" => blog_id} = _params, _session, socket) do + blog = Blogex.get_blog!(String.to_existing_atom(blog_id)) + + {:ok, + socket + |> assign(:blog, blog) + |> assign(:page_title, blog.title())} + end + + @impl true + def handle_params(params, _uri, socket) do + page = String.to_integer(params["page"] || "1") + tag = params["tag"] + blog = socket.assigns.blog + + posts = + if tag do + blog.posts_by_tag(tag) + else + blog.all_posts() + end + + per_page = 10 + total = length(posts) + total_pages = max(ceil(total / per_page), 1) + + entries = + posts + |> Enum.drop((page - 1) * per_page) + |> Enum.take(per_page) + + {:noreply, + socket + |> assign(:posts, entries) + |> assign(:tags, blog.all_tags()) + |> assign(:current_tag, tag) + |> assign(:page, page) + |> assign(:total_pages, total_pages) + |> assign(:base_path, blog.base_path())} + end + + @impl true + def render(assigns) do + ~H""" +
+

{@blog.title()}

+

{@blog.description()}

+ + <.tag_list tags={@tags} base_path={@base_path} current_tag={@current_tag} /> + +
+ <.post_index posts={@posts} base_path={@base_path} /> +
+ + <.pagination page={@page} total_pages={@total_pages} base_path={@base_path} /> + +
+ RSS Feed + · + Atom Feed +
+
+ """ + end +end diff --git a/blogex/examples/blog_live_show.ex b/blogex/examples/blog_live_show.ex new file mode 100644 index 0000000..fc3fdb5 --- /dev/null +++ b/blogex/examples/blog_live_show.ex @@ -0,0 +1,51 @@ +defmodule FirehoseWeb.BlogLive.Show do + @moduledoc """ + Example LiveView for showing a single blog post. + + Copy this into your host app and customize the layout/styling. + """ + use MyAppWeb, :live_view + + import Blogex.Components + + @impl true + def mount(%{"blog" => blog_id} = _params, _session, socket) do + blog = Blogex.get_blog!(String.to_existing_atom(blog_id)) + {:ok, assign(socket, :blog, blog)} + end + + @impl true + def handle_params(%{"slug" => slug}, _uri, socket) do + blog = socket.assigns.blog + post = blog.get_post!(slug) + + meta = Blogex.SEO.meta_tags(post, FirehoseWeb.Endpoint.url(), blog) + + {:noreply, + socket + |> assign(:post, post) + |> assign(:meta, meta) + |> assign(:page_title, post.title) + |> assign(:base_path, blog.base_path())} + end + + @impl true + def render(assigns) do + ~H""" +
+ + + <.post_show post={@post} /> + +
+

Tags

+ <.tag_list tags={@post.tags} base_path={@base_path} /> +
+
+ """ + end +end diff --git a/blogex/examples/router_example.ex b/blogex/examples/router_example.ex new file mode 100644 index 0000000..09321dd --- /dev/null +++ b/blogex/examples/router_example.ex @@ -0,0 +1,89 @@ +defmodule FirehoseWeb.Router do + @moduledoc """ + Example router showing how to integrate Blogex. + + You have two options for mounting blogs: + + ## Option A: Plug Router (JSON API / feeds only) + + Uses `Blogex.Router` directly — great for headless / API usage: + + scope "/blog" do + pipe_through :browser + forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog + forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes + end + + ## Option B: LiveView (full UI, recommended) + + Uses your own LiveViews with Blogex components — full control over layout: + + scope "/blog", MyAppWeb do + pipe_through :browser + + live "/engineering", BlogLive.Index, :index, + metadata: %{blog: :engineering} + + live "/engineering/:slug", BlogLive.Show, :show, + metadata: %{blog: :engineering} + + live "/releases", BlogLive.Index, :index, + metadata: %{blog: :release_notes} + + live "/releases/:slug", BlogLive.Show, :show, + metadata: %{blog: :release_notes} + end + + # Still mount the Plug router for feeds + scope "/blog" do + forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog + forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes + end + + ## Option C: Mixed + + Use LiveView for the HTML pages but let Blogex.Router handle + feeds and the JSON API. Just make sure the LiveView routes are + defined first so they take priority. + """ + + use Phoenix.Router + + import Phoenix.LiveView.Router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {FirehoseWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + # -- Option B: LiveView routes (recommended) -- + + scope "/blog", MyAppWeb do + pipe_through :browser + + # Engineering blog + live "/engineering", BlogLive.Index, :index + live "/engineering/tag/:tag", BlogLive.Index, :tag + live "/engineering/:slug", BlogLive.Show, :show + + # Release notes + live "/releases", BlogLive.Index, :index + live "/releases/tag/:tag", BlogLive.Index, :tag + live "/releases/:slug", BlogLive.Show, :show + end + + # Feeds (served by Blogex.Router as Plug) + scope "/blog" do + forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog + forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes + end + + # Sitemap + scope "/" do + get "/sitemap.xml", FirehoseWeb.SitemapController, :index + end +end diff --git a/blogex/examples/sitemap_controller.ex b/blogex/examples/sitemap_controller.ex new file mode 100644 index 0000000..b5694d0 --- /dev/null +++ b/blogex/examples/sitemap_controller.ex @@ -0,0 +1,14 @@ +defmodule FirehoseWeb.SitemapController do + @moduledoc """ + Example controller for serving the blog sitemap. + """ + use MyAppWeb, :controller + + def index(conn, _params) do + xml = Blogex.SEO.sitemap(Blogex.blogs(), FirehoseWeb.Endpoint.url()) + + conn + |> put_resp_content_type("application/xml") + |> send_resp(200, xml) + end +end diff --git a/blogex/lib/blogex.ex b/blogex/lib/blogex.ex new file mode 100644 index 0000000..74c0b4b --- /dev/null +++ b/blogex/lib/blogex.ex @@ -0,0 +1,118 @@ +defmodule Blogex do + @moduledoc """ + Blogex — a multi-blog engine for Phoenix apps, powered by NimblePublisher. + + Blogex lets you host multiple blogs (e.g. engineering blog, release notes) + from markdown files in your repo. Posts are compiled into the BEAM at build + time for instant reads with zero runtime I/O. + + ## Quick Start + + 1. Add `blogex` to your dependencies: + + ```elixir + def deps do + [ + {:blogex, "~> 0.1.0"} + ] + end + ``` + + 2. Create your markdown posts: + + ``` + priv/blog/engineering/2026/03-10-our-new-architecture.md + priv/blog/release-notes/2026/03-01-v2-launch.md + ``` + + Each file has frontmatter + content: + + ```markdown + %{ + title: "Our New Architecture", + author: "Jane Doe", + tags: ~w(elixir architecture), + description: "How we rebuilt our platform" + } + --- + Your markdown content here... + ``` + + 3. Define blog modules in your app: + + ```elixir + 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 + + 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 our product", + base_path: "/blog/releases" + end + ``` + + 4. Register blogs in your config: + + ```elixir + config :blogex, + blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes] + ``` + + 5. Mount routes in your Phoenix router: + + ```elixir + scope "/blog" do + pipe_through :browser + + forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog + forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes + end + ``` + + 6. Enable live reloading in `config/dev.exs`: + + ```elixir + live_reload: [ + patterns: [ + ..., + ~r"priv/blog/*/.*(md)$" + ] + ] + ``` + + ## Architecture (Poncho Pattern) + + Blogex is designed as a "poncho" — it wraps NimblePublisher and provides + a clean public API while the inner library does the heavy lifting of + markdown parsing and compilation. The host app's supervision tree is used + directly; Blogex adds no processes of its own since all data is compiled + into module attributes at build time. + + ## Modules + + * `Blogex.Blog` — macro to define a blog context + * `Blogex.Post` — post struct + * `Blogex.Registry` — cross-blog queries + * `Blogex.Feed` — RSS/Atom feed generation + * `Blogex.SEO` — meta tags and sitemap generation + * `Blogex.Components` — Phoenix function components + * `Blogex.Router` — mountable Plug router + """ + + defdelegate blogs, to: Blogex.Registry + defdelegate get_blog!(blog_id), to: Blogex.Registry + defdelegate get_blog(blog_id), to: Blogex.Registry + defdelegate all_posts, to: Blogex.Registry + defdelegate all_tags, to: Blogex.Registry +end diff --git a/blogex/lib/blogex/blog.ex b/blogex/lib/blogex/blog.ex new file mode 100644 index 0000000..040347b --- /dev/null +++ b/blogex/lib/blogex/blog.ex @@ -0,0 +1,122 @@ +defmodule Blogex.Blog do + @moduledoc """ + Macro to define a blog context backed by NimblePublisher. + + ## Usage + + In your host application, define one module per blog: + + 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 + + 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 + + Each module compiles all markdown posts at build time and exposes + query functions like `all_posts/0`, `get_post!/1`, `posts_by_tag/1`, etc. + """ + + defmacro __using__(opts) do + blog_id = Keyword.fetch!(opts, :blog_id) + app = Keyword.fetch!(opts, :app) + from = Keyword.fetch!(opts, :from) + title = Keyword.fetch!(opts, :title) + description = Keyword.get(opts, :description, "") + base_path = Keyword.fetch!(opts, :base_path) + highlighters = Keyword.get(opts, :highlighters, [:makeup_elixir, :makeup_erlang]) + + quote do + alias Blogex.Post + + use NimblePublisher, + build: Post, + from: Application.app_dir(unquote(app), unquote(from)), + as: :posts, + highlighters: unquote(highlighters) + + # Inject the blog_id into each post and sort by descending date + @posts @posts + |> Enum.map(&Map.put(&1, :blog, unquote(blog_id))) + |> Enum.sort_by(& &1.date, {:desc, Date}) + + # Collect all unique tags + @tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort() + + @blog_id unquote(blog_id) + @blog_title unquote(title) + @blog_description unquote(description) + @blog_base_path unquote(base_path) + + @doc "Returns the blog identifier atom." + def blog_id, do: @blog_id + + @doc "Returns the blog title." + def title, do: @blog_title + + @doc "Returns the blog description." + def description, do: @blog_description + + @doc "Returns the base URL path for this blog." + def base_path, do: @blog_base_path + + @doc "Returns all published posts, newest first." + def all_posts, do: Enum.filter(@posts, & &1.published) + + @doc "Returns the N most recent published posts." + def recent_posts(n \\ 5), do: Enum.take(all_posts(), n) + + @doc "Returns all unique tags across all published posts." + def all_tags, do: @tags + + @doc "Returns all published posts matching the given tag." + def posts_by_tag(tag) do + Enum.filter(all_posts(), fn post -> tag in post.tags end) + end + + @doc "Returns a single post by slug/id, or raises." + def get_post!(id) do + Enum.find(all_posts(), &(&1.id == id)) || + raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}" + end + + @doc "Returns a single post by slug/id, or nil." + def get_post(id) do + Enum.find(all_posts(), &(&1.id == id)) + end + + @doc "Returns paginated posts. Page is 1-indexed." + def paginate(page \\ 1, per_page \\ 10) do + posts = all_posts() + total = length(posts) + total_pages = max(ceil(total / per_page), 1) + + entries = + posts + |> Enum.drop((page - 1) * per_page) + |> Enum.take(per_page) + + %{ + entries: entries, + page: page, + per_page: per_page, + total_entries: total, + total_pages: total_pages + } + end + end + end +end diff --git a/blogex/lib/blogex/components.ex b/blogex/lib/blogex/components.ex new file mode 100644 index 0000000..393624f --- /dev/null +++ b/blogex/lib/blogex/components.ex @@ -0,0 +1,153 @@ +defmodule Blogex.Components do + @moduledoc """ + Phoenix function components for rendering blog content. + + These are unstyled building blocks — the host app wraps them + in its own layout and applies its own CSS. + + ## Usage in a LiveView or template: + + import Blogex.Components + + <.post_index blog={@blog_module} posts={@posts} /> + <.post_show post={@post} /> + <.tag_list tags={@tags} base_path={@base_path} /> + """ + + use Phoenix.Component + + @doc """ + Renders a list of post previews. + + ## Attributes + + * `:posts` - list of `%Blogex.Post{}` structs (required) + * `:base_path` - base URL path for post links (required) + """ + attr :posts, :list, required: true + attr :base_path, :string, required: true + + def post_index(assigns) do + ~H""" +
+
+
+

+ {post.title} +

+ <.post_meta post={post} /> +
+

{post.description}

+
+
+ """ + end + + @doc """ + Renders a full blog post. + + ## Attributes + + * `:post` - a `%Blogex.Post{}` struct (required) + """ + attr :post, :map, required: true + + def post_show(assigns) do + ~H""" +
+
+

{@post.title}

+ <.post_meta post={@post} /> +
+
+ {Phoenix.HTML.raw(@post.body)} +
+
+ """ + end + + @doc """ + Renders post metadata (date, author, tags). + """ + attr :post, :map, required: true + + def post_meta(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a tag cloud / tag list with links. + + ## Attributes + + * `:tags` - list of tag strings (required) + * `:base_path` - base URL path (required) + * `:current_tag` - currently selected tag for highlighting (optional) + """ + attr :tags, :list, required: true + attr :base_path, :string, required: true + attr :current_tag, :string, default: nil + + def tag_list(assigns) do + ~H""" + + """ + end + + @doc """ + Renders pagination controls. + + ## Attributes + + * `:page` - current page number (required) + * `:total_pages` - total number of pages (required) + * `:base_path` - base URL path (required) + """ + attr :page, :integer, required: true + attr :total_pages, :integer, required: true + attr :base_path, :string, required: true + + def pagination(assigns) do + ~H""" + + """ + end +end diff --git a/blogex/lib/blogex/feed.ex b/blogex/lib/blogex/feed.ex new file mode 100644 index 0000000..41ad50c --- /dev/null +++ b/blogex/lib/blogex/feed.ex @@ -0,0 +1,139 @@ +defmodule Blogex.Feed do + @moduledoc """ + Generates RSS 2.0 and Atom feeds for a blog. + + ## Usage + + # In a controller or plug: + xml = Blogex.Feed.rss(MyApp.EngineeringBlog, "https://myapp.com") + conn |> put_resp_content_type("application/rss+xml") |> send_resp(200, xml) + """ + + @doc """ + Generates an RSS 2.0 XML feed for the given blog module. + + ## Options + + * `:limit` - max number of posts to include (default: 20) + * `:language` - feed language (default: "en-us") + """ + def rss(blog_module, base_url, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + language = Keyword.get(opts, :language, "en-us") + posts = Enum.take(blog_module.all_posts(), limit) + + blog_url = "#{base_url}#{blog_module.base_path()}" + feed_url = "#{blog_url}/feed.xml" + + pub_date = + case posts do + [latest | _] -> format_rfc822(latest.date) + [] -> format_rfc822(Date.utc_today()) + end + + """ + + + + #{escape(blog_module.title())} + #{blog_url} + #{escape(blog_module.description())} + #{language} + #{pub_date} + + #{Enum.map_join(posts, "\n", &item_xml(&1, base_url, blog_module))} + + + """ + |> String.trim() + end + + @doc """ + Generates an Atom feed for the given blog module. + + ## Options + + * `:limit` - max number of posts to include (default: 20) + """ + def atom(blog_module, base_url, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + posts = Enum.take(blog_module.all_posts(), limit) + + blog_url = "#{base_url}#{blog_module.base_path()}" + feed_url = "#{blog_url}/feed.xml" + + updated = + case posts do + [latest | _] -> format_iso8601(latest.date) + [] -> format_iso8601(Date.utc_today()) + end + + """ + + + #{escape(blog_module.title())} + + + #{blog_url} + #{updated} + #{Enum.map_join(posts, "\n", &entry_xml(&1, base_url, blog_module))} + + """ + |> String.trim() + end + + # Private helpers + + defp item_xml(post, base_url, blog_module) do + url = "#{base_url}#{blog_module.base_path()}/#{post.id}" + + """ + + #{escape(post.title)} + #{url} + #{url} + #{format_rfc822(post.date)} + #{escape(post.description)} + + #{Enum.map_join(post.tags, "\n", &" #{escape(&1)}")} + + """ + end + + defp entry_xml(post, base_url, blog_module) do + url = "#{base_url}#{blog_module.base_path()}/#{post.id}" + + """ + + #{escape(post.title)} + + #{url} + #{format_iso8601(post.date)} + #{format_iso8601(post.date)} + #{escape(post.author)} + #{escape(post.description)} + + #{Enum.map_join(post.tags, "\n", &" ")} + + """ + end + + defp format_rfc822(date) do + date + |> DateTime.new!(~T[00:00:00], "Etc/UTC") + |> Calendar.strftime("%a, %d %b %Y %H:%M:%S +0000") + end + + defp format_iso8601(date) do + "#{Date.to_iso8601(date)}T00:00:00Z" + end + + defp escape(text) when is_binary(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + |> String.replace("'", "'") + end +end diff --git a/blogex/lib/blogex/layout.ex b/blogex/lib/blogex/layout.ex new file mode 100644 index 0000000..dba26c6 --- /dev/null +++ b/blogex/lib/blogex/layout.ex @@ -0,0 +1,91 @@ +defmodule Blogex.Layout do + @moduledoc """ + Minimal HTML layout for Blogex pages. + + Provides a default HTML shell when serving blog content to browsers. + Host apps can override by providing their own layout module via the + `:layout` option on the router. + """ + + use Phoenix.Component + import Blogex.Components + + @doc """ + Wraps blog content in a minimal HTML page. + """ + attr :title, :string, required: true + attr :inner_content, :any, required: true + + def page(assigns) do + ~H""" + + + + + + {@title} + + + {Phoenix.HTML.raw(@inner_content)} + + + """ + end + + @doc "Renders the post index page." + def index_page(assigns) do + ~H""" + + + + + + {@blog_title} + + +

{@blog_title}

+

{@blog_description}

+ <.post_index posts={@posts} base_path={@base_path} /> + <.pagination page={@page} total_pages={@total_pages} base_path={@base_path} /> + + + """ + end + + @doc "Renders a single post page." + def show_page(assigns) do + ~H""" + + + + + + {@post.title} + + + + <.post_show post={@post} /> + + + """ + end + + @doc "Renders a tag listing page." + def tag_page(assigns) do + ~H""" + + + + + + {@blog_title} — #{@tag} + + + +

Posts tagged "{@tag}"

+ <.post_index posts={@posts} base_path={@base_path} /> + + + """ + end +end diff --git a/blogex/lib/blogex/not_found_error.ex b/blogex/lib/blogex/not_found_error.ex new file mode 100644 index 0000000..fcdad26 --- /dev/null +++ b/blogex/lib/blogex/not_found_error.ex @@ -0,0 +1,7 @@ +defmodule Blogex.NotFoundError do + @moduledoc """ + Raised when a blog or post is not found. + Implements Plug.Exception to return a 404 status. + """ + defexception [:message, plug_status: 404] +end diff --git a/blogex/lib/blogex/post.ex b/blogex/lib/blogex/post.ex new file mode 100644 index 0000000..a8b2775 --- /dev/null +++ b/blogex/lib/blogex/post.ex @@ -0,0 +1,68 @@ +defmodule Blogex.Post do + @moduledoc """ + Struct representing a single blog post. + + Posts are parsed from markdown files with frontmatter metadata. + The filename determines the date and slug: + + priv/blog/engineering/2026/03-10-our-new-architecture.md + + Frontmatter example: + + %{ + title: "Our New Architecture", + author: "Jane Doe", + tags: ~w(elixir architecture), + description: "How we rebuilt our platform" + } + --- + Your markdown content here... + """ + + @enforce_keys [:id, :title, :author, :body, :description, :date] + defstruct [ + :id, + :title, + :author, + :body, + :description, + :date, + :blog, + tags: [], + published: true + ] + + @type t :: %__MODULE__{ + id: String.t(), + title: String.t(), + author: String.t(), + body: String.t(), + description: String.t(), + date: Date.t(), + tags: [String.t()], + blog: atom(), + published: boolean() + } + + @doc """ + Build callback for NimblePublisher. + + Extracts the date from the filename path and merges with frontmatter attrs. + The `blog` atom is injected by the parent Blog module. + """ + def build(filename, attrs, body) do + [year, month_day_id] = + filename + |> Path.rootname() + |> Path.split() + |> Enum.take(-2) + + [month, day, id] = String.split(month_day_id, "-", parts: 3) + date = Date.from_iso8601!("#{year}-#{month}-#{day}") + + struct!( + __MODULE__, + [id: id, date: date, body: body] ++ Map.to_list(attrs) + ) + end +end diff --git a/blogex/lib/blogex/registry.ex b/blogex/lib/blogex/registry.ex new file mode 100644 index 0000000..9f02edc --- /dev/null +++ b/blogex/lib/blogex/registry.ex @@ -0,0 +1,52 @@ +defmodule Blogex.Registry do + @moduledoc """ + Registry that tracks all blog modules in the host application. + + Configure in your app's config: + + config :blogex, + blogs: [MyApp.EngineeringBlog, MyApp.ReleaseNotes] + + Then you can query across all blogs: + + Blogex.Registry.all_posts() # posts from all blogs, sorted by date + Blogex.Registry.blogs() # list of blog modules + Blogex.Registry.get_blog!(:engineering) # get a specific blog module + """ + + @doc "Returns the list of configured blog modules." + def blogs do + Application.get_env(:blogex, :blogs, []) + end + + @doc "Returns a blog module by its blog_id, or raises." + def get_blog!(blog_id) do + Enum.find(blogs(), fn mod -> mod.blog_id() == blog_id end) || + raise Blogex.NotFoundError, "blog #{inspect(blog_id)} not found" + end + + @doc "Returns a blog module by its blog_id, or nil." + def get_blog(blog_id) do + Enum.find(blogs(), fn mod -> mod.blog_id() == blog_id end) + end + + @doc "Returns all posts from all blogs, sorted newest first." + def all_posts do + blogs() + |> Enum.flat_map(& &1.all_posts()) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + @doc "Returns all unique tags across all blogs." + def all_tags do + blogs() + |> Enum.flat_map(& &1.all_tags()) + |> Enum.uniq() + |> Enum.sort() + end + + @doc "Returns a map of %{blog_id => blog_module} for all registered blogs." + def blogs_map do + Map.new(blogs(), fn mod -> {mod.blog_id(), mod} end) + end +end diff --git a/blogex/lib/blogex/router.ex b/blogex/lib/blogex/router.ex new file mode 100644 index 0000000..5e611eb --- /dev/null +++ b/blogex/lib/blogex/router.ex @@ -0,0 +1,194 @@ +defmodule Blogex.Router do + @moduledoc """ + Plug router that serves blog pages and feeds. + + Serves HTML to browsers (Accept: text/html) and JSON to API clients. + + Mount this in your host app's router: + + # In your Phoenix router + scope "/blog" do + pipe_through :browser + forward "/engineering", Blogex.Router, blog: MyApp.EngineeringBlog + forward "/releases", Blogex.Router, blog: MyApp.ReleaseNotes + end + + Or use the convenience macro: + + import Blogex.Router, only: [blogex_routes: 2] + + scope "/blog" do + pipe_through :browser + blogex_routes "/engineering", MyApp.EngineeringBlog + blogex_routes "/releases", MyApp.ReleaseNotes + end + + ## Routes served + + * `GET /` — post index (paginated) + * `GET /:slug` — individual post + * `GET /tag/:tag` — posts by tag + * `GET /feed.xml` — RSS feed + * `GET /atom.xml` — Atom feed + """ + + use Plug.Router + + plug :match + plug :dispatch + + get "/feed.xml" do + blog = conn.private[:blogex_blog] + base_url = Blogex.Router.Helpers.base_url(conn) + + xml = Blogex.Feed.rss(blog, base_url) + + conn + |> put_resp_content_type("application/rss+xml") + |> send_resp(200, xml) + end + + get "/atom.xml" do + blog = conn.private[:blogex_blog] + base_url = Blogex.Router.Helpers.base_url(conn) + + xml = Blogex.Feed.atom(blog, base_url) + + conn + |> put_resp_content_type("application/atom+xml") + |> send_resp(200, xml) + end + + get "/tag/:tag" do + blog = conn.private[:blogex_blog] + posts = blog.posts_by_tag(tag) + + if wants_html?(conn) do + assigns = %{ + blog_title: blog.title(), + tag: tag, + posts: posts, + base_path: blog.base_path() + } + + send_html(conn, Blogex.Layout.tag_page(assigns)) + else + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{ + blog: blog.blog_id(), + tag: tag, + posts: Enum.map(posts, &post_json/1) + })) + end + end + + get "/:slug" do + blog = conn.private[:blogex_blog] + + case blog.get_post(slug) do + nil -> + conn |> send_resp(404, "Post not found") + + post -> + if wants_html?(conn) do + assigns = %{post: post, base_path: blog.base_path()} + send_html(conn, Blogex.Layout.show_page(assigns)) + else + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(post_json(post))) + end + end + end + + get "/" do + blog = conn.private[:blogex_blog] + page = (conn.params["page"] || "1") |> String.to_integer() + result = blog.paginate(page) + + if wants_html?(conn) do + assigns = %{ + blog_title: blog.title(), + blog_description: blog.description(), + posts: result.entries, + base_path: blog.base_path(), + page: result.page, + total_pages: result.total_pages + } + + send_html(conn, Blogex.Layout.index_page(assigns)) + else + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{ + blog: blog.blog_id(), + title: blog.title(), + posts: Enum.map(result.entries, &post_json/1), + page: result.page, + total_pages: result.total_pages, + total_entries: result.total_entries + })) + end + end + + match _ do + send_resp(conn, 404, "Not found") + end + + @doc false + def init(opts), do: opts + + @doc false + def call(conn, opts) do + blog = Keyword.fetch!(opts, :blog) + + conn + |> Plug.Conn.put_private(:blogex_blog, blog) + |> super(opts) + end + + defp wants_html?(conn) do + case Plug.Conn.get_req_header(conn, "accept") do + [accept | _] -> String.contains?(accept, "text/html") + _ -> false + end + end + + defp send_html(conn, rendered) do + html = + rendered + |> Phoenix.HTML.Safe.to_iodata() + |> IO.iodata_to_binary() + + conn + |> put_resp_content_type("text/html") + |> send_resp(200, html) + end + + defp post_json(post) do + %{ + id: post.id, + title: post.title, + author: post.author, + date: Date.to_iso8601(post.date), + description: post.description, + tags: post.tags, + body: post.body + } + end + + defmodule Helpers do + @moduledoc false + + def base_url(conn) do + scheme = if conn.scheme == :https, do: "https", else: "http" + port_suffix = port_suffix(conn.scheme, conn.port) + "#{scheme}://#{conn.host}#{port_suffix}" + end + + defp port_suffix(:http, 80), do: "" + defp port_suffix(:https, 443), do: "" + defp port_suffix(_, port), do: ":#{port}" + end +end diff --git a/blogex/lib/blogex/seo.ex b/blogex/lib/blogex/seo.ex new file mode 100644 index 0000000..0982d82 --- /dev/null +++ b/blogex/lib/blogex/seo.ex @@ -0,0 +1,62 @@ +defmodule Blogex.SEO do + @moduledoc """ + SEO helpers for generating meta tags and sitemaps. + """ + + @doc """ + Returns a map of meta tag attributes for a post. + Useful for setting OpenGraph and Twitter card tags in your layout. + + + """ + def meta_tags(post, base_url, blog_module) do + url = "#{base_url}#{blog_module.base_path()}/#{post.id}" + + %{ + title: post.title, + description: post.description, + og_title: post.title, + og_description: post.description, + og_type: "article", + og_url: url, + article_published_time: Date.to_iso8601(post.date), + article_author: post.author, + article_tags: post.tags, + twitter_card: "summary_large_image" + } + end + + @doc """ + Generates a sitemap.xml string for the given blog modules. + + xml = Blogex.SEO.sitemap([MyApp.EngineeringBlog, MyApp.ReleaseNotes], "https://myapp.com") + """ + def sitemap(blog_modules, base_url) when is_list(blog_modules) do + urls = + blog_modules + |> Enum.flat_map(fn mod -> + mod.all_posts() + |> Enum.map(fn post -> + url = "#{base_url}#{mod.base_path()}/#{post.id}" + lastmod = Date.to_iso8601(post.date) + + """ + + #{url} + #{lastmod} + monthly + 0.7 + + """ + end) + end) + |> Enum.join() + + """ + + + #{urls} + """ + |> String.trim() + end +end diff --git a/blogex/mix.exs b/blogex/mix.exs new file mode 100644 index 0000000..6466a77 --- /dev/null +++ b/blogex/mix.exs @@ -0,0 +1,58 @@ +defmodule Blogex.MixProject do + use Mix.Project + + @version "0.1.0" + @source_url "https://github.com/yourorg/blogex" + + def project do + [ + app: :blogex, + version: @version, + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + deps: deps(), + docs: docs(), + package: package(), + description: "A multi-blog engine powered by NimblePublisher for Phoenix apps" + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:nimble_publisher, "~> 1.1"}, + {:makeup_elixir, ">= 0.0.0"}, + {:makeup_erlang, ">= 0.0.0"}, + {:phoenix, "~> 1.7"}, + {:phoenix_html, "~> 4.0"}, + {:phoenix_live_view, "~> 1.0"}, + {:jason, "~> 1.4"}, + {:plug, "~> 1.15"}, + {:ex_doc, "~> 0.34", only: :dev, runtime: false} + ] + end + + defp docs do + [ + main: "readme", + source_url: @source_url, + extras: ["README.md"] + ] + end + + defp package do + [ + licenses: ["MIT"], + links: %{"GitHub" => @source_url} + ] + end +end diff --git a/blogex/priv/blog/engineering/2026/02-15-testing-liveview-at-scale.md b/blogex/priv/blog/engineering/2026/02-15-testing-liveview-at-scale.md new file mode 100644 index 0000000..bd3effa --- /dev/null +++ b/blogex/priv/blog/engineering/2026/02-15-testing-liveview-at-scale.md @@ -0,0 +1,66 @@ +%{ + title: "How We Test LiveView at Scale", + author: "Carlos Rivera", + tags: ~w(elixir liveview testing), + description: "Our testing strategy for 200+ LiveView modules" +} +--- +With over 200 LiveView modules in our codebase, we needed a testing strategy +that was both fast and reliable. Here's what we landed on. + +## The three-layer approach + +We test LiveViews at three levels: + +1. **Unit tests** for the assign logic — pure functions, no rendering +2. **Component tests** for individual function components using `render_component/2` +3. **Integration tests** for full page flows using `live/2` + +The key insight is that most bugs live in the assign logic, not in the +templates. By extracting assigns into pure functions, we can test the +interesting bits without mounting a LiveView at all. + +```elixir +defmodule MyAppWeb.DashboardLive do + use MyAppWeb, :live_view + + # Pure function — easy to test + def compute_metrics(raw_data, date_range) do + raw_data + |> Enum.filter(&in_range?(&1, date_range)) + |> Enum.group_by(& &1.category) + |> Enum.map(fn {cat, items} -> + %{category: cat, count: length(items), total: Enum.sum_by(items, & &1.value)} + end) + end +end + +# In the test file +test "compute_metrics groups and sums correctly" do + data = [ + %{category: "sales", value: 100, date: ~D[2026-03-01]}, + %{category: "sales", value: 200, date: ~D[2026-03-02]}, + %{category: "support", value: 50, date: ~D[2026-03-01]} + ] + + result = DashboardLive.compute_metrics(data, {~D[2026-03-01], ~D[2026-03-31]}) + + assert [ + %{category: "sales", count: 2, total: 300}, + %{category: "support", count: 1, total: 50} + ] = Enum.sort_by(result, & &1.category) +end +``` + +## Speed matters + +Our full test suite runs in under 90 seconds on CI. The secret is +`async: true` everywhere and avoiding database writes in unit tests. +We use `Mox` for external service boundaries and `Ecto.Adapters.SQL.Sandbox` +only for integration tests. + +## What we'd do differently + +If starting over, we'd adopt property-based testing with `StreamData` earlier. +Several production bugs would have been caught by generating edge-case assigns +rather than hand-writing examples. diff --git a/blogex/priv/blog/engineering/2026/03-10-rebuilding-data-pipeline.md b/blogex/priv/blog/engineering/2026/03-10-rebuilding-data-pipeline.md new file mode 100644 index 0000000..2c05f2e --- /dev/null +++ b/blogex/priv/blog/engineering/2026/03-10-rebuilding-data-pipeline.md @@ -0,0 +1,64 @@ +%{ + title: "Rebuilding Our Data Pipeline with Broadway", + author: "Jane Doe", + tags: ~w(elixir broadway data-engineering), + description: "How we replaced our Kafka consumer with Broadway for 10x throughput" +} +--- +Last quarter we hit a wall with our homegrown Kafka consumer. Message lag was +growing, backpressure was non-existent, and our on-call engineers were losing +sleep. We decided to rebuild on [Broadway](https://github.com/dashbitco/broadway). + +## Why Broadway? + +Broadway gives us three things our old consumer lacked: + +- **Batching** — messages are grouped before hitting the database, cutting our + write volume by 90% +- **Backpressure** — producers only send what consumers can handle +- **Fault tolerance** — failed messages are retried automatically with + configurable strategies + +## The migration + +We ran both pipelines in parallel for two weeks, comparing output row-by-row. +Once we confirmed parity, we cut over with zero downtime. + +```elixir +defmodule MyApp.EventPipeline do + use Broadway + + def start_link(_opts) do + Broadway.start_link(__MODULE__, + name: __MODULE__, + producer: [ + module: {BroadwayKafka.Producer, [ + hosts: [localhost: 9092], + group_id: "my_app_events", + topics: ["events"] + ]} + ], + processors: [default: [concurrency: 10]], + batchers: [default: [batch_size: 100, batch_timeout: 500]] + ) + end + + @impl true + def handle_message(_, message, _) do + message + |> Broadway.Message.update_data(&Jason.decode!/1) + end + + @impl true + def handle_batch(_, messages, _, _) do + rows = Enum.map(messages, & &1.data) + MyApp.Repo.insert_all("events", rows) + messages + end +end +``` + +## Results + +After the migration, our p99 processing latency dropped from 12s to 180ms and +we haven't had a single page about consumer lag since. diff --git a/blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md b/blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md new file mode 100644 index 0000000..ac4c483 --- /dev/null +++ b/blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md @@ -0,0 +1,33 @@ +%{ + title: "v2.3.0 — Webhook Improvements & Dark Mode", + author: "Product Team", + tags: ~w(release webhooks ui), + description: "Reliable webhook delivery, dark mode, and improved search" +} +--- +Here's what landed in v2.3.0. + +## Webhook Reliability + +Webhooks now retry with exponential backoff (up to 5 attempts over 24 hours). +You can inspect delivery status and payloads from the new **Webhook Logs** +page in Settings. + +Failed deliveries surface in the activity feed so you never miss a dropped +event. + +## Dark Mode + +The dashboard now respects your system preference. You can also override it +manually from the appearance menu. All charts and graphs adapt automatically. + +## Search Improvements + +- Full-text search now indexes custom fields +- Search results show highlighted matching fragments +- Filters can be bookmarked and shared via URL + +## Deprecations + +The `GET /api/v1/users/:id/activity` endpoint is deprecated and will be +removed in v3.0. Use `GET /api/v2/activity?user_id=:id` instead. diff --git a/blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md b/blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md new file mode 100644 index 0000000..aaad60b --- /dev/null +++ b/blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md @@ -0,0 +1,35 @@ +%{ + title: "v2.4.0 — Team Dashboards & API Rate Limiting", + author: "Product Team", + tags: ~w(release dashboards api), + description: "New team dashboards, API rate limiting, and 12 bug fixes" +} +--- +We're excited to ship v2.4.0 with two major features and a pile of bug fixes. + +## Team Dashboards + +Every team now gets a shared dashboard showing key metrics at a glance. +Dashboards are fully customizable — drag widgets, set date ranges, and pin +the views that matter most. + +## API Rate Limiting + +We've introduced tiered rate limits to keep the platform fast for everyone: + +| Plan | Requests/min | Burst | +|------------|-------------|-------| +| Free | 60 | 10 | +| Pro | 600 | 50 | +| Enterprise | 6,000 | 200 | + +Rate limit headers (`X-RateLimit-Remaining`, `X-RateLimit-Reset`) are now +included in every API response. + +## Bug Fixes + +- Fixed CSV export failing for reports with more than 10k rows +- Resolved timezone display issue in the activity feed +- Fixed a race condition in webhook delivery retries +- Corrected pagination on the audit log page +- 8 additional minor fixes — see the full changelog diff --git a/blogex/test/blogex/blog_test.exs b/blogex/test/blogex/blog_test.exs new file mode 100644 index 0000000..fe89e9c --- /dev/null +++ b/blogex/test/blogex/blog_test.exs @@ -0,0 +1,145 @@ +defmodule Blogex.BlogTest do + use ExUnit.Case + + import Blogex.Test.PostBuilder + alias Blogex.Test.FakeBlog + + setup do + Blogex.Test.Setup.with_blog() + end + + describe "all_posts/0" do + test "excludes drafts", %{blog: blog} do + ids = blog.all_posts() |> Enum.map(& &1.id) + + assert "draft-post" not in ids + end + + test "returns posts newest first", %{blog: blog} do + dates = blog.all_posts() |> Enum.map(& &1.date) + + assert dates == Enum.sort(dates, {:desc, Date}) + end + end + + describe "recent_posts/1" do + test "returns at most n posts", %{blog: blog} do + assert length(blog.recent_posts(2)) == 2 + end + + test "returns newest posts", %{blog: blog} do + [first | _] = blog.recent_posts(1) + + assert first.id == "newest-post" + end + end + + describe "posts_by_tag/1" do + test "returns only posts with the given tag", %{blog: blog} do + posts = blog.posts_by_tag("testing") + + assert [%{id: "middle-post"}] = posts + end + + test "returns empty list for unknown tag", %{blog: blog} do + assert blog.posts_by_tag("nonexistent") == [] + end + + test "excludes drafts even if tag matches" do + {:ok, _} = FakeBlog.start([ + build(id: "pub", tags: ["elixir"], published: true), + build(id: "draft", tags: ["elixir"], published: false) + ]) + + ids = FakeBlog.posts_by_tag("elixir") |> Enum.map(& &1.id) + + assert "pub" in ids + refute "draft" in ids + end + end + + describe "all_tags/0" do + test "returns unique sorted tags from published posts", %{blog: blog} do + tags = blog.all_tags() + + assert tags == Enum.sort(tags) + assert "elixir" in tags + assert "devops" in tags + end + + test "excludes tags only appearing on drafts" do + {:ok, _} = FakeBlog.start([ + build(tags: ["visible"], published: true), + build(id: "d", tags: ["hidden"], published: false) + ]) + + refute "hidden" in FakeBlog.all_tags() + end + end + + describe "get_post!/1" do + test "returns post by id", %{blog: blog} do + post = blog.get_post!("oldest-post") + + assert post.id == "oldest-post" + end + + test "raises for unknown id", %{blog: blog} do + assert_raise Blogex.NotFoundError, fn -> + blog.get_post!("nope") + end + end + + test "raises for draft post id", %{blog: blog} do + assert_raise Blogex.NotFoundError, fn -> + blog.get_post!("draft-post") + end + end + end + + describe "get_post/1" do + test "returns nil for unknown id", %{blog: blog} do + assert blog.get_post("nope") == nil + end + end + + describe "paginate/2" do + setup do + posts = build_many(25) + {:ok, _} = FakeBlog.start(posts) + %{blog: FakeBlog} + end + + test "returns the correct page size", %{blog: blog} do + result = blog.paginate(1, 10) + + assert length(result.entries) == 10 + end + + test "calculates total pages", %{blog: blog} do + result = blog.paginate(1, 10) + + assert result.total_pages == 3 + assert result.total_entries == 25 + end + + test "returns fewer entries on last page", %{blog: blog} do + result = blog.paginate(3, 10) + + assert length(result.entries) == 5 + end + + test "page 2 does not overlap with page 1", %{blog: blog} do + page1_ids = blog.paginate(1, 10).entries |> MapSet.new(& &1.id) + page2_ids = blog.paginate(2, 10).entries |> MapSet.new(& &1.id) + + assert MapSet.disjoint?(page1_ids, page2_ids) + end + + test "returns empty entries beyond last page", %{blog: blog} do + result = blog.paginate(99, 10) + + assert result.entries == [] + end + end +end diff --git a/blogex/test/blogex/feed_test.exs b/blogex/test/blogex/feed_test.exs new file mode 100644 index 0000000..7409c22 --- /dev/null +++ b/blogex/test/blogex/feed_test.exs @@ -0,0 +1,133 @@ +defmodule Blogex.FeedTest do + use ExUnit.Case + + import Blogex.Test.PostBuilder + alias Blogex.Test.FakeBlog + alias Blogex.Feed + + @base_url "https://example.com" + + setup do + Blogex.Test.Setup.with_blog( + %{}, + blog_id: :eng, + title: "Eng Blog", + description: "Tech articles", + base_path: "/blog/eng" + ) + end + + describe "rss/3" do + test "produces valid RSS 2.0 XML", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + + assert xml =~ ~s() + end + + test "includes blog title and description", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + + assert xml =~ "Eng Blog" + assert xml =~ "Tech articles" + end + + test "includes post entries with correct links", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + + assert xml =~ "https://example.com/blog/eng/newest-post" + end + + test "wraps post body in CDATA", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + + assert xml =~ "elixir" + end + + test "respects limit option" do + {:ok, _} = FakeBlog.start(build_many(10)) + xml = Feed.rss(FakeBlog, @base_url, limit: 3) + + item_count = xml |> String.split("") |> length() |> Kernel.-(1) + assert item_count == 3 + end + + test "excludes drafts", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + + refute xml =~ "draft-post" + end + + test "includes self-referencing atom:link", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + + assert xml =~ ~s(href="https://example.com/blog/eng/feed.xml") + assert xml =~ ~s(rel="self") + end + end + + describe "atom/3" do + test "produces valid Atom XML", %{blog: blog} do + xml = Feed.atom(blog, @base_url) + + assert xml =~ ~s() + assert xml =~ ~s() + end + + test "includes post entries", %{blog: blog} do + xml = Feed.atom(blog, @base_url) + + assert xml =~ "" + assert xml =~ ~s(href="https://example.com/blog/eng/newest-post") + end + + test "respects limit option" do + {:ok, _} = FakeBlog.start(build_many(10)) + xml = Feed.atom(FakeBlog, @base_url, limit: 2) + + entry_count = xml |> String.split("") |> length() |> Kernel.-(1) + assert entry_count == 2 + end + end + + describe "XML escaping" do + test "escapes special characters in titles" do + {:ok, _} = FakeBlog.start( + [build(title: "Foo & Bar ")], + title: "A & B" + ) + + xml = Feed.rss(FakeBlog, @base_url) + + assert xml =~ "Foo & Bar <Baz>" + assert xml =~ "A & B" + end + end + + describe "empty blog" do + test "rss produces valid XML with no items" do + {:ok, _} = FakeBlog.start([]) + + xml = Feed.rss(FakeBlog, @base_url) + + assert xml =~ "" + refute xml =~ "" + end + + test "atom produces valid XML with no entries" do + {:ok, _} = FakeBlog.start([]) + + xml = Feed.atom(FakeBlog, @base_url) + + assert xml =~ "" + end + end +end diff --git a/blogex/test/blogex/not_found_error_test.exs b/blogex/test/blogex/not_found_error_test.exs new file mode 100644 index 0000000..f4e6288 --- /dev/null +++ b/blogex/test/blogex/not_found_error_test.exs @@ -0,0 +1,15 @@ +defmodule Blogex.NotFoundErrorTest do + use ExUnit.Case, async: true + + test "has a 404 plug status" do + error = %Blogex.NotFoundError{message: "not found"} + + assert error.plug_status == 404 + end + + test "is raisable with a message" do + assert_raise Blogex.NotFoundError, "gone", fn -> + raise Blogex.NotFoundError, "gone" + end + end +end diff --git a/blogex/test/blogex/post_test.exs b/blogex/test/blogex/post_test.exs new file mode 100644 index 0000000..7db6787 --- /dev/null +++ b/blogex/test/blogex/post_test.exs @@ -0,0 +1,71 @@ +defmodule Blogex.PostTest do + use ExUnit.Case, async: true + + alias Blogex.Post + + describe "build/3" do + test "extracts date from filename path" do + post = Post.build("anything/2026/03-10-my-slug.md", valid_attrs(), "

body

") + + assert post.date == ~D[2026-03-10] + end + + test "extracts slug from filename" do + post = Post.build("anything/2026/01-05-cool-feature.md", valid_attrs(), "

body

") + + assert post.id == "cool-feature" + end + + test "preserves slug with multiple hyphens" do + post = Post.build("x/2026/06-01-my-multi-part-slug.md", valid_attrs(), "

x

") + + assert post.id == "my-multi-part-slug" + end + + test "merges frontmatter attributes into struct" do + attrs = %{ + title: "Custom Title", + author: "Specific Author", + description: "Custom desc", + tags: ~w(alpha beta) + } + + post = Post.build("x/2026/01-01-x.md", attrs, "

x

") + + assert post.title == "Custom Title" + assert post.author == "Specific Author" + assert post.tags == ["alpha", "beta"] + end + + test "stores rendered HTML body" do + html = "

Hello

World

" + + post = Post.build("x/2026/01-01-x.md", valid_attrs(), html) + + assert post.body == html + end + + test "defaults published to true" do + post = Post.build("x/2026/01-01-x.md", valid_attrs(), "

x

") + + assert post.published == true + end + + test "allows overriding published to false" do + attrs = Map.put(valid_attrs(), :published, false) + + post = Post.build("x/2026/01-01-x.md", attrs, "

x

") + + assert post.published == false + end + end + + defp valid_attrs do + %{ + title: "Title", + author: "Author", + description: "Desc", + tags: ["tag"] + } + end +end diff --git a/blogex/test/blogex/registry_test.exs b/blogex/test/blogex/registry_test.exs new file mode 100644 index 0000000..50a8ea2 --- /dev/null +++ b/blogex/test/blogex/registry_test.exs @@ -0,0 +1,88 @@ +defmodule Blogex.RegistryTest do + use ExUnit.Case + + import Blogex.Test.PostBuilder + alias Blogex.Registry + + defmodule AlphaBlog do + def blog_id, do: :alpha + def all_posts, do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)] + def all_tags, do: ["elixir"] + end + + defmodule BetaBlog do + def blog_id, do: :beta + def all_posts, do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)] + def all_tags, do: ["devops"] + end + + setup do + Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog]) + on_exit(fn -> Application.delete_env(:blogex, :blogs) end) + end + + describe "blogs/0" do + test "returns configured blog modules" do + assert Registry.blogs() == [AlphaBlog, BetaBlog] + end + + test "returns empty list when unconfigured" do + Application.delete_env(:blogex, :blogs) + + assert Registry.blogs() == [] + end + end + + describe "get_blog!/1" do + test "returns module by blog_id" do + assert Registry.get_blog!(:alpha) == AlphaBlog + end + + test "raises for unknown blog_id" do + assert_raise Blogex.NotFoundError, fn -> + Registry.get_blog!(:nonexistent) + end + end + end + + describe "get_blog/1" do + test "returns nil for unknown blog_id" do + assert Registry.get_blog(:nonexistent) == nil + end + end + + describe "all_posts/0" do + test "merges posts from all blogs" do + ids = Registry.all_posts() |> Enum.map(& &1.id) + + assert "a1" in ids + assert "b1" in ids + end + + test "sorts merged posts newest first" do + [first, second] = Registry.all_posts() + + assert first.id == "b1" + assert second.id == "a1" + end + end + + describe "all_tags/0" do + test "merges and deduplicates tags from all blogs" do + tags = Registry.all_tags() + + assert "elixir" in tags + assert "devops" in tags + assert length(tags) == length(Enum.uniq(tags)) + end + end + + describe "blogs_map/0" do + test "returns map keyed by blog_id" do + map = Registry.blogs_map() + + assert map[:alpha] == AlphaBlog + assert map[:beta] == BetaBlog + end + end +end diff --git a/blogex/test/blogex/router_test.exs b/blogex/test/blogex/router_test.exs new file mode 100644 index 0000000..221eaa2 --- /dev/null +++ b/blogex/test/blogex/router_test.exs @@ -0,0 +1,123 @@ +defmodule Blogex.RouterTest do + use ExUnit.Case + use Plug.Test + + import Blogex.Test.PostBuilder + alias Blogex.Test.FakeBlog + + setup do + posts = [ + build(id: "first-post", title: "First", tags: ["elixir"], date: ~D[2026-03-10]), + build(id: "second-post", title: "Second", tags: ["otp"], date: ~D[2026-02-01]), + build(id: "draft", published: false, date: ~D[2026-03-12]) + ] + + {:ok, _} = FakeBlog.start(posts, + blog_id: :test, + title: "Test Blog", + description: "Test", + base_path: "/blog/test" + ) + + :ok + end + + defp call(method, path) do + conn(method, path) + |> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog)) + end + + describe "GET /feed.xml" do + test "returns RSS XML" do + conn = call(:get, "/feed.xml") + + assert conn.status == 200 + assert get_content_type(conn) =~ "application/rss+xml" + assert conn.resp_body =~ " Plug.Conn.get_resp_header("content-type") + |> List.first("") + end +end diff --git a/blogex/test/blogex/seo_test.exs b/blogex/test/blogex/seo_test.exs new file mode 100644 index 0000000..f841950 --- /dev/null +++ b/blogex/test/blogex/seo_test.exs @@ -0,0 +1,75 @@ +defmodule Blogex.SEOTest do + use ExUnit.Case, async: true + + import Blogex.Test.PostBuilder + alias Blogex.SEO + + defmodule StubBlog do + def base_path, do: "/blog/eng" + end + + @base_url "https://example.com" + + describe "meta_tags/3" do + test "includes post title and description" do + post = build(title: "My Title", description: "My Description") + + meta = SEO.meta_tags(post, @base_url, StubBlog) + + assert meta.title == "My Title" + assert meta.description == "My Description" + end + + test "builds canonical URL from base_url and post id" do + post = build(id: "hello-world") + + meta = SEO.meta_tags(post, @base_url, StubBlog) + + assert meta.og_url == "https://example.com/blog/eng/hello-world" + end + + test "includes OpenGraph article metadata" do + post = build(author: "Alice", date: ~D[2026-06-15], tags: ["a", "b"]) + + meta = SEO.meta_tags(post, @base_url, StubBlog) + + assert meta.og_type == "article" + assert meta.article_author == "Alice" + assert meta.article_published_time == "2026-06-15" + assert meta.article_tags == ["a", "b"] + end + end + + describe "sitemap/2" do + defmodule BlogA do + def base_path, do: "/blog/a" + def all_posts, do: [Blogex.Test.PostBuilder.build(id: "post-a", date: ~D[2026-01-01])] + end + + defmodule BlogB do + def base_path, do: "/blog/b" + def all_posts, do: [Blogex.Test.PostBuilder.build(id: "post-b", date: ~D[2026-02-01])] + end + + test "includes URLs from all blog modules" do + xml = SEO.sitemap([BlogA, BlogB], @base_url) + + assert xml =~ "https://example.com/blog/a/post-a" + assert xml =~ "https://example.com/blog/b/post-b" + end + + test "produces valid sitemap XML" do + xml = SEO.sitemap([BlogA], @base_url) + + assert xml =~ ~s() + assert xml =~ "" + end + + test "includes lastmod from post date" do + xml = SEO.sitemap([BlogA], @base_url) + + assert xml =~ "2026-01-01" + end + end +end diff --git a/blogex/test/support/fake_blog.ex b/blogex/test/support/fake_blog.ex new file mode 100644 index 0000000..918588e --- /dev/null +++ b/blogex/test/support/fake_blog.ex @@ -0,0 +1,100 @@ +defmodule Blogex.Test.FakeBlog do + @moduledoc """ + A test double that implements the same interface as a `use Blogex.Blog` + module, but backed by an Agent so tests can control the post data. + + ## Usage in tests + + setup do + posts = [PostBuilder.build(id: "hello"), PostBuilder.build(id: "world")] + Blogex.Test.FakeBlog.start(posts, blog_id: :engineering, title: "Eng Blog") + :ok + end + + Then pass `Blogex.Test.FakeBlog` anywhere a blog module is expected. + """ + + use Agent + + @defaults [ + blog_id: :test_blog, + title: "Test Blog", + description: "A blog for tests", + base_path: "/blog/test" + ] + + def start(posts \\ [], opts \\ []) do + opts = Keyword.merge(@defaults, opts) + + state = %{ + posts: posts, + blog_id: opts[:blog_id], + title: opts[:title], + description: opts[:description], + base_path: opts[:base_path] + } + + case Agent.start(fn -> state end, name: __MODULE__) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> + Agent.update(__MODULE__, fn _ -> state end) + {:ok, pid} + end + end + + def stop, do: Agent.stop(__MODULE__) + + defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key)) + + def blog_id, do: get(:blog_id) + def title, do: get(:title) + def description, do: get(:description) + def base_path, do: get(:base_path) + + def all_posts do + get(:posts) + |> Enum.filter(& &1.published) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def recent_posts(n \\ 5), do: Enum.take(all_posts(), n) + + def all_tags do + all_posts() + |> Enum.flat_map(& &1.tags) + |> Enum.uniq() + |> Enum.sort() + end + + def posts_by_tag(tag) do + Enum.filter(all_posts(), fn post -> tag in post.tags end) + end + + def get_post!(id) do + Enum.find(all_posts(), &(&1.id == id)) || + raise Blogex.NotFoundError, "post #{inspect(id)} not found" + end + + def get_post(id) do + Enum.find(all_posts(), &(&1.id == id)) + end + + def paginate(page \\ 1, per_page \\ 10) do + posts = all_posts() + total = length(posts) + total_pages = max(ceil(total / per_page), 1) + + entries = + posts + |> Enum.drop((page - 1) * per_page) + |> Enum.take(per_page) + + %{ + entries: entries, + page: page, + per_page: per_page, + total_entries: total, + total_pages: total_pages + } + end +end diff --git a/blogex/test/support/post_builder.ex b/blogex/test/support/post_builder.ex new file mode 100644 index 0000000..b82f618 --- /dev/null +++ b/blogex/test/support/post_builder.ex @@ -0,0 +1,48 @@ +defmodule Blogex.Test.PostBuilder do + @moduledoc """ + Builds `%Blogex.Post{}` structs for tests. + + Call `build/0` for a post with sensible defaults, or `build/1` + with a keyword list to override only the fields that matter for + your specific test. + + build() # generic post + build(title: "Specific Title") # override one field + build(tags: ~w(elixir otp), blog: :eng) # override several + build(published: false) # draft post + """ + + @defaults %{ + id: "a-blog-post", + title: "A Blog Post", + author: "Test Author", + body: "

Post body.

", + description: "A test post", + date: ~D[2026-01-15], + tags: ["general"], + blog: :test_blog, + published: true + } + + @doc "Build a post with defaults, merging any overrides." + def build(overrides \\ []) do + attrs = Map.merge(@defaults, Map.new(overrides)) + struct!(Blogex.Post, attrs) + end + + @doc "Build a list of n posts with sequential dates (newest first)." + def build_many(n, overrides \\ []) do + Enum.map(1..n, fn i -> + build( + Keyword.merge( + [ + id: "post-#{i}", + title: "Post #{i}", + date: Date.add(~D[2026-01-01], n - i) + ], + overrides + ) + ) + end) + end +end diff --git a/blogex/test/support/setup.ex b/blogex/test/support/setup.ex new file mode 100644 index 0000000..83903dd --- /dev/null +++ b/blogex/test/support/setup.ex @@ -0,0 +1,50 @@ +defmodule Blogex.Test.Setup do + @moduledoc """ + Reusable setup blocks for blog tests. + """ + + import Blogex.Test.PostBuilder + + @doc """ + Starts a FakeBlog with a standard set of posts. + Returns `%{blog: module, posts: posts}`. + """ + def with_blog(context \\ %{}, opts \\ []) do + posts = Keyword.get(opts, :posts, default_posts()) + blog_opts = Keyword.drop(opts, [:posts]) + + {:ok, _} = Blogex.Test.FakeBlog.start(posts, blog_opts) + + Map.merge(context, %{blog: Blogex.Test.FakeBlog, posts: posts}) + end + + @doc "A small set of posts covering common scenarios." + def default_posts do + [ + build( + id: "newest-post", + date: ~D[2026-03-10], + tags: ["elixir", "otp"], + published: true + ), + build( + id: "middle-post", + date: ~D[2026-02-15], + tags: ["elixir", "testing"], + published: true + ), + build( + id: "oldest-post", + date: ~D[2026-01-05], + tags: ["devops"], + published: true + ), + build( + id: "draft-post", + date: ~D[2026-03-12], + tags: ["elixir"], + published: false + ) + ] + end +end diff --git a/blogex/test/test_helper.exs b/blogex/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/blogex/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..fac7861 --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +elixir = "latest" +erlang = "latest" +node = "latest"