big updates to content

This commit is contained in:
sloane 2025-04-12 12:50:57 -04:00
parent 1c17c6f508
commit 464f3a0ae3
Signed by: sloanelybutsurely
SSH key fingerprint: SHA256:8SBnwhl+RY3oEyQxy1a9wByPzxWM0x+/Ejc+sIlY5qQ
26 changed files with 778 additions and 80 deletions

View file

@ -17,4 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Testing: Descriptive test names, fixtures, shared setup, tagged tests
- Imports: Organize at top, import frequently used functions, follow boundary pattern
- Documentation: `@doc` for public functions, `@moduledoc false` for internal modules
- Use `@impl` for callback implementations
- Use `@impl` for callback implementations
- Styling: Prefer CSS/tailwind based styling using things like group, first:, focus:, hover:, etc. to any sort of dynamic classes using Elixir or JavaScript
- All data should be microformat2 compatible
- Use lists of classes instead of string interpolation

View file

@ -30,7 +30,13 @@ config :sloanely_but_surely, Web.Endpoint,
config :sloanely_but_surely,
namespace: Core,
ecto_repos: [Core.Repo],
generators: [timestamp_type: :utc_datetime_usec, binary_id: true]
generators: [timestamp_type: :utc_datetime_usec, binary_id: true],
author: %{
name: "Sloane",
username: "sloanelybutsurely",
avatar_emoji: "💜",
avatar_url: nil
}
config :sloanely_but_surely, Core.Repo, migration_timestamps: [type: :utc_datetime_usec]

View file

@ -1,4 +1,4 @@
defmodule Core do
@moduledoc false
use Boundary, deps: [Schema], exports: [Accounts, Posts]
use Boundary, deps: [Schema], exports: [Accounts, Posts, Author]
end

19
lib/core/author.ex Normal file
View file

@ -0,0 +1,19 @@
defmodule Core.Author do
@moduledoc false
@author_config Application.compile_env!(:sloanely_but_surely, :author)
@doc """
Returns the author configuration.
"""
def get do
@author_config
end
@doc """
Returns a specific attribute from the author configuration.
"""
def get(attribute) when is_atom(attribute) do
Map.get(@author_config, attribute)
end
end

View file

@ -193,4 +193,22 @@ defmodule Web.CoreComponents do
defp normalize_posts(posts) when is_list(posts),
do: Enum.with_index(posts, &{"#{&1.kind}-#{&2}", &1})
@doc """
Renders markdown content as HTML.
## Examples
<.markdown content={@post.body} />
"""
attr :content, :string, required: true
attr :class, :string, default: ""
def markdown(assigns) do
~H"""
<div class={["markdown-content", @class]}>
{Phoenix.HTML.raw(Web.Markdown.to_html(@content))}
</div>
"""
end
end

View file

@ -1,15 +1,9 @@
<div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 md:mb-2 border-b border-gray-200">
<div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 border-b border-gray-200">
<div class="flex flex-col md:flex-row">
<section class="flex flex-row gap-x-2 md:border-r border-gray-200 px-2">
<section class="flex flex-row gap-x-2 px-2">
<.link navigate={~p"/"} class="font-bold group">
💜 <span class="group-hover:underline">sloanelybutsurely.com</span>
</.link>
<nav>
<ul class="flex flex-row gap-x-2">
<li><.link href={~p"/blog"}>writing</.link></li>
<li><.link href={~p"/microblog"}>microblog</.link></li>
</ul>
</nav>
</section>
<section :if={not is_nil(@current_user)} class="ml-2">
<nav>
@ -29,6 +23,6 @@
<% end %>
</div>
<main class="p-2 max-w-2xl mx-auto">
<main class="mx-auto">
{@inner_content}
</main>

View file

@ -0,0 +1,29 @@
defmodule Web.BlogController do
use Web, :controller
def index(conn, _params) do
blogs = Core.Posts.get_published_recent_posts(:blog)
blogs_by_year =
blogs
|> Enum.group_by(fn blog ->
date = Core.Posts.publish_date(blog)
date && date.year
end)
|> Enum.sort_by(fn {year, _} -> year end, :desc)
render(conn, :index, blogs_by_year: blogs_by_year)
end
def show(conn, %{"year" => year, "month" => month, "day" => day, "slug" => slug}) do
with {year, ""} <- Integer.parse(year),
{month, ""} <- Integer.parse(month),
{day, ""} <- Integer.parse(day),
{:ok, publish_date} <- Date.new(year, month, day) do
blog = Core.Posts.get_published_blog!(publish_date, slug)
render(conn, :show, blog: blog)
else
_ -> raise Ecto.NoResultsError
end
end
end

View file

@ -1,7 +1,7 @@
defmodule Web.PostHTML do
defmodule Web.BlogHTML do
use Web, :html
embed_templates "post_html/*"
embed_templates "blog_html/*"
def blog_path(%Schema.Post{} = blog) do
if date = Core.Posts.publish_date(blog) do

View file

@ -0,0 +1,30 @@
<div class="max-w-2xl mx-auto">
<div class="p-4">
<h1 class="text-2xl font-bold mb-6 mt-2">Writing</h1>
<div class="h-feed">
<%= for {year, blogs} <- @blogs_by_year do %>
<div class="mb-8">
<h2 class="text-lg font-semibold mb-4">{year}</h2>
<ul>
<%= for blog <- blogs do %>
<li>
<article class="h-entry">
<.link
navigate={blog_path(blog)}
class="flex justify-between items-center hover:bg-gray-50 -mx-2 px-2 py-1 rounded"
>
<h3 class="p-name u-url">{blog.title}</h3>
<div class="text-gray-500 dt-published">
<.timex value={blog.published_at} format="{Mfull} {D}" />
</div>
</.link>
</article>
</li>
<% end %>
</ul>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,18 @@
<div class="p-4 max-w-2xl mx-auto">
<article class="h-entry mt-2">
<header class="mb-4">
<h1 class="p-name text-2xl font-bold mb-2">{@blog.title}</h1>
<%= if @blog.published_at do %>
<div class="text-sm text-gray-500 flex items-center">
<span class="p-author h-card">{Core.Author.get(:name)}</span>
<span class="mx-2">·</span>
<.timex value={@blog.published_at} format="{Mfull} {D}, {YYYY}" class="dt-published" />
</div>
<% end %>
</header>
<div class="e-content">
<.markdown content={@blog.body} />
</div>
</article>
</div>

View file

@ -2,6 +2,12 @@ defmodule Web.PageController do
use Web, :controller
def home(conn, _params) do
render(conn, :home)
# Get recent blog posts
recent_blogs = Core.Posts.get_published_recent_posts(:blog)
# Get recent statuses
recent_statuses = Core.Posts.get_published_recent_posts(:status)
render(conn, :home, recent_blogs: recent_blogs, recent_statuses: recent_statuses)
end
end

View file

@ -1 +1,94 @@
<h1>Home</h1>
<div class="max-w-2xl mx-auto mt-2">
<!-- Recent Blog Posts -->
<section class="mb-8 p-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Writing</h2>
<.link navigate={~p"/blog"} class="text-sm text-gray-500 hover:text-gray-800">
See all →
</.link>
</div>
<ul>
<%= for blog <- @recent_blogs do %>
<li>
<article class="h-entry">
<.link
navigate={Web.BlogHTML.blog_path(blog)}
class="flex justify-between items-center hover:bg-gray-50 -mx-2 px-2 py-1 rounded"
>
<h3 class="p-name u-url">{blog.title}</h3>
<div class="text-gray-500 dt-published">
<.timex value={blog.published_at} format="{Mfull} {D}" />
</div>
</.link>
</article>
</li>
<% end %>
</ul>
</section>
</div>
<!-- Recent Statuses -->
<div class="max-w-2xl mx-auto">
<section>
<div class="flex justify-between items-center mb-4 py-2 px-4">
<h2 class="text-xl font-bold">Microblog</h2>
<.link navigate={~p"/microblog"} class="text-sm text-gray-500 hover:text-gray-800">
See all →
</.link>
</div>
<div class="border border-gray-200">
<%= for status <- @recent_statuses do %>
<.link
navigate={~p"/status/#{status.id}"}
class="block border-t first:border-t-0 border-gray-200 hover:bg-gray-50 u-url"
>
<article class="h-entry py-4">
<div class="flex items-start">
<div class="flex-shrink-0 pl-3 pr-2">
<%= if Core.Author.get(:avatar_url) do %>
<img
src={Core.Author.get(:avatar_url)}
alt={Core.Author.get(:name)}
class="w-10 h-10 rounded-full u-photo"
/>
<% else %>
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-xl u-photo">
{Core.Author.get(:avatar_emoji)}
</div>
<% end %>
</div>
<div class="flex-grow min-w-0 pl-1 pr-3">
<div class="flex items-center mb-1">
<p class="p-name font-bold">
{Core.Author.get(:name)}
</p>
<p class="ml-1 inline">
<span class="p-nickname font-normal text-gray-600">
@{Core.Author.get(:username)}
</span>
</p>
<span class="mx-2 text-sm text-gray-500">·</span>
<div class="text-sm text-gray-500">
<.timex
value={status.published_at}
format="{relative}"
formatter={:relative}
class="dt-published"
/>
</div>
</div>
<div class="e-content">
<.markdown content={status.body} class="text-gray-900" />
</div>
</div>
</div>
</article>
</.link>
<% end %>
</div>
</section>
</div>

View file

@ -1,38 +0,0 @@
defmodule Web.PostController do
use Web, :controller
def show(conn, %{"year" => year, "month" => month, "day" => day, "slug" => slug}) do
with {year, ""} <- Integer.parse(year),
{month, ""} <- Integer.parse(month),
{day, ""} <- Integer.parse(day),
{:ok, publish_date} <- Date.new(year, month, day) do
post = Core.Posts.get_published_blog!(publish_date, slug)
conn
|> render_post(post)
else
_ -> raise Ecto.NoResultsError
end
end
def show(conn, %{"status_id" => status_id}) do
status = Core.Posts.get_published_status!(status_id)
conn
|> render_post(status)
end
def index(%{assigns: %{kind: kind}} = conn, _params) when kind in ~w[blog status]a do
posts = Core.Posts.get_published_recent_posts(kind)
render(conn, :index, posts: posts)
end
defp render_post(conn, %Schema.Post{kind: :blog} = blog) do
render(conn, :show_blog, blog: blog)
end
defp render_post(conn, %Schema.Post{kind: :status} = status) do
render(conn, :show_status, status: status)
end
end

View file

@ -1,8 +0,0 @@
<.post_list :let={post} id="recent-posts" posts={@posts}>
<%= case post.kind do %>
<% :blog -> %>
<.link navigate={blog_path(post)}>{post.title}</.link>
<% :status -> %>
{post.body}
<% end %>
</.post_list>

View file

@ -1,9 +0,0 @@
<article>
<header class="mb-2">
<h1 class="text-2xl font-bold">{@blog.title}</h1>
</header>
<p>{@blog.body}</p>
<%!-- <footer class="mt-4 border-t border-gray-200"></footer> --%>
</article>

View file

@ -1,5 +0,0 @@
<article>
<p>{@status.body}</p>
<%!-- <footer class="mt-4 border-t border-gray-200"></footer> --%>
</article>

View file

@ -0,0 +1,13 @@
defmodule Web.StatusController do
use Web, :controller
def index(conn, _params) do
statuses = Core.Posts.get_published_recent_posts(:status)
render(conn, :index, statuses: statuses)
end
def show(conn, %{"status_id" => status_id}) do
status = Core.Posts.get_published_status!(status_id)
render(conn, :show, status: status)
end
end

View file

@ -0,0 +1,69 @@
defmodule Web.StatusHTML do
use Web, :html
embed_templates "status_html/*"
attr :status, :map, required: true
attr :class, :string, default: nil
attr :rest, :global
def status_entry(assigns) do
~H"""
<.link
navigate={~p"/status/#{@status.id}"}
class="block border-x border-gray-200 border-t hover:bg-gray-50 u-url"
>
<article
class={[
"h-entry py-4",
@class
]}
{@rest}
>
<div class="flex items-start">
<div class="flex-shrink-0 pl-3 pr-2">
<%= if Core.Author.get(:avatar_url) do %>
<img
src={Core.Author.get(:avatar_url)}
alt={Core.Author.get(:name)}
class="w-10 h-10 rounded-full u-photo"
/>
<% else %>
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-xl u-photo">
{Core.Author.get(:avatar_emoji)}
</div>
<% end %>
</div>
<div class="flex-grow min-w-0 pl-1 pr-3">
<div class="flex items-center mb-1">
<p class="p-name font-bold">
{Core.Author.get(:name)}
</p>
<p class="ml-1 inline">
<span class="p-nickname font-normal text-gray-600">
@{Core.Author.get(:username)}
</span>
</p>
<span class="mx-2 text-sm text-gray-500">·</span>
<div class="text-sm text-gray-500">
<.timex
value={@status.published_at}
format="{relative}"
formatter={:relative}
class="dt-published"
/>
</div>
</div>
<div class="e-content">
<.markdown content={@status.body} class="text-gray-900" />
</div>
</div>
</div>
</article>
</.link>
"""
end
end

View file

@ -0,0 +1,11 @@
<div class="max-w-2xl mx-auto">
<div class="flex items-center py-2 px-4 border-x border-gray-200">
<h1 class="text-lg font-bold">Microblog</h1>
</div>
<div class="h-feed border-b border-gray-200">
<%= for status <- @statuses do %>
<.status_entry status={status} />
<% end %>
</div>
</div>

View file

@ -0,0 +1,53 @@
<div class="max-w-2xl mx-auto">
<div class="flex items-center py-2 px-4 border-x border-gray-200">
<.link
navigate={~p"/microblog"}
class="mr-4 text-gray-500 hover:text-gray-800 flex items-center"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<h1 class="text-lg font-bold">Post</h1>
</div>
<article class="h-entry p-4 border border-gray-200">
<header class="flex items-center mb-3">
<div class="h-card flex items-center gap-3 p-author">
<%= if Core.Author.get(:avatar_url) do %>
<img
src={Core.Author.get(:avatar_url)}
alt={Core.Author.get(:name)}
class="w-10 h-10 rounded-full u-photo"
/>
<% else %>
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-xl u-photo">
{Core.Author.get(:avatar_emoji)}
</div>
<% end %>
<div class="flex flex-col">
<p class="p-name font-bold">
{Core.Author.get(:name)}
</p>
<p>
<span class="p-nickname font-normal text-gray-600">
@{Core.Author.get(:username)}
</span>
</p>
</div>
</div>
</header>
<div class="e-content my-4">
<.markdown content={@status.body} />
</div>
<footer class="mt-4 border-t border-gray-200 pt-2 text-sm text-gray-500">
<%= if @status.published_at do %>
<.timex
value={@status.published_at}
format="{Mfull} {D}, {YYYY} at {h12}:{m} {AM}"
class="dt-published"
/>
<% end %>
</footer>
</article>
</div>

323
lib/web/markdown.ex Normal file
View file

@ -0,0 +1,323 @@
defmodule Web.Markdown do
@moduledoc """
Converts markdown to HTML using MDEx, with custom rendering to add Tailwind CSS classes.
Uses iodata for efficient string generation.
"""
alias MDEx.Document
@doc """
Converts markdown to HTML with syntax highlighting, additional extensions,
and appropriate Tailwind CSS classes for styling.
"""
def to_html(markdown) when is_binary(markdown) do
# First parse the document to get the AST
{:ok, document} =
MDEx.parse_document(markdown,
extension: [
strikethrough: true,
table: true,
autolink: true,
tasklist: true,
footnotes: true
],
features: [syntax_highlight_theme: "catppuccin_latte"]
)
# Render the document to HTML with Tailwind classes
render_document(document)
end
def to_html(nil), do: ""
# Core rendering function for the document
defp render_document(%Document{} = document) do
document.nodes
|> Enum.map(&render_node(&1, nil))
end
# Heading (h1-h6) with anchor links
defp render_node(%MDEx.Heading{level: level} = node, _parent) do
tag = "h#{level}"
class =
case level do
1 -> "text-3xl font-bold mt-6 mb-4 group"
2 -> "text-2xl font-bold mt-5 mb-3 group"
3 -> "text-xl font-bold mt-4 mb-2 group"
4 -> "text-lg font-bold mt-3 mb-2 group"
5 -> "text-base font-bold mt-3 mb-2 group"
6 -> "text-sm font-bold mt-3 mb-2 group"
end
# Generate a slug from the text content for the ID
content = Enum.map(node.nodes, &render_node(&1, node))
content_text = extract_text_content(node)
slug = Slug.slugify(content_text)
[
"<",
tag,
" id=\"",
slug,
"\" class=\"",
class,
" relative\">",
"<a href=\"#",
slug,
"\" class=\"absolute -left-5 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity\">¶</a>",
content,
"</",
tag,
">"
]
end
# Paragraph
defp render_node(%MDEx.Paragraph{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<p>", content, "</p>"]
end
# Text
defp render_node(%MDEx.Text{literal: literal}, _parent) do
literal
end
# Strong (bold)
defp render_node(%MDEx.Strong{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<strong>", content, "</strong>"]
end
# Emphasis (italic)
defp render_node(%MDEx.Emph{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<em>", content, "</em>"]
end
# Strikethrough
defp render_node(%MDEx.Strikethrough{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<del>", content, "</del>"]
end
# Link
defp render_node(%MDEx.Link{url: url, title: title} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
title_attr = if title, do: [" title=\"", title, "\""], else: []
[
"<a href=\"",
url,
"\"",
title_attr,
" class=\"text-blue-600 hover:text-blue-800 hover:underline\">",
content,
"</a>"
]
end
# Image
defp render_node(%MDEx.Image{url: url, title: title} = node, _parent) do
alt = Enum.map(node.nodes, &render_node(&1, node))
title_attr = if title, do: [" title=\"", title, "\""], else: []
[
"<img src=\"",
url,
"\" alt=\"",
alt,
"\"",
title_attr,
" class=\"max-w-full my-4 rounded\">"
]
end
# Code (inline)
defp render_node(%MDEx.Code{literal: literal}, _parent) do
["<code class=\"bg-gray-100 rounded px-1 py-0.5 text-sm\">", literal, "</code>"]
end
# Code blocks - use MDEx's built-in HTML rendering to preserve syntax highlighting
defp render_node(%MDEx.CodeBlock{} = node, _parent) do
# Use MDEx's built-in HTML rendering for code blocks to preserve syntax highlighting
{:ok, html} =
MDEx.to_html(
%MDEx.Document{nodes: [node]},
extension: [],
features: [syntax_highlight_theme: "catppuccin_latte"]
)
# Return the HTML as is since it's already properly formatted with syntax highlighting
String.replace(html, ~s|class="autumn-hl"|, ~s|class="autumn-hl p-2 rounded"|)
end
# Lists - Bullet
defp render_node(%MDEx.List{list_type: :bullet} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<ul class=\"list-disc pl-6 my-4\">", content, "</ul>"]
end
# Lists - Ordered
defp render_node(%MDEx.List{list_type: :ordered} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<ol class=\"list-decimal pl-6 my-4\">", content, "</ol>"]
end
# List items
defp render_node(%MDEx.ListItem{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<li class=\"mb-1\">", content, "</li>"]
end
# Task items (checkboxes)
defp render_node(%MDEx.TaskItem{checked: checked} = node, _parent) do
checkbox =
if checked do
"<input type=\"checkbox\" checked disabled class=\"rounded mr-1.5 text-blue-600\">"
else
"<input type=\"checkbox\" disabled class=\"rounded mr-1.5 text-blue-600\">"
end
content = Enum.map(node.nodes, &render_node(&1, node))
[
"<li class=\"mb-1 flex items-start\">",
checkbox,
"<div class=\"inline\">",
content,
"</div>",
"</li>"
]
end
# Blockquote
defp render_node(%MDEx.BlockQuote{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
[
"<blockquote class=\"border-l-4 border-gray-300 pl-4 py-1 my-4 italic\">",
content,
"</blockquote>"
]
end
# Horizontal Rule
defp render_node(%MDEx.ThematicBreak{}, _parent) do
"<hr class=\"border-t border-gray-300 my-6\">"
end
# Table
defp render_node(%MDEx.Table{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
[
"<div class=\"overflow-x-auto my-4\"><table class=\"min-w-full divide-y divide-gray-200\">",
content,
"</table></div>"
]
end
# Table row
defp render_node(%MDEx.TableRow{header: true} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<tr class=\"bg-gray-50 border-b border-gray-200\">", content, "</tr>"]
end
defp render_node(%MDEx.TableRow{header: false} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<tr class=\"border-b border-gray-200\">", content, "</tr>"]
end
# Table cell in header row
defp render_node(%MDEx.TableCell{} = node, %MDEx.TableRow{header: true}) do
content = Enum.map(node.nodes, &render_node(&1, node))
[
"<th class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">",
content,
"</th>"
]
end
# Table cell in normal row
defp render_node(%MDEx.TableCell{} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
["<td class=\"px-6 py-4 whitespace-nowrap text-sm\">", content, "</td>"]
end
# HTML blocks (pass through)
defp render_node(%MDEx.HtmlBlock{literal: literal}, _parent) do
literal
end
# HTML inline (pass through)
defp render_node(%MDEx.HtmlInline{literal: literal}, _parent) do
literal
end
# Soft break (line break that doesn't create new paragraph)
defp render_node(%MDEx.SoftBreak{}, _parent) do
"\n"
end
# Hard break (explicit line break with <br>)
defp render_node(%MDEx.LineBreak{}, _parent) do
"<br>"
end
# Footnote reference
defp render_node(%MDEx.FootnoteReference{name: name}, _parent) do
[
"<sup class=\"footnote-ref\">",
"<a href=\"#fn-",
name,
"\" id=\"fnref-",
name,
"\" class=\"text-blue-600 hover:text-blue-800\">",
name,
"</a>",
"</sup>"
]
end
# Footnote definition
defp render_node(%MDEx.FootnoteDefinition{name: name} = node, _parent) do
content = Enum.map(node.nodes, &render_node(&1, node))
[
"<div class=\"footnote flex flex-row\" id=\"fn-",
name,
"\">",
"<p class=\"my-1 flex\">",
"<span class=\"mr-2 flex-shrink-0\"><sup>",
name,
".</sup></span>",
"<span class=\"inline\">",
content,
" <a href=\"#fnref-",
name,
"\" class=\"text-blue-600 hover:text-blue-800 text-sm\">↩</a>",
"</span>",
"</p>",
"</div>"
]
end
# Extract plain text from a node tree (for generating slugs)
defp extract_text_content(node) do
cond do
is_map(node) && Map.has_key?(node, :literal) && is_binary(node.literal) ->
node.literal
is_map(node) && Map.has_key?(node, :nodes) ->
node.nodes
|> Enum.map(&extract_text_content/1)
|> Enum.join("")
true ->
""
end
end
end

View file

@ -45,10 +45,10 @@ defmodule Web.Router do
delete "/admin/users/log_out", UserSessionController, :delete
get "/blog", PostController, :index, assigns: %{kind: :blog}
get "/blog/:year/:month/:day/:slug", PostController, :show
get "/microblog", PostController, :index, assigns: %{kind: :status}
get "/status/:status_id", PostController, :show
get "/blog", BlogController, :index
get "/blog/:year/:month/:day/:slug", BlogController, :show
get "/microblog", StatusController, :index
get "/status/:status_id", StatusController, :show
# live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do
# end

View file

@ -61,6 +61,7 @@ defmodule SlaonelyButSurely.MixProject do
{:tzdata, "~> 1.1"},
{:slugify, "~> 1.3"},
{:timex, "~> 3.7"},
{:mdex, "~> 0.5.0"},
# Added dev and/or test dependencies
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},

View file

@ -25,9 +25,11 @@
"hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"},
"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"},
"mdex": {:hex, :mdex, "0.5.0", "252c83cebc6a089801dfc1e142b4d98c9c358378ec7096a94796bce8bd13b0fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "73e3ddee03130267e3be6aaf47a7f423c6f86add4bb5c62b352465cd9fb87d95"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.7.19", "36617efe5afbd821099a8b994ff4618a340a5bfb25531a1802c4d4c634017a57", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {: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", "ba4dc14458278773f905f8ae6c2ec743d52c3a35b6b353733f64f02dfe096cd6"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [: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", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"},
@ -40,6 +42,8 @@
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [: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", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [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", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
"rustler": {:hex, :rustler, "0.36.1", "2d4b1ff57ea2789a44756a40dbb5fbb73c6ee0a13d031dcba96d0a5542598a6a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "f3fba4ad272970e0d1bc62972fc4a99809651e54a125c5242de9bad4574b2d02"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.2", "5f25cbe220a8fac3e7ad62e6f950fcdca5a5a5f8501835d2823e8c74bf4268d5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "63d1bd5f8e23096d1ff851839923162096364bac8656a4a3c00d1fff8e83ee0a"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"styler": {:hex, :styler, "1.4.0", "5944723d08afe4d38210b674d7e97dd1137a75968a85a633983cc308e86dc5f2", [:mix], [], "hexpm", "07de0e89c27490c8e469bb814d77ddaaa3283d7d8038501021d80a7705cf13e9"},
@ -49,6 +53,7 @@
"telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
"thousand_island": {:hex, :thousand_island, "1.3.10", "a9971ebab1dfb36e2710a86b37c3f54973fbc9470d892035334415521fb53328", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17ab1f1b13aadb1f4b4c8e5b59c06874d701119fed082884c9c6d38addad254f"},
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
"typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"},
"tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},

View file

@ -0,0 +1,39 @@
# Markdown Authoring
You must update the site to support Markdown content in blog posts and microblog statuses.
- Use the [mdex](https://hex.pm/packages/mdex) to convert markdown into HTML
- [MDEx documentation](https://hexdocs.pm/mdex/MDEx.html)
- Keep all content markdown -> HTML in a new module in the `Web` boundary so that any later customizations to markdown to HTML conversion are applied throughout the site
- Use the existing `body` field of the `Schema.Post`. assume this content is already markdown
- Ensure that syntax highlighting is supported in codefences
- Use the catppuccin latte colorscheme
- Make sure support for the following languges is available:
- Elixir
- Erlang
- JavaScript (including React / JSX)
- TypeScript
- Bash
- HTML
- Markdown
- Lua
- JSON
- TOML
- SQL
- YAML
- Enable these extensions:
- strikethrough
- table
- autolink
- tasklist
- footnotes
You will need to update the blog show page, the status show page, the status index page, and the home page where recent statuses are shown.
Use components to reduced code duplication when possible.
If components are shared between multiple views create a new module in `lib/web/components/` for the components pertaining to a specific thing.

View file

@ -0,0 +1,28 @@
# Pagination for Blog posts and Microblog feed
Your task is to add pagination to the blog posts index page and the statuses index page.
Requirements:
- Must use cursor based pagination NOT limit/offset
- Invalid pagination options should just redirect to the first page
- h-feed compatibility should be maintained
- microformat2 consumers should be able to discover all posts somehow
- if the h-feed spec allows for pagination just use the html pages
- consider reading the h-feed spec: https://microformats.org/wiki/h-feed to ensure compliance.
This involves several things
1. Install the `flop` package
- documentation: https://hexdocs.pm/flop/readme.html
- make sure to run `mix deps.get` after adding this dependency to the `mix.exs` file.
- the current version is `0.26.1`, use this version
2. Read through the Flop documentation to understand how the module works and all of the options available
3. Derive `Flop.Schema` in `Schema.Post`
- Do not support any filtering or sorting for now
4. Add new functions to the `Cost.Posts` module that support using Flop
5. Add pagination controls to the pages
- use the text "Newer posts" and "Older posts" justified with space between at the bottom of the page
- only show the pagination controls if there are newer or older posts respectively
- the newer posts and older posts links should always be in the same positions even if one is not rendered meaning if "newer posts" is not shown the "older posts" link should not suddenly show on the left-hand side
Think hard about this problem, review any rerference material, and then ask any clarifying questions or state assumptions for validation before continuing on to complete the task.