From 25c7159f7a5d9a2ed39efa626be4bc78d3e340e6 Mon Sep 17 00:00:00 2001 From: Fredrik Teschke Date: Mon, 16 Mar 2026 12:13:42 +0100 Subject: [PATCH] Auto link extras: fail on bad path --- lib/ex_doc.ex | 4 +++ lib/ex_doc/autolink.ex | 24 ++++++++++++++-- lib/ex_doc/formatter.ex | 5 +++- test/ex_doc/language/elixir_test.exs | 41 ++++++++++++++++++++++++++++ test/ex_doc/language/erlang_test.exs | 12 +++++++- 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/lib/ex_doc.ex b/lib/ex_doc.ex index f9a98a6c8..0568c874d 100644 --- a/lib/ex_doc.ex +++ b/lib/ex_doc.ex @@ -249,6 +249,10 @@ defmodule ExDoc do * `:title` - The title of the extra page. If not provided, the title will be inferred from the extra name. * `:url` - The external url to link to from the sidebar. + Bare filenames such as `[Intro](intro.md)` use the legacy filename-based lookup against the flattened output. + Links with a directory component, such as `[Intro](guides/intro.md)`, `[Intro](../guides/intro.md)`, or + `[Intro](/guides/intro.md)`, are resolved against the extra source path (or project root for `/`). + ### Customizing search data It is possible to fully customize the way a given extra is indexed, both in autocomplete and in search. diff --git a/lib/ex_doc/autolink.ex b/lib/ex_doc/autolink.ex index 8fa342b70..0cd2a54be 100644 --- a/lib/ex_doc/autolink.ex +++ b/lib/ex_doc/autolink.ex @@ -52,7 +52,7 @@ defmodule ExDoc.Autolink do :language, file: "nofile", apps: [], - extras: [], + extras: %{}, deps: [], ext: ".html", current_kfa: nil, @@ -217,7 +217,7 @@ defmodule ExDoc.Autolink do with %{scheme: nil, host: nil, path: path} = uri <- URI.parse(link), true <- is_binary(path) and path != "" and not (path =~ ref_regex()), true <- Path.extname(path) in @builtin_ext do - if file = config.extras[Path.basename(path)] do + if file = resolve_extra_target(path, config) do append_fragment(file <> config.ext, uri.fragment) else maybe_warn(config, nil, nil, %{file_path: path, original_text: link}) @@ -228,6 +228,26 @@ defmodule ExDoc.Autolink do end end + defp resolve_extra_target(path, config) do + filename = Path.basename(path) + + case path do + "/" <> absolute_path -> + config.extras[absolute_path] + + ^filename -> + config.extras[filename] + + relative_path -> + path = + relative_path + |> Path.expand(Path.dirname(config.file)) + |> Path.relative_to_cwd() + + config.extras[path] + end + end + defp maybe_remove_link(nil, :custom_link) do :remove_link end diff --git a/lib/ex_doc/formatter.ex b/lib/ex_doc/formatter.ex index 6ce1e73af..8998802a4 100644 --- a/lib/ex_doc/formatter.ex +++ b/lib/ex_doc/formatter.ex @@ -314,7 +314,10 @@ defmodule ExDoc.Formatter do %ExDoc.ExtraNode{source_path: source_path, id: id}, acc when is_binary(source_path) -> base = Path.basename(source_path) - Map.put(acc, base, id) + + acc + |> Map.put(source_path, id) + |> Map.put(base, id) _extra, acc -> acc diff --git a/test/ex_doc/language/elixir_test.exs b/test/ex_doc/language/elixir_test.exs index 4023708c1..1d2e26f77 100644 --- a/test/ex_doc/language/elixir_test.exs +++ b/test/ex_doc/language/elixir_test.exs @@ -259,7 +259,9 @@ defmodule ExDoc.Language.ElixirTest do test "extras" do opts = [ + file: "guides/current.md", extras: %{ + "guide/Foo Bar.md" => "foo-bar", "Foo Bar.md" => "foo-bar", "Bar Baz.livemd" => "bar-baz", "Bar Baz.cheatmd" => "bar-baz" @@ -286,6 +288,45 @@ defmodule ExDoc.Language.ElixirTest do assert autolink_doc("[Foo](#baz)", opts) == ~s|Foo| end + test "path-qualified extra links use the extra source path" do + opts = [ + file: "guides/current.md", + extras: %{"guides/Foo Bar.md" => "foo-bar", "Foo Bar.md" => "legacy-foo"} + ] + + assert autolink_doc("[Foo](./Foo Bar.md)", opts) == + ~s|Foo| + + assert autolink_doc("[Foo](../guides/Foo Bar.md)", opts) == + ~s|Foo| + + assert autolink_doc("[Foo](/guides/Foo Bar.md)", opts) == + ~s|Foo| + end + + test "bare filename extra links use legacy lookup" do + opts = [ + file: "guides/current.md", + extras: %{"guides/Foo Bar.md" => "relative-foo", "Foo Bar.md" => "legacy-foo"} + ] + + assert autolink_doc("[Foo](Foo Bar.md)", opts) == + ~s|Foo| + end + + test "extras with bad directories warn instead of silently matching by basename" do + opts = [ + warnings: :send, + file: "guides/current.md", + extras: %{"guide/Foo Bar.md" => "foo-bar", "Foo Bar.md" => "foo-bar"} + ] + + assert warn(fn -> + assert autolink_doc("[Foo](/bad_dir/Foo Bar.md)", opts) == + ~s|Foo| + end) =~ ~s|documentation references file "/bad_dir/Foo Bar.md" but it does not exist| + end + test "special case links" do assert autolink_doc("`//2`") == ~s|//2| diff --git a/test/ex_doc/language/erlang_test.exs b/test/ex_doc/language/erlang_test.exs index a6caf2531..37bc91735 100644 --- a/test/ex_doc/language/erlang_test.exs +++ b/test/ex_doc/language/erlang_test.exs @@ -669,6 +669,16 @@ defmodule ExDoc.Language.ErlangTest do extras: %{"Foo Bar.md" => "foo-bar", "Bar Baz.livemd" => "bar-baz"} ] + @relative_opts [ + file: "guides/current.md", + extras: %{ + "guide/Foo Bar.md" => "foo-bar", + "guide/Bar Baz.livemd" => "bar-baz", + "Foo Bar.md" => "foo-bar", + "Bar Baz.livemd" => "bar-baz" + } + ] + test "extras", c do assert autolink_doc("[Foo](Foo Bar.md)", c, @opts) == ~s|Foo| @@ -690,7 +700,7 @@ defmodule ExDoc.Language.ErlangTest do end test "extras relative", c do - assert autolink_doc("[Foo](../guide/Foo Bar.md)", c, @opts) == + assert autolink_doc("[Foo](../guide/Foo Bar.md)", c, @relative_opts) == ~s|Foo| end end