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:
commit
bc14696f57
6
app/.formatter.exs
Normal file
6
app/.formatter.exs
Normal 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
37
app/.gitignore
vendored
Normal 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
334
app/AGENTS.md
Normal 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
18
app/README.md
Normal 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
205
app/assets/css/app.css
Normal 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
83
app/assets/js/app.js
Normal 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
32
app/assets/tsconfig.json
Normal 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
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
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
43
app/assets/vendor/heroicons.js
vendored
Normal 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
138
app/assets/vendor/topbar.js
vendored
Normal 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
68
app/config/config.exs
Normal 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
89
app/config/dev.exs
Normal 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
20
app/config/prod.exs
Normal 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
119
app/config/runtime.exs
Normal 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
37
app/config/test.exs
Normal 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
9
app/lib/firehose.ex
Normal 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
|
||||
34
app/lib/firehose/application.ex
Normal file
34
app/lib/firehose/application.ex
Normal 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
|
||||
9
app/lib/firehose/blogs/engineering_blog.ex
Normal file
9
app/lib/firehose/blogs/engineering_blog.ex
Normal 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
|
||||
9
app/lib/firehose/blogs/release_notes.ex
Normal file
9
app/lib/firehose/blogs/release_notes.ex
Normal 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
|
||||
3
app/lib/firehose/mailer.ex
Normal file
3
app/lib/firehose/mailer.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule Firehose.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :firehose
|
||||
end
|
||||
5
app/lib/firehose/repo.ex
Normal file
5
app/lib/firehose/repo.ex
Normal 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
114
app/lib/firehose_web.ex
Normal 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
|
||||
472
app/lib/firehose_web/components/core_components.ex
Normal file
472
app/lib/firehose_web/components/core_components.ex
Normal 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
|
||||
93
app/lib/firehose_web/components/layouts.ex
Normal file
93
app/lib/firehose_web/components/layouts.ex
Normal 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
|
||||
33
app/lib/firehose_web/components/layouts/app.html.heex
Normal file
33
app/lib/firehose_web/components/layouts/app.html.heex
Normal 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} />
|
||||
41
app/lib/firehose_web/components/layouts/root.html.heex
Normal file
41
app/lib/firehose_web/components/layouts/root.html.heex
Normal 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>
|
||||
50
app/lib/firehose_web/controllers/blog_controller.ex
Normal file
50
app/lib/firehose_web/controllers/blog_controller.ex
Normal 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
|
||||
7
app/lib/firehose_web/controllers/blog_html.ex
Normal file
7
app/lib/firehose_web/controllers/blog_html.ex
Normal file
@ -0,0 +1,7 @@
|
||||
defmodule FirehoseWeb.BlogHTML do
|
||||
use FirehoseWeb, :html
|
||||
|
||||
import Blogex.Components
|
||||
|
||||
embed_templates "blog_html/*"
|
||||
end
|
||||
@ -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>
|
||||
@ -0,0 +1,4 @@
|
||||
<div class="space-y-8">
|
||||
<a href={@base_path} class="text-sm text-primary hover:underline">← Back to posts</a>
|
||||
<.post_show post={@post} />
|
||||
</div>
|
||||
10
app/lib/firehose_web/controllers/blog_html/tag.html.heex
Normal file
10
app/lib/firehose_web/controllers/blog_html/tag.html.heex
Normal 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">← All posts</a>
|
||||
</div>
|
||||
24
app/lib/firehose_web/controllers/error_html.ex
Normal file
24
app/lib/firehose_web/controllers/error_html.ex
Normal 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
|
||||
21
app/lib/firehose_web/controllers/error_json.ex
Normal file
21
app/lib/firehose_web/controllers/error_json.ex
Normal 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
|
||||
13
app/lib/firehose_web/controllers/page_controller.ex
Normal file
13
app/lib/firehose_web/controllers/page_controller.ex
Normal 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
|
||||
18
app/lib/firehose_web/controllers/page_html.ex
Normal file
18
app/lib/firehose_web/controllers/page_html.ex
Normal 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
|
||||
36
app/lib/firehose_web/controllers/page_html/home.html.heex
Normal file
36
app/lib/firehose_web/controllers/page_html/home.html.heex
Normal 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>
|
||||
54
app/lib/firehose_web/endpoint.ex
Normal file
54
app/lib/firehose_web/endpoint.ex
Normal 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
|
||||
25
app/lib/firehose_web/gettext.ex
Normal file
25
app/lib/firehose_web/gettext.ex
Normal 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
|
||||
54
app/lib/firehose_web/router.ex
Normal file
54
app/lib/firehose_web/router.ex
Normal 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
|
||||
93
app/lib/firehose_web/telemetry.ex
Normal file
93
app/lib/firehose_web/telemetry.ex
Normal 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
95
app/mix.exs
Normal 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
52
app/mix.lock
Normal 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"},
|
||||
}
|
||||
8
app/priv/blog/engineering/2026/03-16-hello-world.md
Normal file
8
app/priv/blog/engineering/2026/03-16-hello-world.md
Normal 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.
|
||||
13
app/priv/blog/release-notes/2026/03-16-v0-1-0.md
Normal file
13
app/priv/blog/release-notes/2026/03-16-v0-1-0.md
Normal 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
|
||||
112
app/priv/gettext/en/LC_MESSAGES/errors.po
Normal file
112
app/priv/gettext/en/LC_MESSAGES/errors.po
Normal 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
109
app/priv/gettext/errors.pot
Normal 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 ""
|
||||
4
app/priv/repo/migrations/.formatter.exs
Normal file
4
app/priv/repo/migrations/.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
||||
[
|
||||
import_deps: [:ecto_sql],
|
||||
inputs: ["*.exs"]
|
||||
]
|
||||
11
app/priv/repo/seeds.exs
Normal file
11
app/priv/repo/seeds.exs
Normal 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
BIN
app/priv/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
11
app/priv/static/favicon.svg
Normal file
11
app/priv/static/favicon.svg
Normal 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 |
6
app/priv/static/images/logo.svg
Normal file
6
app/priv/static/images/logo.svg
Normal 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 |
5
app/priv/static/robots.txt
Normal file
5
app/priv/static/robots.txt
Normal 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: /
|
||||
88
app/test/firehose_web/controllers/blog_test.exs
Normal file
88
app/test/firehose_web/controllers/blog_test.exs
Normal 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
|
||||
14
app/test/firehose_web/controllers/error_html_test.exs
Normal file
14
app/test/firehose_web/controllers/error_html_test.exs
Normal 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
|
||||
12
app/test/firehose_web/controllers/error_json_test.exs
Normal file
12
app/test/firehose_web/controllers/error_json_test.exs
Normal 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
|
||||
10
app/test/firehose_web/controllers/page_controller_test.exs
Normal file
10
app/test/firehose_web/controllers/page_controller_test.exs
Normal 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
|
||||
38
app/test/support/conn_case.ex
Normal file
38
app/test/support/conn_case.ex
Normal 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
|
||||
58
app/test/support/data_case.ex
Normal file
58
app/test/support/data_case.ex
Normal 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
2
app/test/test_helper.exs
Normal file
@ -0,0 +1,2 @@
|
||||
ExUnit.start()
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Firehose.Repo, :manual)
|
||||
5
blogex/.formatter.exs
Normal file
5
blogex/.formatter.exs
Normal 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
6
blogex/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/_build/
|
||||
/deps/
|
||||
/doc/
|
||||
*.ez
|
||||
*.beam
|
||||
.elixir_ls/
|
||||
217
blogex/README.md
Normal file
217
blogex/README.md
Normal 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
|
||||
77
blogex/examples/blog_live_index.ex
Normal file
77
blogex/examples/blog_live_index.ex
Normal 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
|
||||
51
blogex/examples/blog_live_show.ex
Normal file
51
blogex/examples/blog_live_show.ex
Normal 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">
|
||||
← 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
|
||||
89
blogex/examples/router_example.ex
Normal file
89
blogex/examples/router_example.ex
Normal 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
|
||||
14
blogex/examples/sitemap_controller.ex
Normal file
14
blogex/examples/sitemap_controller.ex
Normal 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
118
blogex/lib/blogex.ex
Normal 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
122
blogex/lib/blogex/blog.ex
Normal 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
|
||||
153
blogex/lib/blogex/components.ex
Normal file
153
blogex/lib/blogex/components.ex
Normal 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"
|
||||
>
|
||||
← 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 →
|
||||
</a>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
end
|
||||
139
blogex/lib/blogex/feed.ex
Normal file
139
blogex/lib/blogex/feed.ex
Normal 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("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
|> String.replace("'", "'")
|
||||
end
|
||||
end
|
||||
91
blogex/lib/blogex/layout.ex
Normal file
91
blogex/lib/blogex/layout.ex
Normal 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}>← 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}>← Back</a></nav>
|
||||
<h1>Posts tagged "{@tag}"</h1>
|
||||
<.post_index posts={@posts} base_path={@base_path} />
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
end
|
||||
7
blogex/lib/blogex/not_found_error.ex
Normal file
7
blogex/lib/blogex/not_found_error.ex
Normal 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
68
blogex/lib/blogex/post.ex
Normal 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
|
||||
52
blogex/lib/blogex/registry.ex
Normal file
52
blogex/lib/blogex/registry.ex
Normal 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
194
blogex/lib/blogex/router.ex
Normal 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
62
blogex/lib/blogex/seo.ex
Normal 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
58
blogex/mix.exs
Normal 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
|
||||
@ -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.
|
||||
@ -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.
|
||||
33
blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md
Normal file
33
blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md
Normal 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.
|
||||
35
blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md
Normal file
35
blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md
Normal 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
|
||||
145
blogex/test/blogex/blog_test.exs
Normal file
145
blogex/test/blogex/blog_test.exs
Normal 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
|
||||
133
blogex/test/blogex/feed_test.exs
Normal file
133
blogex/test/blogex/feed_test.exs
Normal 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 & Bar <Baz>"
|
||||
assert xml =~ "<title>A & 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
|
||||
15
blogex/test/blogex/not_found_error_test.exs
Normal file
15
blogex/test/blogex/not_found_error_test.exs
Normal 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
|
||||
71
blogex/test/blogex/post_test.exs
Normal file
71
blogex/test/blogex/post_test.exs
Normal 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
|
||||
88
blogex/test/blogex/registry_test.exs
Normal file
88
blogex/test/blogex/registry_test.exs
Normal 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
|
||||
123
blogex/test/blogex/router_test.exs
Normal file
123
blogex/test/blogex/router_test.exs
Normal 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
|
||||
75
blogex/test/blogex/seo_test.exs
Normal file
75
blogex/test/blogex/seo_test.exs
Normal 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
|
||||
100
blogex/test/support/fake_blog.ex
Normal file
100
blogex/test/support/fake_blog.ex
Normal 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
|
||||
48
blogex/test/support/post_builder.ex
Normal file
48
blogex/test/support/post_builder.ex
Normal 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
|
||||
50
blogex/test/support/setup.ex
Normal file
50
blogex/test/support/setup.ex
Normal 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
|
||||
1
blogex/test/test_helper.exs
Normal file
1
blogex/test/test_helper.exs
Normal file
@ -0,0 +1 @@
|
||||
ExUnit.start()
|
||||
Loading…
x
Reference in New Issue
Block a user