Skip to content

feat(tags): tag extension #127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Static Site Generator for Elixir.
- [x] Posts
- [x] RSS
- [x] Sitemap
- [x] Tags
- [ ] SEO
- [x] Project generator

Expand Down
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ config :tableau, Mix.Tasks.Tableau.LogExtension, enabled: true
config :tableau, Tableau.PostExtension, enabled: true
config :tableau, Tableau.RSSExtension, enabled: false
config :tableau, Tableau.SitemapExtension, enabled: false
config :tableau, Tableau.TagExtension, enabled: false
config :tableau, :config, url: "http://localhost:4999"
7 changes: 7 additions & 0 deletions lib/tableau/extensions/post_extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ defmodule Tableau.PostExtension do

alias Tableau.Extension.Common

@type post :: %{
body: String.t(),
file: String.t(),
layout: module(),
date: DateTime.t()
}

@impl Tableau.Extension
def config(config) do
unify(
Expand Down
145 changes: 145 additions & 0 deletions lib/tableau/extensions/tag_extension.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
defmodule Tableau.TagExtension do
@moduledoc ~S'''
Creates pages for tags found in posts created by the `Tableau.PostExtension`.

The `:tags` key provided on every page in the assigns is described by `t:tags/0`.

The `@page` assign passed to the `layout` provided in the configuration is described by `t:page/0`.

## Configuration

- `:enabled` - boolean - Extension is active or not.
* `:layout` - module - The `Tableau.Layout` implementation to use.
* `:permalink` - string - The permalink prefix to use for the tag page, will be joined with the tag name.


## Layout and Page

To take advantage of tag extension, you'll need to define a layout that will render each "tag page" and a normal `Tableau.Page` that lists all tags on your site.

### Layout to render a tag page

```elixir
defmodule MySite.TagLayout do
use Tableau.Layout, layout: MySite.RootLayout

def template(assigns) do
~H"""
<div>
<h1>Tag: #{@page.tag}</h1>

<ul>
<li :for={post <- @page.posts}>
<a href={post.permalink}> {post.title}</a>
</li>
</ul>
</div>
"""
end
end
```

### Page to render all tags

This example page shows listing all takes, sorting them by the number of posts for each tag.

```elixir
defmodule MySite.TagPage do
use Tableau.Page,
layout: MySite.RootLayout,
permalink: "/tags",
title: "Tags"


def template(assigns) do
sorted_tags = Enum.sort_by(assigns.tags, fn {_, p} -> length(p) end, :desc)
assigns = Map.put(assigns, :tags, sorted_tags)

~H"""
<div>
<h1>Tags</h1>

<ul>
<li :for={{tag, posts} <- @tags}>
<a href={tag.permalink}>tag.tag</a>

<span>- {length(posts)}</span>
</li>
</ul>
</div>
"""
end
end
```
'''
use Tableau.Extension, key: :tag, priority: 200, type: :pre_build

import Schematic

@type page :: %{
title: String.t(),
tag: String.t(),
permalink: String.t(),
posts: [Tableau.PostExtension.post()]
}

@type tag :: %{
title: String.t(),
tag: String.t(),
permalink: String.t()
}

@type tags :: %{
tag() => [Tableau.PostExtension.post()]
}

@impl Tableau.Extension
def config(config) do
unify(
oneof([
map(%{enabled: false}),
map(%{
enabled: true,
layout: atom(),
permalink: str()
})
]),
config
)
end

@impl Tableau.Extension
def run(token) do
posts = token.posts
layout = token.extensions.tag.config.layout
permalink = token.extensions.tag.config.permalink

tags =
for post <- posts, tag <- post |> Map.get(:tags, []) |> Enum.uniq(), reduce: Map.new() do
acc ->
permalink = Path.join(permalink, tag)

tag = %{title: tag, permalink: permalink, tag: tag}
Map.update(acc, tag, [post], &[post | &1])
end

graph =
Tableau.Graph.insert(
token.graph,
for {tag, posts} <- tags do
posts = Enum.sort_by(posts, & &1.date, {:desc, DateTime})

opts = Map.put(tag, :posts, posts)

%Tableau.Page{
parent: layout,
permalink: tag.permalink,
template: fn _ -> "" end,
opts: opts
}
end
)

{:ok, token |> Map.put(:graph, graph) |> Map.put(:tags, tags)}
end
end
7 changes: 6 additions & 1 deletion lib/tableau_dev_server/build_exception.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ defmodule TableauDevServer.BuildException do

@impl true
def message(%__MODULE__{page: page, exception: exception}) do
exception = String.replace(exception, ~r/\x1B\[[0-9;]*m/, "")
exception =
if is_binary(exception) do
String.replace(exception, ~r/\x1B\[[0-9;]*m/, "")
else
exception
end

"""
An exception was raised:
Expand Down
9 changes: 5 additions & 4 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ defmodule Tableau.MixProject do
{:html_entities, "~> 0.5.2"},
{:libgraph, "~> 0.16.0"},
{:mdex, "~> 0.4.0"},
{:schematic, "~> 0.5"},
{:schematic, "~> 0.5.1"},
{:tz, "~> 0.28.1"},
{:web_dev_utils, "~> 0.3"},
{:websock_adapter, "~> 0.5"},
Expand Down Expand Up @@ -71,19 +71,20 @@ defmodule Tableau.MixProject do
Tableau,
Tableau.Layout,
Tableau.Page,
Tableau.Document.Helper
Tableau.Document.Helper,
Tableau.Extension
],
Converters: [
Tableau.Converter,
Tableau.MDExConverter
],
Extensions: [
Tableau.Extension,
Tableau.PostExtension,
Tableau.PageExtension,
Tableau.SitemapExtension,
Tableau.RSSExtension,
Tableau.DataExtension
Tableau.DataExtension,
Tableau.TagExtension
]
]
]
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"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"},
"schematic": {:hex, :schematic, "0.5.0", "e9df4e9ad2074ad803cc82612cdfddaa57fbded9698b27f94e9d3aeb0b48e88e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6cc50951999548506b9f99bfd5c707af70624ec2c4cb85a772da1be12be1b06"},
"schematic": {:hex, :schematic, "0.5.1", "be4b2c03115d5a593459c11a7249a6fbb45855947d9653e9250455dcd7df1d42", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02f913c97e6e04ccdaa02004679a7a16bb16fe0449583ad647e296d8e8961546"},
"styler": {:hex, :styler, "1.1.1", "ccb55763316915b5de532bf14c587c211ddc86bc749ac676e74dfacd3894cc0d", [:mix], [], "hexpm", "80ce12fb862e13d998589eea7c1932f4e6ce9d6ded2182cb322f8f9b2b8d3632"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
Expand Down
21 changes: 21 additions & 0 deletions test/support/helpers.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule Tableau.Support.Helpers do
@moduledoc false
def post(idx, overrides) do
base = %{
title: "Post #{idx}",
permalink: "/posts/post-#{1}",
date: DateTime.utc_now(),
body: """
## Welcome to Post #{idx}

Here, we post like crazy.
"""
}

Map.merge(base, Map.new(overrides))
end

def page_with_permalink?(page, permalink) do
is_struct(page, Tableau.Page) and page.permalink == permalink
end
end
11 changes: 0 additions & 11 deletions test/support/my_data.ex

This file was deleted.

14 changes: 9 additions & 5 deletions test/tableau/extensions/post_extension_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ end
defmodule Tableau.PostExtensionTest do
use ExUnit.Case, async: true

import Tableau.Support.Helpers

alias Tableau.PostExtension

@moduletag :tmp_dir
Expand Down Expand Up @@ -113,9 +115,10 @@ defmodule Tableau.PostExtensionTest do

vertices = Graph.vertices(graph)

assert Enum.any?(vertices, fn v -> is_struct(v, Tableau.Page) and v.permalink == post_1.permalink end)
assert Enum.any?(vertices, fn v -> is_struct(v, Tableau.Page) and v.permalink == post_2.permalink end)
assert Enum.any?(vertices, fn v -> v == Blog.PostLayout end)
assert Enum.any?(vertices, &page_with_permalink?(&1, post_1.permalink))
assert Enum.any?(vertices, &page_with_permalink?(&1, post_2.permalink))

assert Blog.PostLayout in vertices
end

test "future: true will render future posts", %{tmp_dir: dir, token: token} do
Expand Down Expand Up @@ -155,8 +158,9 @@ defmodule Tableau.PostExtensionTest do

vertices = Graph.vertices(graph)

assert Enum.any?(vertices, fn v -> is_struct(v, Tableau.Page) and v.permalink == post.permalink end)
assert Enum.any?(vertices, fn v -> v == Blog.PostLayout end)
assert Enum.any?(vertices, &page_with_permalink?(&1, post.permalink))

assert Blog.PostLayout in vertices
end

test "configured permalink works when you dont specify one", %{tmp_dir: dir, token: token} do
Expand Down
17 changes: 2 additions & 15 deletions test/tableau/extensions/rss_extension_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule Tableau.RSSExtensionTest do
use ExUnit.Case, async: true

import Tableau.Support.Helpers

alias Tableau.RSSExtension

describe "run/1" do
Expand Down Expand Up @@ -192,19 +194,4 @@ defmodule Tableau.RSSExtensionTest do
refute not_casual_content =~ "Post 3"
end
end

defp post(idx, overrides) do
base = %{
title: "Post #{idx}",
permalink: "/posts/post-#{1}",
date: DateTime.utc_now(),
body: """
## Welcome to Post #{idx}

Here, we post like crazy.
"""
}

Map.merge(base, Map.new(overrides))
end
end
45 changes: 45 additions & 0 deletions test/tableau/extensions/tag_extension_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule Tableau.TagExtensionTest do
use ExUnit.Case, async: true

import Tableau.Support.Helpers

alias Tableau.TagExtension
alias Tableau.TagExtensionTest.Layout

describe "run" do
test "creates tag pages and tags key" do
posts = [
# dedups tags
post(1, tags: ["post", "post"]),
# post can have multiple tags, includes posts from same tag
post(2, tags: ["til", "post"]),
post(3, tags: ["recipe"])
]

token = %{
posts: posts,
graph: Graph.new(),
extensions: %{tag: %{config: %{layout: Layout, permalink: "/tags"}}}
}

assert {:ok, token} = TagExtension.run(token)

assert %{
tags: %{
%{tag: "post", title: "post", permalink: "/tags/post"} => [%{title: "Post 2"}, %{title: "Post 1"}],
%{tag: "recipe", title: "recipe", permalink: "/tags/recipe"} => [%{title: "Post 3"}],
%{tag: "til", title: "til", permalink: "/tags/til"} => [%{title: "Post 2"}]
},
graph: graph
} = token

vertices = Graph.vertices(graph)

assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/post"))
assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/recipe"))
assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/til"))

assert Layout in vertices
end
end
end