diff --git a/CLAUDE.md b/CLAUDE.md index 9893c3d..c019381 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/config/config.exs b/config/config.exs index f46990a..4a3f784 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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] diff --git a/lib/core.ex b/lib/core.ex index cdd9c0e..3071bda 100644 --- a/lib/core.ex +++ b/lib/core.ex @@ -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 diff --git a/lib/core/author.ex b/lib/core/author.ex new file mode 100644 index 0000000..c1d1a60 --- /dev/null +++ b/lib/core/author.ex @@ -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 diff --git a/lib/web/components/core_components.ex b/lib/web/components/core_components.ex index ad9d967..a90fa48 100644 --- a/lib/web/components/core_components.ex +++ b/lib/web/components/core_components.ex @@ -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 diff --git a/lib/web/components/layouts/app.html.heex b/lib/web/components/layouts/app.html.heex index 2ca9351..94f066a 100644 --- a/lib/web/components/layouts/app.html.heex +++ b/lib/web/components/layouts/app.html.heex @@ -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> diff --git a/lib/web/controllers/blog_controller.ex b/lib/web/controllers/blog_controller.ex new file mode 100644 index 0000000..3c039ee --- /dev/null +++ b/lib/web/controllers/blog_controller.ex @@ -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 diff --git a/lib/web/controllers/post_html.ex b/lib/web/controllers/blog_html.ex similarity index 76% rename from lib/web/controllers/post_html.ex rename to lib/web/controllers/blog_html.ex index 0183532..00a01d3 100644 --- a/lib/web/controllers/post_html.ex +++ b/lib/web/controllers/blog_html.ex @@ -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 diff --git a/lib/web/controllers/blog_html/index.html.heex b/lib/web/controllers/blog_html/index.html.heex new file mode 100644 index 0000000..1671f0c --- /dev/null +++ b/lib/web/controllers/blog_html/index.html.heex @@ -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> diff --git a/lib/web/controllers/blog_html/show.html.heex b/lib/web/controllers/blog_html/show.html.heex new file mode 100644 index 0000000..3955b33 --- /dev/null +++ b/lib/web/controllers/blog_html/show.html.heex @@ -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> diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex index f6a7755..3ad37f1 100644 --- a/lib/web/controllers/page_controller.ex +++ b/lib/web/controllers/page_controller.ex @@ -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 diff --git a/lib/web/controllers/page_html/home.html.heex b/lib/web/controllers/page_html/home.html.heex index f95bef3..7d31bfa 100644 --- a/lib/web/controllers/page_html/home.html.heex +++ b/lib/web/controllers/page_html/home.html.heex @@ -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> diff --git a/lib/web/controllers/post_controller.ex b/lib/web/controllers/post_controller.ex deleted file mode 100644 index 9765928..0000000 --- a/lib/web/controllers/post_controller.ex +++ /dev/null @@ -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 diff --git a/lib/web/controllers/post_html/index.html.heex b/lib/web/controllers/post_html/index.html.heex deleted file mode 100644 index 8e61040..0000000 --- a/lib/web/controllers/post_html/index.html.heex +++ /dev/null @@ -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> diff --git a/lib/web/controllers/post_html/show_blog.html.heex b/lib/web/controllers/post_html/show_blog.html.heex deleted file mode 100644 index b796a6a..0000000 --- a/lib/web/controllers/post_html/show_blog.html.heex +++ /dev/null @@ -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> diff --git a/lib/web/controllers/post_html/show_status.html.heex b/lib/web/controllers/post_html/show_status.html.heex deleted file mode 100644 index 1b9608c..0000000 --- a/lib/web/controllers/post_html/show_status.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -<article> - <p>{@status.body}</p> - - <%!-- <footer class="mt-4 border-t border-gray-200"></footer> --%> -</article> diff --git a/lib/web/controllers/status_controller.ex b/lib/web/controllers/status_controller.ex new file mode 100644 index 0000000..e16afca --- /dev/null +++ b/lib/web/controllers/status_controller.ex @@ -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 diff --git a/lib/web/controllers/status_html.ex b/lib/web/controllers/status_html.ex new file mode 100644 index 0000000..627cc8f --- /dev/null +++ b/lib/web/controllers/status_html.ex @@ -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 diff --git a/lib/web/controllers/status_html/index.html.heex b/lib/web/controllers/status_html/index.html.heex new file mode 100644 index 0000000..5817bf5 --- /dev/null +++ b/lib/web/controllers/status_html/index.html.heex @@ -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> diff --git a/lib/web/controllers/status_html/show.html.heex b/lib/web/controllers/status_html/show.html.heex new file mode 100644 index 0000000..093462c --- /dev/null +++ b/lib/web/controllers/status_html/show.html.heex @@ -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> diff --git a/lib/web/markdown.ex b/lib/web/markdown.ex new file mode 100644 index 0000000..784aa66 --- /dev/null +++ b/lib/web/markdown.ex @@ -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 diff --git a/lib/web/router.ex b/lib/web/router.ex index 1d23a76..69bb5bc 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -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 diff --git a/mix.exs b/mix.exs index e0285f1..25ede0d 100644 --- a/mix.exs +++ b/mix.exs @@ -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}, diff --git a/mix.lock b/mix.lock index c70be4d..f23d922 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/prompts/00001-markdown-authoring.md b/prompts/00001-markdown-authoring.md new file mode 100644 index 0000000..241b502 --- /dev/null +++ b/prompts/00001-markdown-authoring.md @@ -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. diff --git a/prompts/00002-pagination.md b/prompts/00002-pagination.md new file mode 100644 index 0000000..b4c7463 --- /dev/null +++ b/prompts/00002-pagination.md @@ -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.