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.
This commit is contained in:
Your Name 2026-03-17 11:17:21 +00:00
commit bc14696f57
94 changed files with 6846 additions and 0 deletions

6
app/.formatter.exs Normal file
View File

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

37
app/.gitignore vendored Normal file
View File

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

334
app/AGENTS.md Normal file
View File

@ -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 `<Layouts.app flash={@flash} ...>` 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 `<Layouts.app>`
- **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 <script>custom js</script> 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
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## 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:elixir-end -->
<!-- phoenix:phoenix-start -->
## 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
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## 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:ecto-end -->
<!-- phoenix:html-start -->
## 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 `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
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**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
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 `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> 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:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## 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 `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @stream.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->

18
app/README.md Normal file
View File

@ -0,0 +1,18 @@
# Firehose
To start your Phoenix server:
* Run `mix setup` to install and setup dependencies
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## Learn more
* Official website: https://www.phoenixframework.org/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix

205
app/assets/css/app.css Normal file
View File

@ -0,0 +1,205 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/firehose_web";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
@plugin "../vendor/daisyui" {
themes: false;
}
/* Warm light theme — amber/terracotta + cream */
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(97% 0.008 75);
--color-base-200: oklch(94% 0.012 75);
--color-base-300: oklch(90% 0.016 75);
--color-base-content: oklch(25% 0.015 50);
--color-primary: oklch(58% 0.16 45);
--color-primary-content: oklch(98% 0.01 75);
--color-secondary: oklch(55% 0.12 30);
--color-secondary-content: oklch(98% 0.008 30);
--color-accent: oklch(62% 0.19 55);
--color-accent-content: oklch(98% 0.01 55);
--color-neutral: oklch(40% 0.02 50);
--color-neutral-content: oklch(97% 0.005 75);
--color-info: oklch(62% 0.17 245);
--color-info-content: oklch(97% 0.01 245);
--color-success: oklch(65% 0.15 155);
--color-success-content: oklch(98% 0.01 155);
--color-warning: oklch(72% 0.16 70);
--color-warning-content: oklch(25% 0.02 70);
--color-error: oklch(58% 0.22 25);
--color-error-content: oklch(97% 0.01 25);
--radius-selector: 0.375rem;
--radius-field: 0.375rem;
--radius-box: 0.75rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* Warm dark theme */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(24% 0.015 50);
--color-base-200: oklch(20% 0.012 50);
--color-base-300: oklch(16% 0.01 50);
--color-base-content: oklch(92% 0.015 75);
--color-primary: oklch(68% 0.16 45);
--color-primary-content: oklch(18% 0.02 45);
--color-secondary: oklch(62% 0.12 30);
--color-secondary-content: oklch(18% 0.01 30);
--color-accent: oklch(70% 0.17 55);
--color-accent-content: oklch(18% 0.01 55);
--color-neutral: oklch(32% 0.02 50);
--color-neutral-content: oklch(92% 0.008 75);
--color-info: oklch(62% 0.17 245);
--color-info-content: oklch(97% 0.01 245);
--color-success: oklch(65% 0.15 155);
--color-success-content: oklch(98% 0.01 155);
--color-warning: oklch(72% 0.16 70);
--color-warning-content: oklch(25% 0.02 70);
--color-error: oklch(58% 0.22 25);
--color-error-content: oklch(97% 0.01 25);
--radius-selector: 0.375rem;
--radius-field: 0.375rem;
--radius-box: 0.75rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* Typography */
.font-display { font-family: 'Fraunces', serif; }
body { font-family: 'Source Sans 3', sans-serif; }
/* Blogex component styling */
.blogex-post-index { display: flex; flex-direction: column; gap: 1.5rem; }
.blogex-post-preview header h2 {
font-family: 'Fraunces', serif;
font-size: 1.25rem;
font-weight: 600;
}
.blogex-post-preview header h2 a {
color: oklch(var(--color-base-content));
text-decoration: none;
}
.blogex-post-preview header h2 a:hover {
color: oklch(var(--color-primary));
}
.blogex-post-description {
margin-top: 0.25rem;
opacity: 0.7;
}
.blogex-post-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
opacity: 0.6;
margin-top: 0.25rem;
}
.blogex-tag {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
background: oklch(var(--color-base-200));
}
.blogex-post-header h1 {
font-family: 'Fraunces', serif;
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
}
.blogex-post-body {
margin-top: 2rem;
line-height: 1.75;
}
.blogex-post-body h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; }
.blogex-post-body h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
.blogex-post-body p { margin-top: 1rem; }
.blogex-post-body ul { list-style: disc; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body ol { list-style: decimal; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
.blogex-post-body code { font-size: 0.875em; }
.blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; }
.blogex-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.blogex-tag-link {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
background: oklch(var(--color-base-200));
text-decoration: none;
}
.blogex-tag-link:hover, .blogex-tag-active {
background: oklch(var(--color-primary));
color: oklch(var(--color-primary-content));
}
.blogex-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding-top: 1rem;
font-size: 0.875rem;
}
.blogex-pagination a {
color: oklch(var(--color-primary));
text-decoration: none;
}
.blogex-pagination a:hover {
text-decoration: underline;
}

83
app/assets/js/app.js Normal file
View File

@ -0,0 +1,83 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/firehose"
import topbar from "../vendor/topbar"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks},
})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", e => keyDown = null)
window.addEventListener("click", e => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}

32
app/assets/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}

124
app/assets/vendor/daisyui-theme.js vendored Normal file

File diff suppressed because one or more lines are too long

1031
app/assets/vendor/daisyui.js vendored Normal file

File diff suppressed because one or more lines are too long

43
app/assets/vendor/heroicons.js vendored Normal file
View File

@ -0,0 +1,43 @@
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
content = encodeURIComponent(content)
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})

138
app/assets/vendor/topbar.js vendored Normal file
View File

@ -0,0 +1,138 @@
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/
(function (window, document) {
"use strict";
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
if (!canvas.parentElement) document.body.appendChild(canvas);
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

68
app/config/config.exs Normal file
View File

@ -0,0 +1,68 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :firehose,
ecto_repos: [Firehose.Repo],
generators: [timestamp_type: :utc_datetime]
# Configures the endpoint
config :firehose, FirehoseWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: FirehoseWeb.ErrorHTML, json: FirehoseWeb.ErrorJSON],
layout: false
],
pubsub_server: Firehose.PubSub,
live_view: [signing_salt: "dXdos+ah"]
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :firehose, Firehose.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
firehose: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.1.7",
firehose: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__)
]
# Configures Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
config :blogex,
blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

89
app/config/dev.exs Normal file
View File

@ -0,0 +1,89 @@
import Config
# Configure your database
config :firehose, Firehose.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "firehose_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :firehose, FirehoseWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4050")],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "y49URRe0FjLWWZhUHJOpdQZZRmfioCAp2l2GPVJUiKBXXgc6HIEyDgzfq/bxUQZe",
watchers: [
esbuild: {Esbuild, :install_and_run, [:firehose, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:firehose, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Watch static and templates for browser reloading.
config :firehose, FirehoseWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/firehose_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$",
~r"priv/blog/.*(md)$"
]
]
# Enable dev routes for dashboard and mailbox
config :firehose, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include debug annotations and locations in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

20
app/config/prod.exs Normal file
View File

@ -0,0 +1,20 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :firehose, FirehoseWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

119
app/config/runtime.exs Normal file
View File

@ -0,0 +1,119 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/firehose start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :firehose, FirehoseWeb.Endpoint, server: true
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :firehose, Firehose.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :firehose, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :firehose, FirehoseWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :firehose, FirehoseWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :firehose, FirehoseWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :firehose, Firehose.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end

37
app/config/test.exs Normal file
View File

@ -0,0 +1,37 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :firehose, Firehose.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "firehose_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :firehose, FirehoseWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "nf3edR3OFIZzs4KVcehq5IkIq/HzI3MolPjww4WE78Kssqxvz6eOKEAsdgoCgXWc",
server: false
# In test we don't send emails
config :firehose, Firehose.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true

9
app/lib/firehose.ex Normal file
View File

@ -0,0 +1,9 @@
defmodule Firehose do
@moduledoc """
Firehose keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@ -0,0 +1,34 @@
defmodule Firehose.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
FirehoseWeb.Telemetry,
Firehose.Repo,
{DNSCluster, query: Application.get_env(:firehose, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Firehose.PubSub},
# Start a worker by calling: Firehose.Worker.start_link(arg)
# {Firehose.Worker, arg},
# Start to serve requests, typically the last entry
FirehoseWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Firehose.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
FirehoseWeb.Endpoint.config_change(changed, removed)
:ok
end
end

View File

@ -0,0 +1,9 @@
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

View File

@ -0,0 +1,9 @@
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

View File

@ -0,0 +1,3 @@
defmodule Firehose.Mailer do
use Swoosh.Mailer, otp_app: :firehose
end

5
app/lib/firehose/repo.ex Normal file
View File

@ -0,0 +1,5 @@
defmodule Firehose.Repo do
use Ecto.Repo,
otp_app: :firehose,
adapter: Ecto.Adapters.Postgres
end

114
app/lib/firehose_web.ex Normal file
View File

@ -0,0 +1,114 @@
defmodule FirehoseWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use FirehoseWeb, :controller
use FirehoseWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico favicon.svg robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
use Gettext, backend: FirehoseWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: FirehoseWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import FirehoseWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias FirehoseWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: FirehoseWeb.Endpoint,
router: FirehoseWeb.Router,
statics: FirehoseWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@ -0,0 +1,472 @@
defmodule FirehoseWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as tables, forms, and
inputs. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
and themes. Here are useful references:
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
started and see the available components.
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
we build on. You will use it for layout, sizing, flexbox, grid, and
spacing.
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
the component system used by Phoenix. Some components, such as `<.link>`
and `<.form>`, are defined there.
"""
use Phoenix.Component
use Gettext, backend: FirehoseWeb.Gettext
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc """
Renders a button with navigation support.
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :string
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as hidden and radio,
are best written directly in your templates.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :string, default: nil, doc: "the input class to use over defaults"
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a header with title.
"""
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(FirehoseWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(FirehoseWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

View File

@ -0,0 +1,93 @@
defmodule FirehoseWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use FirehoseWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end

View File

@ -0,0 +1,33 @@
<header class="navbar px-4 sm:px-6 lg:px-8 border-b border-base-200">
<div class="flex-1">
<a href="/" class="font-display text-xl font-semibold tracking-tight text-primary hover:opacity-80 transition">
firehose
</a>
</div>
<div class="flex-none">
<ul class="flex flex-row px-1 space-x-2 sm:space-x-4 items-center">
<li>
<a href="/blog/engineering" class="btn btn-ghost btn-sm">Engineering</a>
</li>
<li>
<a href="/blog/releases" class="btn btn-ghost btn-sm">Releases</a>
</li>
<li>
<a href="https://qwan.eu" class="btn btn-ghost btn-sm" target="_blank" rel="noopener">
QWAN
</a>
</li>
<li>
<.theme_toggle />
</li>
</ul>
</div>
</header>
<main class="px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{@inner_content}
</div>
</main>
<.flash_group flash={@flash} />

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<.live_title default="Firehose" suffix=" — Willem van den Ende">
{assigns[:page_title]}
</.live_title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Source+Sans+3:ital,wght@0,300..900;1,300..900&display=swap" rel="stylesheet" />
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body>
{@inner_content}
</body>
</html>

View File

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

View File

@ -0,0 +1,7 @@
defmodule FirehoseWeb.BlogHTML do
use FirehoseWeb, :html
import Blogex.Components
embed_templates "blog_html/*"
end

View File

@ -0,0 +1,9 @@
<div class="space-y-8">
<header>
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
<p :if={@blog_description} class="mt-2 text-base-content/70">{@blog_description}</p>
</header>
<.post_index posts={@posts} base_path={@base_path} />
<.pagination page={@page} total_pages={@total_pages} base_path={@base_path} />
</div>

View File

@ -0,0 +1,4 @@
<div class="space-y-8">
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a>
<.post_show post={@post} />
</div>

View File

@ -0,0 +1,10 @@
<div class="space-y-8">
<header>
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
<p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p>
</header>
<.post_index posts={@posts} base_path={@base_path} />
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; All posts</a>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
<div class="space-y-12">
<section class="space-y-6">
<h1 class="text-4xl sm:text-5xl font-display font-semibold leading-tight tracking-tight text-balance">
Drinking from the firehose
</h1>
<div class="space-y-4 text-lg leading-relaxed text-base-content/80">
<p>
I'm <strong class="text-base-content">Willem van den Ende</strong>,
partner at <a href="https://qwan.eu" class="text-primary hover:underline" target="_blank" rel="noopener">QWAN</a>.
This is where I write about AI-native consulting, shitty evals,
and whatever prototype I'm building this week.
</p>
</div>
</section>
<section class="space-y-6">
<h2 class="text-2xl font-display font-semibold">Recent posts</h2>
<div class="grid gap-4 sm:grid-cols-2">
<article
:for={post <- @recent_posts}
class="rounded-box border border-base-200 p-5 space-y-2 hover:border-primary/30 transition"
>
<a href={"#{post_base_path(post)}/#{post.id}"} class="block space-y-2">
<h3 class="font-semibold text-base-content hover:text-primary transition">{post.title}</h3>
<p class="text-sm text-base-content/60">{post.description}</p>
<div class="flex items-center gap-2 text-xs text-base-content/50">
<time datetime={Date.to_iso8601(post.date)}>
{Calendar.strftime(post.date, "%B %d, %Y")}
</time>
<span class="badge badge-ghost badge-xs">{blog_label(post)}</span>
</div>
</a>
</article>
</div>
</section>
</div>

View File

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

View File

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

View File

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

View File

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

95
app/mix.exs Normal file
View File

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

52
app/mix.lock Normal file
View File

@ -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"},
}

View File

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

View File

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

View File

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

109
app/priv/gettext/errors.pot Normal file
View File

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

View File

@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

11
app/priv/repo/seeds.exs Normal file
View File

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

BIN
app/priv/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
@media (prefers-color-scheme: dark) {
.flame { fill: #e8a87c; }
.drop { fill: #f4c096; }
}
</style>
<circle cx="16" cy="16" r="15" fill="#c2593a" rx="3"/>
<path class="flame" fill="#f4c096" d="M16 5c0 0-7 8-7 14a7 7 0 0 0 14 0c0-6-7-14-7-14zm0 18a4 4 0 0 1-4-4c0-3 4-8 4-8s4 5 4 8a4 4 0 0 1-4 4z"/>
<path class="drop" fill="#fff4e6" d="M16 13s-2.5 3.5-2.5 5.5a2.5 2.5 0 0 0 5 0c0-2-2.5-5.5-2.5-5.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -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: /

View File

@ -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) =~ "<rss"
end
end
describe "release notes blog (JSON API)" do
test "GET /api/blog/releases returns post index", %{conn: conn} do
conn = get(conn, "/api/blog/releases")
assert %{"blog" => "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) =~ "<rss"
end
end
end

View File

@ -0,0 +1,14 @@
defmodule FirehoseWeb.ErrorHTMLTest do
use FirehoseWeb.ConnCase, async: true
# Bring render_to_string/4 for testing custom views
import Phoenix.Template, only: [render_to_string: 4]
test "renders 404.html" do
assert render_to_string(FirehoseWeb.ErrorHTML, "404", "html", []) == "Not Found"
end
test "renders 500.html" do
assert render_to_string(FirehoseWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
end
end

View File

@ -0,0 +1,12 @@
defmodule FirehoseWeb.ErrorJSONTest do
use FirehoseWeb.ConnCase, async: true
test "renders 404" do
assert FirehoseWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end
test "renders 500" do
assert FirehoseWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end

View File

@ -0,0 +1,10 @@
defmodule FirehoseWeb.PageControllerTest do
use FirehoseWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
body = html_response(conn, 200)
assert body =~ "Drinking from the firehose"
assert body =~ "Willem van den Ende"
end
end

View File

@ -0,0 +1,38 @@
defmodule FirehoseWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use FirehoseWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint FirehoseWeb.Endpoint
use FirehoseWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import FirehoseWeb.ConnCase
end
end
setup tags do
Firehose.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

View File

@ -0,0 +1,58 @@
defmodule Firehose.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use Firehose.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias Firehose.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import Firehose.DataCase
end
end
setup tags do
Firehose.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Firehose.Repo, shared: not tags[:async])
on_exit(fn -> 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

2
app/test/test_helper.exs Normal file
View File

@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Firehose.Repo, :manual)

5
blogex/.formatter.exs Normal file
View File

@ -0,0 +1,5 @@
[
import_deps: [:phoenix],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

6
blogex/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/_build/
/deps/
/doc/
*.ez
*.beam
.elixir_ls/

217
blogex/README.md Normal file
View File

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

View File

@ -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"""
<div class="max-w-3xl mx-auto py-8 px-4">
<h1 class="text-3xl font-bold mb-2">{@blog.title()}</h1>
<p class="text-gray-600 mb-8">{@blog.description()}</p>
<.tag_list tags={@tags} base_path={@base_path} current_tag={@current_tag} />
<div class="mt-8">
<.post_index posts={@posts} base_path={@base_path} />
</div>
<.pagination page={@page} total_pages={@total_pages} base_path={@base_path} />
<div class="mt-8 text-sm text-gray-500">
<a href={"#{@base_path}/feed.xml"} class="hover:underline">RSS Feed</a>
·
<a href={"#{@base_path}/atom.xml"} class="hover:underline">Atom Feed</a>
</div>
</div>
"""
end
end

View File

@ -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"""
<div class="max-w-3xl mx-auto py-8 px-4">
<nav class="mb-8">
<a href={@base_path} class="text-blue-600 hover:underline">
&larr; Back to {@blog.title()}
</a>
</nav>
<.post_show post={@post} />
<footer class="mt-12 pt-8 border-t border-gray-200">
<h3 class="text-lg font-semibold mb-4">Tags</h3>
<.tag_list tags={@post.tags} base_path={@base_path} />
</footer>
</div>
"""
end
end

View File

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

View File

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

118
blogex/lib/blogex.ex Normal file
View File

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

122
blogex/lib/blogex/blog.ex Normal file
View File

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

View File

@ -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"""
<div class="blogex-post-index">
<article :for={post <- @posts} class="blogex-post-preview">
<header>
<h2>
<a href={"#{@base_path}/#{post.id}"}>{post.title}</a>
</h2>
<.post_meta post={post} />
</header>
<p class="blogex-post-description">{post.description}</p>
</article>
</div>
"""
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"""
<article class="blogex-post">
<header class="blogex-post-header">
<h1>{@post.title}</h1>
<.post_meta post={@post} />
</header>
<div class="blogex-post-body">
{Phoenix.HTML.raw(@post.body)}
</div>
</article>
"""
end
@doc """
Renders post metadata (date, author, tags).
"""
attr :post, :map, required: true
def post_meta(assigns) do
~H"""
<div class="blogex-post-meta">
<time datetime={Date.to_iso8601(@post.date)}>
{Calendar.strftime(@post.date, "%B %d, %Y")}
</time>
<span :if={@post.author} class="blogex-post-author">
by {@post.author}
</span>
<span :for={tag <- @post.tags} class="blogex-tag">
{tag}
</span>
</div>
"""
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"""
<nav class="blogex-tag-list">
<a
:for={tag <- @tags}
href={"#{@base_path}/tag/#{tag}"}
class={["blogex-tag-link", tag == @current_tag && "blogex-tag-active"]}
>
{tag}
</a>
</nav>
"""
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"""
<nav :if={@total_pages > 1} class="blogex-pagination">
<a
:if={@page > 1}
href={"#{@base_path}?page=#{@page - 1}"}
class="blogex-pagination-prev"
>
&larr; Newer
</a>
<span class="blogex-pagination-info">
Page {@page} of {@total_pages}
</span>
<a
:if={@page < @total_pages}
href={"#{@base_path}?page=#{@page + 1}"}
class="blogex-pagination-next"
>
Older &rarr;
</a>
</nav>
"""
end
end

139
blogex/lib/blogex/feed.ex Normal file
View File

@ -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
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>#{escape(blog_module.title())}</title>
<link>#{blog_url}</link>
<description>#{escape(blog_module.description())}</description>
<language>#{language}</language>
<pubDate>#{pub_date}</pubDate>
<atom:link href="#{feed_url}" rel="self" type="application/rss+xml"/>
#{Enum.map_join(posts, "\n", &item_xml(&1, base_url, blog_module))}
</channel>
</rss>
"""
|> 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
"""
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>#{escape(blog_module.title())}</title>
<link href="#{blog_url}" rel="alternate"/>
<link href="#{feed_url}" rel="self"/>
<id>#{blog_url}</id>
<updated>#{updated}</updated>
#{Enum.map_join(posts, "\n", &entry_xml(&1, base_url, blog_module))}
</feed>
"""
|> String.trim()
end
# Private helpers
defp item_xml(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
"""
<item>
<title>#{escape(post.title)}</title>
<link>#{url}</link>
<guid isPermaLink="true">#{url}</guid>
<pubDate>#{format_rfc822(post.date)}</pubDate>
<description>#{escape(post.description)}</description>
<content:encoded><![CDATA[#{post.body}]]></content:encoded>
#{Enum.map_join(post.tags, "\n", &" <category>#{escape(&1)}</category>")}
</item>
"""
end
defp entry_xml(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
"""
<entry>
<title>#{escape(post.title)}</title>
<link href="#{url}" rel="alternate"/>
<id>#{url}</id>
<published>#{format_iso8601(post.date)}</published>
<updated>#{format_iso8601(post.date)}</updated>
<author><name>#{escape(post.author)}</name></author>
<summary>#{escape(post.description)}</summary>
<content type="html"><![CDATA[#{post.body}]]></content>
#{Enum.map_join(post.tags, "\n", &" <category term=\"#{escape(&1)}\"/>")}
</entry>
"""
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("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
end

View File

@ -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"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
{Phoenix.HTML.raw(@inner_content)}
</body>
</html>
"""
end
@doc "Renders the post index page."
def index_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@blog_title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<h1>{@blog_title}</h1>
<p>{@blog_description}</p>
<.post_index posts={@posts} base_path={@base_path} />
<.pagination page={@page} total_pages={@total_pages} base_path={@base_path} />
</body>
</html>
"""
end
@doc "Renders a single post page."
def show_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@post.title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav>
<.post_show post={@post} />
</body>
</html>
"""
end
@doc "Renders a tag listing page."
def tag_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@blog_title} #{@tag}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav>
<h1>Posts tagged "{@tag}"</h1>
<.post_index posts={@posts} base_path={@base_path} />
</body>
</html>
"""
end
end

View File

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

68
blogex/lib/blogex/post.ex Normal file
View File

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

View File

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

194
blogex/lib/blogex/router.ex Normal file
View File

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

62
blogex/lib/blogex/seo.ex Normal file
View File

@ -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.
<meta property="og:title" content={@meta.og_title} />
"""
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>
<loc>#{url}</loc>
<lastmod>#{lastmod}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
"""
end)
end)
|> Enum.join()
"""
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}</urlset>
"""
|> String.trim()
end
end

58
blogex/mix.exs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(<?xml version="1.0")
assert xml =~ ~s(<rss version="2.0")
assert xml =~ ~s(</rss>)
end
test "includes blog title and description", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<title>Eng Blog</title>"
assert xml =~ "<description>Tech articles</description>"
end
test "includes post entries with correct links", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<link>https://example.com/blog/eng/newest-post</link>"
end
test "wraps post body in CDATA", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<content:encoded><![CDATA["
end
test "includes post tags as categories", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<category>elixir</category>"
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("<item>") |> 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(<feed xmlns="http://www.w3.org/2005/Atom">)
assert xml =~ ~s(</feed>)
end
test "includes post entries", %{blog: blog} do
xml = Feed.atom(blog, @base_url)
assert xml =~ "<entry>"
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("<entry>") |> 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 <Baz>")],
title: "A & B"
)
xml = Feed.rss(FakeBlog, @base_url)
assert xml =~ "Foo &amp; Bar &lt;Baz&gt;"
assert xml =~ "<title>A &amp; B</title>"
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 =~ "<channel>"
refute xml =~ "<item>"
end
test "atom produces valid XML with no entries" do
{:ok, _} = FakeBlog.start([])
xml = Feed.atom(FakeBlog, @base_url)
assert xml =~ "<feed"
refute xml =~ "<entry>"
end
end
end

View File

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

View File

@ -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(), "<p>body</p>")
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(), "<p>body</p>")
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(), "<p>x</p>")
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, "<p>x</p>")
assert post.title == "Custom Title"
assert post.author == "Specific Author"
assert post.tags == ["alpha", "beta"]
end
test "stores rendered HTML body" do
html = "<h1>Hello</h1><p>World</p>"
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(), "<p>x</p>")
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, "<p>x</p>")
assert post.published == false
end
end
defp valid_attrs do
%{
title: "Title",
author: "Author",
description: "Desc",
tags: ["tag"]
}
end
end

View File

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

View File

@ -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 =~ "<rss version=\"2.0\""
end
test "includes published posts" do
conn = call(:get, "/feed.xml")
assert conn.resp_body =~ "first-post"
refute conn.resp_body =~ "draft"
end
end
describe "GET /atom.xml" do
test "returns Atom XML" do
conn = call(:get, "/atom.xml")
assert conn.status == 200
assert get_content_type(conn) =~ "application/atom+xml"
assert conn.resp_body =~ "<feed xmlns="
end
end
describe "GET /:slug" do
test "returns post as JSON" do
conn = call(:get, "/first-post")
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["id"] == "first-post"
assert body["title"] == "First"
end
test "returns 404 for unknown slug" do
conn = call(:get, "/nonexistent")
assert conn.status == 404
end
test "returns 404 for draft post" do
conn = call(:get, "/draft")
assert conn.status == 404
end
end
describe "GET /tag/:tag" do
test "returns posts matching tag" do
conn = call(:get, "/tag/elixir")
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["tag"] == "elixir"
assert length(body["posts"]) == 1
assert hd(body["posts"])["id"] == "first-post"
end
test "returns empty list for unknown tag" do
conn = call(:get, "/tag/unknown")
body = Jason.decode!(conn.resp_body)
assert body["posts"] == []
end
end
describe "GET /" do
test "returns paginated post list" do
conn = call(:get, "/")
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert is_list(body["posts"])
assert body["total_entries"] == 2
end
test "excludes draft posts from listing" do
conn = call(:get, "/")
body = Jason.decode!(conn.resp_body)
ids = Enum.map(body["posts"], & &1["id"])
refute "draft" in ids
end
end
defp get_content_type(conn) do
conn
|> Plug.Conn.get_resp_header("content-type")
|> List.first("")
end
end

View File

@ -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 =~ "<loc>https://example.com/blog/a/post-a</loc>"
assert xml =~ "<loc>https://example.com/blog/b/post-b</loc>"
end
test "produces valid sitemap XML" do
xml = SEO.sitemap([BlogA], @base_url)
assert xml =~ ~s(<?xml version="1.0")
assert xml =~ ~s(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
assert xml =~ "</urlset>"
end
test "includes lastmod from post date" do
xml = SEO.sitemap([BlogA], @base_url)
assert xml =~ "<lastmod>2026-01-01</lastmod>"
end
end
end

View File

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

View File

@ -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: "<p>Post body.</p>",
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

View File

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

View File

@ -0,0 +1 @@
ExUnit.start()

4
mise.toml Normal file
View File

@ -0,0 +1,4 @@
[tools]
elixir = "latest"
erlang = "latest"
node = "latest"