diff --git a/lib/ex_doc/extras.ex b/lib/ex_doc/extras.ex index 166e2f183..acf9bb365 100644 --- a/lib/ex_doc/extras.ex +++ b/lib/ex_doc/extras.ex @@ -13,6 +13,8 @@ defmodule ExDoc.Extras do extras_input |> Enum.map(&build_extra(&1, groups, config)) + validate_no_duplicate_extras!(extras) + ids_count = Enum.reduce(extras, %{}, &Map.update(&2, &1.id, 1, fn c -> c + 1 end)) extras @@ -23,6 +25,30 @@ defmodule ExDoc.Extras do |> Enum.sort_by(fn extra -> Config.index(groups, extra.group) end) end + # Detects duplicate ExtraNode entries by checking for collisions on the + # {source_path, id} pair. + # - URLNodes are excluded since they don't produce output files. + # - Two nodes are duplicates when they share both source_path and ID — meaning + # the same file would generate the same output page. + # - Nodes with different source paths or different IDs (e.g. via the + # :filename option) are not duplicates. + # - Nodes from different source files that happen to share an ID are handled + # by disambiguate_id/2 instead. + defp validate_no_duplicate_extras!(extras) do + duplicates = + extras + |> Enum.filter(&match?(%ExDoc.ExtraNode{}, &1)) + |> Enum.frequencies_by(fn extra -> {extra.source_path, extra.id} end) + |> Enum.filter(fn {_, count} -> count > 1 end) + + if duplicates != [] do + entries = Enum.map_join(duplicates, ", ", fn {{source, _}, _} -> inspect(source) end) + + raise ArgumentError, + "duplicate extras: #{entries}" + end + end + defp disambiguate_id(extra, discriminator) do Map.put(extra, :id, "#{extra.id}-#{discriminator}") end diff --git a/test/ex_doc/extras_test.exs b/test/ex_doc/extras_test.exs index 626c55d66..3cbed87c8 100644 --- a/test/ex_doc/extras_test.exs +++ b/test/ex_doc/extras_test.exs @@ -323,6 +323,53 @@ defmodule ExDoc.ExtrasTest do end end + test "raises on duplicate extras", %{tmp_dir: tmp_dir} do + File.write!("#{tmp_dir}/readme.md", "# README") + + assert_raise ArgumentError, ~r/duplicate extras/, fn -> + extras = ["#{tmp_dir}/readme.md", "#{tmp_dir}/readme.md"] + Extras.build(extras, config()) + end + end + + test "raises on same file with different non-filename options", %{tmp_dir: tmp_dir} do + File.write!("#{tmp_dir}/readme.md", "# README") + + assert_raise ArgumentError, ~r/duplicate extras/, fn -> + extras = [ + {"#{tmp_dir}/readme.md", [title: "A"]}, + {"#{tmp_dir}/readme.md", [title: "B"]} + ] + + Extras.build(extras, config()) + end + end + + test "raises when explicit filename matches derived id", %{tmp_dir: tmp_dir} do + File.write!("#{tmp_dir}/readme.md", "# README") + + assert_raise ArgumentError, ~r/duplicate extras/, fn -> + extras = [ + "#{tmp_dir}/readme.md", + {"#{tmp_dir}/readme.md", [filename: "readme"]} + ] + + Extras.build(extras, config()) + end + end + + test "allows same file with different filename options", %{tmp_dir: tmp_dir} do + File.write!("#{tmp_dir}/readme.md", "# README") + + extras = [ + {"#{tmp_dir}/readme.md", [filename: "foo"]}, + {"#{tmp_dir}/readme.md", [filename: "bar"]} + ] + + result = Extras.build(extras, config()) + assert length(result) == 2 + end + test "raises when title option is not a string", %{tmp_dir: tmp_dir} do File.write!("#{tmp_dir}/page.md", "# Page")