Skip to content

Commit 974b263

Browse files
Raise on duplicate entries in files in :extras (#2226)
Duplicate entries in :extras silently produced renamed output files (e.g., readme-1.html and readme-2.html instead of readme.html), potentially breaking internal links and links from previously published versions. Add validation that raises an ArgumentError when duplicate extras are detected: - Same file listed twice raises an error - Same file with different non-:filename options (e.g., :title) raises an error, since they produce the same output file - Same file with different :filename options is allowed, since they produce different output files - Different files that derive the same ID are not flagged, as disambiguate_id/2 already handles that case Closes #2216
1 parent af07dd7 commit 974b263

File tree

2 files changed

+73
-0
lines changed

2 files changed

+73
-0
lines changed

lib/ex_doc/extras.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ defmodule ExDoc.Extras do
1313
extras_input
1414
|> Enum.map(&build_extra(&1, groups, config))
1515

16+
validate_no_duplicate_extras!(extras)
17+
1618
ids_count = Enum.reduce(extras, %{}, &Map.update(&2, &1.id, 1, fn c -> c + 1 end))
1719

1820
extras
@@ -23,6 +25,30 @@ defmodule ExDoc.Extras do
2325
|> Enum.sort_by(fn extra -> Config.index(groups, extra.group) end)
2426
end
2527

28+
# Detects duplicate ExtraNode entries by checking for collisions on the
29+
# {source_path, id} pair.
30+
# - URLNodes are excluded since they don't produce output files.
31+
# - Two nodes are duplicates when they share both source_path and ID — meaning
32+
# the same file would generate the same output page.
33+
# - Nodes with different source paths or different IDs (e.g. via the
34+
# :filename option) are not duplicates.
35+
# - Nodes from different source files that happen to share an ID are handled
36+
# by disambiguate_id/2 instead.
37+
defp validate_no_duplicate_extras!(extras) do
38+
duplicates =
39+
extras
40+
|> Enum.filter(&match?(%ExDoc.ExtraNode{}, &1))
41+
|> Enum.frequencies_by(fn extra -> {extra.source_path, extra.id} end)
42+
|> Enum.filter(fn {_, count} -> count > 1 end)
43+
44+
if duplicates != [] do
45+
entries = Enum.map_join(duplicates, ", ", fn {{source, _}, _} -> inspect(source) end)
46+
47+
raise ArgumentError,
48+
"duplicate extras: #{entries}"
49+
end
50+
end
51+
2652
defp disambiguate_id(extra, discriminator) do
2753
Map.put(extra, :id, "#{extra.id}-#{discriminator}")
2854
end

test/ex_doc/extras_test.exs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,53 @@ defmodule ExDoc.ExtrasTest do
323323
end
324324
end
325325

326+
test "raises on duplicate extras", %{tmp_dir: tmp_dir} do
327+
File.write!("#{tmp_dir}/readme.md", "# README")
328+
329+
assert_raise ArgumentError, ~r/duplicate extras/, fn ->
330+
extras = ["#{tmp_dir}/readme.md", "#{tmp_dir}/readme.md"]
331+
Extras.build(extras, config())
332+
end
333+
end
334+
335+
test "raises on same file with different non-filename options", %{tmp_dir: tmp_dir} do
336+
File.write!("#{tmp_dir}/readme.md", "# README")
337+
338+
assert_raise ArgumentError, ~r/duplicate extras/, fn ->
339+
extras = [
340+
{"#{tmp_dir}/readme.md", [title: "A"]},
341+
{"#{tmp_dir}/readme.md", [title: "B"]}
342+
]
343+
344+
Extras.build(extras, config())
345+
end
346+
end
347+
348+
test "raises when explicit filename matches derived id", %{tmp_dir: tmp_dir} do
349+
File.write!("#{tmp_dir}/readme.md", "# README")
350+
351+
assert_raise ArgumentError, ~r/duplicate extras/, fn ->
352+
extras = [
353+
"#{tmp_dir}/readme.md",
354+
{"#{tmp_dir}/readme.md", [filename: "readme"]}
355+
]
356+
357+
Extras.build(extras, config())
358+
end
359+
end
360+
361+
test "allows same file with different filename options", %{tmp_dir: tmp_dir} do
362+
File.write!("#{tmp_dir}/readme.md", "# README")
363+
364+
extras = [
365+
{"#{tmp_dir}/readme.md", [filename: "foo"]},
366+
{"#{tmp_dir}/readme.md", [filename: "bar"]}
367+
]
368+
369+
result = Extras.build(extras, config())
370+
assert length(result) == 2
371+
end
372+
326373
test "raises when title option is not a string", %{tmp_dir: tmp_dir} do
327374
File.write!("#{tmp_dir}/page.md", "# Page")
328375

0 commit comments

Comments
 (0)