Skip to content

Commit 7c438c4

Browse files
committed
feat(tags): slugify tag names
This change makes tags with spaces or special characters play nicely with the Web. By default, tag display values will be converted to `slugs` using [Slugify][slugify] when building permalinks. The tag extension configuration has been extended to have a `:tags` option with a map of display values to options (the only supported option is `:slug`): ```elixir config :tableau, Tableau.TagExtension, tags: %{ "C++" => [slug: "c-plus-plus"], } ``` This will be used instead of automatic slug conversion. Automatic slug conversion can be configured in the main Tableau configuration with the `:slug` keyword option, which is passed directly to [Slugify][slugify]. Other extension writers may use `Tableau.Extension.Common.slugify/2`. [slugify]: https://hexdocs.pm/slugify/Slug.html Resolves: #160
1 parent 953145c commit 7c438c4

File tree

5 files changed

+100
-9
lines changed

5 files changed

+100
-9
lines changed

lib/tableau.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Tableau do
1010
* `:converters` - mapping of file extensions to converter module. Defaults to `[md: Tableau.MDExConverter]`
1111
* `:markdown` - keyword
1212
* `:mdex` - keyword - Options to pass to `MDEx.to_html/2`
13+
* `:slug` - keyword - Options to pass to `Slug.slugify/2`
1314
1415
### Example
1516
@@ -25,6 +26,9 @@ defmodule Tableau do
2526
md: Tableau.MDExConverter,
2627
dj: MySite.DjotConverter
2728
],
29+
slug: [
30+
lowercase: false
31+
],
2832
markdown: [
2933
mdex: [
3034
extension: [

lib/tableau/config.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Tableau.Config do
1010
out_dir: "_site",
1111
timezone: "Etc/UTC",
1212
reload_log: false,
13+
slug: [],
1314
converters: [md: Tableau.MDExConverter],
1415
markdown: [mdex: []]
1516
]
@@ -32,6 +33,13 @@ defmodule Tableau.Config do
3233
optional(:reload_log) => bool(),
3334
optional(:converters) => keyword(values: atom()),
3435
optional(:markdown) => keyword(values: list()),
36+
optional(:slug) =>
37+
keyword(%{
38+
optional(:separator) => oneof([str(), int()]),
39+
optional(:lowercase) => bool(),
40+
optional(:truncate) => int(),
41+
optional(:ignore) => oneof([str(), list(oneof([str(), int()]))])
42+
}),
3543
optional(:base_path) => str(),
3644
url: str()
3745
},

lib/tableau/extensions/common.ex

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@ defmodule Tableau.Extension.Common do
88
wildcard |> Path.wildcard() |> Enum.sort()
99
end
1010

11+
@doc """
12+
Transform strings from any language into slugs using `Slug.slugify/1`.
13+
14+
Returns the original string if the slug cannot be generated.
15+
"""
16+
def slugify(string, overrides \\ [])
17+
18+
def slugify(string, %{site: %{config: config}}) do
19+
Slug.slugify(string, config.slug) || string
20+
end
21+
22+
def slugify(string, config) when is_map(config) do
23+
slugify(string)
24+
end
25+
26+
def slugify(string, overrides) do
27+
{:ok, config} = Tableau.Config.get()
28+
29+
Slug.slugify(string, Keyword.merge(config.slug, overrides)) || string
30+
end
31+
1132
@doc """
1233
Build content entries from a list of paths.
1334

lib/tableau/extensions/tag_extension.ex

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,29 @@ defmodule Tableau.TagExtension do
66
77
The `@page` assign passed to the `layout` provided in the configuration is described by `t:page/0`.
88
9+
Unless a tag has a `slug` defined in the plugin `tags` map, tag names will be converted to slugs using `Slug.slugify/2` with options provided in Tableau configuration. These slugs will be used to build the permalink.
10+
911
## Configuration
1012
1113
- `:enabled` - boolean - Extension is active or not.
1214
* `:layout` - module - The `Tableau.Layout` implementation to use.
1315
* `:permalink` - string - The permalink prefix to use for the tag page, will be joined with the tag name.
16+
* `:tags` - map - A map of tag display values to slug options. Supported options:
17+
* `:slug` - string - The slug to use for the displayed tag
18+
19+
20+
### Configuring Manual Tag Slugs
21+
22+
```elixir
23+
config :tableau, Tableau.TagExtension,
24+
enabled: true,
25+
tags: %{
26+
"C++" => [slug: "c-plus-plus"]
27+
}
28+
```
29+
30+
With this configuration, the tag `C++` will be have a permalink slug of `c-plus-plus`,
31+
`Eixir` will be `elixir`, and `Bun.sh` will be `bun-sh`.
1432
1533
1634
## Layout and Page
@@ -79,6 +97,8 @@ defmodule Tableau.TagExtension do
7997

8098
import Schematic
8199

100+
alias Tableau.Extension.Common
101+
82102
@type page :: %{
83103
title: String.t(),
84104
tag: String.t(),
@@ -89,7 +109,8 @@ defmodule Tableau.TagExtension do
89109
@type tag :: %{
90110
title: String.t(),
91111
tag: String.t(),
92-
permalink: String.t()
112+
permalink: String.t(),
113+
slug: String.t()
93114
}
94115

95116
@type tags :: %{
@@ -102,6 +123,7 @@ defmodule Tableau.TagExtension do
102123
oneof([
103124
map(%{enabled: false}),
104125
map(%{
126+
optional(:tags, %{}) => map(keys: str(), values: keyword(%{slug: str()})),
105127
enabled: true,
106128
layout: atom(),
107129
permalink: str()
@@ -115,14 +137,21 @@ defmodule Tableau.TagExtension do
115137
def pre_build(token) do
116138
posts = token.posts
117139
permalink = token.extensions.tag.config.permalink
140+
defs = token.extensions.tag.config.tags
118141

119142
tags =
120143
for post <- posts, tag <- post |> Map.get(:tags, []) |> Enum.uniq(), reduce: Map.new() do
121144
acc ->
122-
permalink = Path.join(permalink, tag)
145+
slug = get_in(defs, [tag, :slug]) || Common.slugify(tag, token)
146+
permalink = Path.join(permalink, slug)
123147

124-
tag = %{title: tag, permalink: permalink, tag: tag}
125-
Map.update(acc, tag, [post], &[post | &1])
148+
tag = %{title: tag, permalink: permalink, tag: tag, slug: slug}
149+
Map.update(acc, slug, %{tag: tag, posts: [post]}, &%{tag: tag, posts: [post | &1.posts]})
150+
end
151+
152+
tags =
153+
for {_slug, %{tag: tag, posts: posts}} <- tags, into: %{} do
154+
{tag, posts}
126155
end
127156

128157
{:ok, Map.put(token, :tags, tags)}

test/tableau/extensions/tag_extension_test.exs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,57 @@ defmodule Tableau.TagExtensionTest do
66
alias Tableau.TagExtension
77
alias Tableau.TagExtensionTest.Layout
88

9+
describe "config" do
10+
test "handles tag slugs correctly" do
11+
config =
12+
%{
13+
enabled: true,
14+
layout: Layout,
15+
permalink: "/tags",
16+
tags: %{"C++" => [slug: "c-plus-plus"]}
17+
}
18+
19+
assert {:ok, ^config} = TagExtension.config(config)
20+
end
21+
end
22+
923
describe "run" do
1024
test "creates tag pages and tags key" do
1125
posts = [
1226
# dedups tags
1327
post(1, tags: ["post", "post"]),
1428
# post can have multiple tags, includes posts from same tag
15-
post(2, tags: ["til", "post"]),
29+
# tags will be converted to slugs for linking
30+
post(2, tags: ["til", "post", "Today I Learned", "C++"]),
1631
post(3, tags: ["recipe"])
1732
]
1833

1934
token = %{
2035
posts: posts,
2136
graph: Graph.new(),
22-
extensions: %{tag: %{config: %{layout: Layout, permalink: "/tags"}}}
37+
extensions: %{tag: %{config: %{layout: Layout, permalink: "/tags", tags: %{"C++" => [slug: "c-plus-plus"]}}}}
2338
}
2439

2540
assert {:ok, token} = TagExtension.pre_build(token)
2641
assert {:ok, token} = TagExtension.pre_render(token)
2742

2843
assert %{
2944
tags: %{
30-
%{tag: "post", title: "post", permalink: "/tags/post"} => [%{title: "Post 2"}, %{title: "Post 1"}],
31-
%{tag: "recipe", title: "recipe", permalink: "/tags/recipe"} => [%{title: "Post 3"}],
32-
%{tag: "til", title: "til", permalink: "/tags/til"} => [%{title: "Post 2"}]
45+
%{tag: "post", title: "post", permalink: "/tags/post", slug: "post"} => [
46+
%{title: "Post 2"},
47+
%{title: "Post 1"}
48+
],
49+
%{tag: "recipe", title: "recipe", permalink: "/tags/recipe", slug: "recipe"} => [%{title: "Post 3"}],
50+
%{tag: "til", title: "til", permalink: "/tags/til", slug: "til"} => [%{title: "Post 2"}],
51+
%{
52+
tag: "Today I Learned",
53+
title: "Today I Learned",
54+
permalink: "/tags/today-i-learned",
55+
slug: "today-i-learned"
56+
} => [
57+
%{title: "Post 2"}
58+
],
59+
%{tag: "C++", title: "C++", permalink: "/tags/c-plus-plus", slug: "c-plus-plus"} => [%{title: "Post 2"}]
3360
},
3461
graph: graph
3562
} = token
@@ -39,6 +66,8 @@ defmodule Tableau.TagExtensionTest do
3966
assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/post"))
4067
assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/recipe"))
4168
assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/til"))
69+
assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/today-i-learned"))
70+
assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/c-plus-plus"))
4271

4372
assert Layout in vertices
4473
end

0 commit comments

Comments
 (0)