Skip to content

Commit 41bbea2

Browse files
committed
Auto link extras: fail on bad path
1 parent 9e5899a commit 41bbea2

5 files changed

Lines changed: 146 additions & 12 deletions

File tree

lib/ex_doc.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ defmodule ExDoc do
249249
* `:title` - The title of the extra page. If not provided, the title will be inferred from the extra name.
250250
* `:url` - The external url to link to from the sidebar.
251251
252+
Bare filenames such as `[Intro](intro.md)` use the legacy filename-based lookup against the flattened output.
253+
Links with a directory component, such as `[Intro](guides/intro.md)`, `[Intro](../guides/intro.md)`, or
254+
`[Intro](/guides/intro.md)`, are resolved against the extra source path (or project root for `/`).
255+
252256
### Customizing search data
253257
254258
It is possible to fully customize the way a given extra is indexed, both in autocomplete and in search.

lib/ex_doc/autolink.ex

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ defmodule ExDoc.Autolink do
5252
:language,
5353
file: "nofile",
5454
apps: [],
55-
extras: [],
55+
extras: %{},
5656
deps: [],
5757
ext: ".html",
5858
current_kfa: nil,
@@ -214,20 +214,86 @@ defmodule ExDoc.Autolink do
214214
@builtin_ext [".livemd", ".cheatmd", ".md", ".txt", ""]
215215

216216
defp build_extra_link(link, config) do
217-
with %{scheme: nil, host: nil, path: path} = uri <- URI.parse(link),
218-
true <- is_binary(path) and path != "" and not (path =~ ref_regex()),
219-
true <- Path.extname(path) in @builtin_ext do
220-
if file = config.extras[Path.basename(path)] do
221-
append_fragment(file <> config.ext, uri.fragment)
222-
else
217+
case extra_link_target(link, config) do
218+
{:ok, target, fragment} ->
219+
append_fragment(target <> config.ext, fragment)
220+
221+
{:missing, path} ->
223222
maybe_warn(config, nil, nil, %{file_path: path, original_text: link})
224223
nil
225-
end
224+
225+
:ignore ->
226+
nil
227+
end
228+
end
229+
230+
defp extra_link_target(link, config) do
231+
case parse_extra_link(link) do
232+
{:ok, path, fragment} ->
233+
extra_link_target(path, fragment, config)
234+
235+
{:error, :invalid} ->
236+
:ignore
237+
end
238+
end
239+
240+
defp extra_link_target(path, fragment, config) do
241+
case resolve_extra_target(path, config) do
242+
nil -> {:missing, path}
243+
target -> {:ok, target, fragment}
244+
end
245+
end
246+
247+
defp parse_extra_link(link) do
248+
case URI.parse(link) do
249+
%{scheme: nil, host: nil, path: path, fragment: fragment} when is_binary(path) ->
250+
if valid_extra_link_path?(path) do
251+
{:ok, path, fragment}
252+
else
253+
{:error, :invalid}
254+
end
255+
256+
_ ->
257+
{:error, :invalid}
258+
end
259+
end
260+
261+
defp valid_extra_link_path?(path) do
262+
path != "" and not (path =~ ref_regex()) and Path.extname(path) in @builtin_ext
263+
end
264+
265+
defp resolve_extra_target(path, config) do
266+
if path_qualified_link?(path) do
267+
config.extras[normalize_extra_link_path(path, config.file)]
226268
else
227-
_ -> nil
269+
config.extras[Path.basename(path)]
228270
end
229271
end
230272

273+
defp path_qualified_link?(path), do: path != Path.basename(path)
274+
275+
defp normalize_extra_link_path("/" <> path, _current_file) do
276+
normalize_extra_link_path_from(path, File.cwd!())
277+
end
278+
279+
defp normalize_extra_link_path(path, current_file) do
280+
normalize_extra_link_path_from(path, extra_link_base_dir(current_file))
281+
end
282+
283+
defp normalize_extra_link_path_from(path, base_dir) do
284+
path
285+
|> Path.expand(base_dir)
286+
|> Path.relative_to(File.cwd!())
287+
end
288+
289+
defp extra_link_base_dir(file) when is_binary(file) do
290+
file
291+
|> Path.expand(File.cwd!())
292+
|> Path.dirname()
293+
end
294+
295+
defp extra_link_base_dir(_), do: File.cwd!()
296+
231297
defp maybe_remove_link(nil, :custom_link) do
232298
:remove_link
233299
end

lib/ex_doc/formatter.ex

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,11 +313,21 @@ defmodule ExDoc.Formatter do
313313
acc
314314

315315
%ExDoc.ExtraNode{source_path: source_path, id: id}, acc when is_binary(source_path) ->
316-
base = Path.basename(source_path)
317-
Map.put(acc, base, id)
316+
path = normalize_extra_path(source_path)
317+
base = Path.basename(path)
318+
319+
acc
320+
|> Map.put(path, id)
321+
|> Map.put(base, id)
318322

319323
_extra, acc ->
320324
acc
321325
end)
322326
end
327+
328+
defp normalize_extra_path(path) do
329+
path
330+
|> Path.relative_to(File.cwd!())
331+
|> String.replace_leading("./", "")
332+
end
323333
end

test/ex_doc/language/elixir_test.exs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,9 @@ defmodule ExDoc.Language.ElixirTest do
259259

260260
test "extras" do
261261
opts = [
262+
file: "guides/current.md",
262263
extras: %{
264+
"guide/Foo Bar.md" => "foo-bar",
263265
"Foo Bar.md" => "foo-bar",
264266
"Bar Baz.livemd" => "bar-baz",
265267
"Bar Baz.cheatmd" => "bar-baz"
@@ -286,6 +288,48 @@ defmodule ExDoc.Language.ElixirTest do
286288
assert autolink_doc("[Foo](#baz)", opts) == ~s|<a href="#baz">Foo</a>|
287289
end
288290

291+
test "extras relative paths use the extra source path" do
292+
opts = [
293+
file: "guides/current.md",
294+
extras: %{"guide/Foo Bar.md" => "foo-bar", "Foo Bar.md" => "foo-bar"}
295+
]
296+
297+
assert autolink_doc("[Foo](../guide/Foo Bar.md)", opts) ==
298+
~s|<a href="foo-bar.html">Foo</a>|
299+
300+
assert autolink_doc("[Foo](/guide/Foo Bar.md)", opts) ==
301+
~s|<a href="foo-bar.html">Foo</a>|
302+
end
303+
304+
test "bare filename links use legacy lookup but directory paths use source paths" do
305+
opts = [
306+
file: "guides/current.md",
307+
extras: %{"guides/Foo Bar.md" => "relative-foo", "Foo Bar.md" => "legacy-foo"}
308+
]
309+
310+
assert autolink_doc("[Foo](Foo Bar.md)", opts) ==
311+
~s|<a href="legacy-foo.html">Foo</a>|
312+
313+
assert autolink_doc("[Foo](./Foo Bar.md)", opts) ==
314+
~s|<a href="relative-foo.html">Foo</a>|
315+
316+
assert autolink_doc("[Foo](../guides/Foo Bar.md)", opts) ==
317+
~s|<a href="relative-foo.html">Foo</a>|
318+
end
319+
320+
test "extras with bad directories warn instead of silently matching by basename" do
321+
opts = [
322+
warnings: :send,
323+
file: "guides/current.md",
324+
extras: %{"guide/Foo Bar.md" => "foo-bar", "Foo Bar.md" => "foo-bar"}
325+
]
326+
327+
assert warn(fn ->
328+
assert autolink_doc("[Foo](/bad_dir/Foo Bar.md)", opts) ==
329+
~s|<a href="/bad_dir/Foo Bar.md">Foo</a>|
330+
end) =~ ~s|documentation references file "/bad_dir/Foo Bar.md" but it does not exist|
331+
end
332+
289333
test "special case links" do
290334
assert autolink_doc("`//2`") ==
291335
~s|<a href="https://hexdocs.pm/elixir/Kernel.html#//2"><code class="inline">//2</code></a>|

test/ex_doc/language/erlang_test.exs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,16 @@ defmodule ExDoc.Language.ErlangTest do
669669
extras: %{"Foo Bar.md" => "foo-bar", "Bar Baz.livemd" => "bar-baz"}
670670
]
671671

672+
@relative_opts [
673+
file: "guides/current.md",
674+
extras: %{
675+
"guide/Foo Bar.md" => "foo-bar",
676+
"guide/Bar Baz.livemd" => "bar-baz",
677+
"Foo Bar.md" => "foo-bar",
678+
"Bar Baz.livemd" => "bar-baz"
679+
}
680+
]
681+
672682
test "extras", c do
673683
assert autolink_doc("[Foo](Foo Bar.md)", c, @opts) ==
674684
~s|<a href="foo-bar.html">Foo</a>|
@@ -690,7 +700,7 @@ defmodule ExDoc.Language.ErlangTest do
690700
end
691701

692702
test "extras relative", c do
693-
assert autolink_doc("[Foo](../guide/Foo Bar.md)", c, @opts) ==
703+
assert autolink_doc("[Foo](../guide/Foo Bar.md)", c, @relative_opts) ==
694704
~s|<a href="foo-bar.html">Foo</a>|
695705
end
696706
end

0 commit comments

Comments
 (0)