From f380a4cf51e1caf853178f1960ca1956b0eab367 Mon Sep 17 00:00:00 2001 From: Austin Ziegler Date: Wed, 10 Dec 2025 23:09:32 -0500 Subject: [PATCH] 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 --- lib/tableau.ex | 4 ++ lib/tableau/config.ex | 8 ++++ lib/tableau/extensions/common.ex | 21 ++++++++++ lib/tableau/extensions/tag_extension.ex | 37 ++++++++++++++++-- .../tableau/extensions/tag_extension_test.exs | 39 ++++++++++++++++--- 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/lib/tableau.ex b/lib/tableau.ex index ad381d7..e696dda 100644 --- a/lib/tableau.ex +++ b/lib/tableau.ex @@ -10,6 +10,7 @@ defmodule Tableau do * `:converters` - mapping of file extensions to converter module. Defaults to `[md: Tableau.MDExConverter]` * `:markdown` - keyword * `:mdex` - keyword - Options to pass to `MDEx.to_html/2` + * `:slug` - keyword - Options to pass to `Slug.slugify/2` ### Example @@ -25,6 +26,9 @@ defmodule Tableau do md: Tableau.MDExConverter, dj: MySite.DjotConverter ], + slug: [ + lowercase: false + ], markdown: [ mdex: [ extension: [ diff --git a/lib/tableau/config.ex b/lib/tableau/config.ex index 45f67b0..59beaad 100644 --- a/lib/tableau/config.ex +++ b/lib/tableau/config.ex @@ -10,6 +10,7 @@ defmodule Tableau.Config do out_dir: "_site", timezone: "Etc/UTC", reload_log: false, + slug: [], converters: [md: Tableau.MDExConverter], markdown: [mdex: []] ] @@ -32,6 +33,13 @@ defmodule Tableau.Config do optional(:reload_log) => bool(), optional(:converters) => keyword(values: atom()), optional(:markdown) => keyword(values: list()), + optional(:slug) => + keyword(%{ + optional(:separator) => oneof([str(), int()]), + optional(:lowercase) => bool(), + optional(:truncate) => int(), + optional(:ignore) => oneof([str(), list(oneof([str(), int()]))]) + }), optional(:base_path) => str(), url: str() }, diff --git a/lib/tableau/extensions/common.ex b/lib/tableau/extensions/common.ex index bd96de1..9eb9937 100644 --- a/lib/tableau/extensions/common.ex +++ b/lib/tableau/extensions/common.ex @@ -8,6 +8,27 @@ defmodule Tableau.Extension.Common do wildcard |> Path.wildcard() |> Enum.sort() end + @doc """ + Transform strings from any language into slugs using `Slug.slugify/1`. + + Returns the original string if the slug cannot be generated. + """ + def slugify(string, overrides \\ []) + + def slugify(string, %{site: %{config: config}}) do + Slug.slugify(string, config.slug) || string + end + + def slugify(string, config) when is_map(config) do + slugify(string) + end + + def slugify(string, overrides) do + {:ok, config} = Tableau.Config.get() + + Slug.slugify(string, Keyword.merge(config.slug, overrides)) || string + end + @doc """ Build content entries from a list of paths. diff --git a/lib/tableau/extensions/tag_extension.ex b/lib/tableau/extensions/tag_extension.ex index e1653ff..bbe57bd 100644 --- a/lib/tableau/extensions/tag_extension.ex +++ b/lib/tableau/extensions/tag_extension.ex @@ -6,11 +6,29 @@ defmodule Tableau.TagExtension do The `@page` assign passed to the `layout` provided in the configuration is described by `t:page/0`. + 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. + ## 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. + * `:tags` - map - A map of tag display values to slug options. Supported options: + * `:slug` - string - The slug to use for the displayed tag + + + ### Configuring Manual Tag Slugs + + ```elixir + config :tableau, Tableau.TagExtension, + enabled: true, + tags: %{ + "C++" => [slug: "c-plus-plus"] + } + ``` + + With this configuration, the tag `C++` will be have a permalink slug of `c-plus-plus`, + `Eixir` will be `elixir`, and `Bun.sh` will be `bun-sh`. ## Layout and Page @@ -79,6 +97,8 @@ defmodule Tableau.TagExtension do import Schematic + alias Tableau.Extension.Common + @type page :: %{ title: String.t(), tag: String.t(), @@ -89,7 +109,8 @@ defmodule Tableau.TagExtension do @type tag :: %{ title: String.t(), tag: String.t(), - permalink: String.t() + permalink: String.t(), + slug: String.t() } @type tags :: %{ @@ -102,6 +123,7 @@ defmodule Tableau.TagExtension do oneof([ map(%{enabled: false}), map(%{ + optional(:tags, %{}) => map(keys: str(), values: keyword(%{slug: str()})), enabled: true, layout: atom(), permalink: str() @@ -115,14 +137,21 @@ defmodule Tableau.TagExtension do def pre_build(token) do posts = token.posts permalink = token.extensions.tag.config.permalink + defs = token.extensions.tag.config.tags tags = for post <- posts, tag <- post |> Map.get(:tags, []) |> Enum.uniq(), reduce: Map.new() do acc -> - permalink = Path.join(permalink, tag) + slug = get_in(defs, [tag, :slug]) || Common.slugify(tag, token) + permalink = Path.join(permalink, slug) - tag = %{title: tag, permalink: permalink, tag: tag} - Map.update(acc, tag, [post], &[post | &1]) + tag = %{title: tag, permalink: permalink, tag: tag, slug: slug} + Map.update(acc, slug, %{tag: tag, posts: [post]}, &%{tag: tag, posts: [post | &1.posts]}) + end + + tags = + for {_slug, %{tag: tag, posts: posts}} <- tags, into: %{} do + {tag, posts} end {:ok, Map.put(token, :tags, tags)} diff --git a/test/tableau/extensions/tag_extension_test.exs b/test/tableau/extensions/tag_extension_test.exs index b19be0c..0da2ccb 100644 --- a/test/tableau/extensions/tag_extension_test.exs +++ b/test/tableau/extensions/tag_extension_test.exs @@ -6,20 +6,35 @@ defmodule Tableau.TagExtensionTest do alias Tableau.TagExtension alias Tableau.TagExtensionTest.Layout + describe "config" do + test "handles tag slugs correctly" do + config = + %{ + enabled: true, + layout: Layout, + permalink: "/tags", + tags: %{"C++" => [slug: "c-plus-plus"]} + } + + assert {:ok, ^config} = TagExtension.config(config) + end + end + 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"]), + # tags will be converted to slugs for linking + post(2, tags: ["til", "post", "Today I Learned", "C++"]), post(3, tags: ["recipe"]) ] token = %{ posts: posts, graph: Graph.new(), - extensions: %{tag: %{config: %{layout: Layout, permalink: "/tags"}}} + extensions: %{tag: %{config: %{layout: Layout, permalink: "/tags", tags: %{"C++" => [slug: "c-plus-plus"]}}}} } assert {:ok, token} = TagExtension.pre_build(token) @@ -27,9 +42,21 @@ defmodule Tableau.TagExtensionTest do 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"}] + %{tag: "post", title: "post", permalink: "/tags/post", slug: "post"} => [ + %{title: "Post 2"}, + %{title: "Post 1"} + ], + %{tag: "recipe", title: "recipe", permalink: "/tags/recipe", slug: "recipe"} => [%{title: "Post 3"}], + %{tag: "til", title: "til", permalink: "/tags/til", slug: "til"} => [%{title: "Post 2"}], + %{ + tag: "Today I Learned", + title: "Today I Learned", + permalink: "/tags/today-i-learned", + slug: "today-i-learned" + } => [ + %{title: "Post 2"} + ], + %{tag: "C++", title: "C++", permalink: "/tags/c-plus-plus", slug: "c-plus-plus"} => [%{title: "Post 2"}] }, graph: graph } = token @@ -39,6 +66,8 @@ defmodule Tableau.TagExtensionTest do 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 Enum.any?(vertices, &page_with_permalink?(&1, "/tags/today-i-learned")) + assert Enum.any?(vertices, &page_with_permalink?(&1, "/tags/c-plus-plus")) assert Layout in vertices end