Skip to content

Commit 8bbc037

Browse files
committed
Add more DocAST helpers
1 parent 6961df5 commit 8bbc037

File tree

3 files changed

+196
-79
lines changed

3 files changed

+196
-79
lines changed

lib/ex_doc/doc_ast.ex

Lines changed: 123 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,43 @@ defmodule ExDoc.DocAST do
122122
do: text
123123
end
124124

125+
@doc """
126+
Extracts the headers which have anchors (aka ids) in them.
127+
"""
128+
def extract_headers_with_ids(ast, headers) do
129+
ast
130+
|> reduce_tags([], fn {tag, attrs, inner, _}, acc ->
131+
with true <- tag in headers,
132+
id = Keyword.get(attrs, :id, ""),
133+
text = ExDoc.DocAST.text(inner),
134+
true <- id != "" and text != "" do
135+
[{tag, text, id} | acc]
136+
else
137+
_ -> acc
138+
end
139+
end)
140+
|> Enum.reverse()
141+
end
142+
143+
@doc """
144+
Adds an id attribute to the given headers.
145+
"""
146+
def add_ids_to_headers(doc_ast, headers) do
147+
doc_ast
148+
|> map_reduce_tags(%{}, fn {tag, attrs, inner, meta} = ast, seen ->
149+
if tag in headers and not Keyword.has_key?(attrs, :id) do
150+
possible_id = inner |> text() |> ExDoc.Utils.text_to_id()
151+
id_count = Map.get(seen, possible_id, 0)
152+
actual_id = if id_count >= 1, do: "#{possible_id}-#{id_count}", else: possible_id
153+
seen = Map.put(seen, possible_id, id_count + 1)
154+
{{tag, [id: actual_id] ++ attrs, inner, meta}, seen}
155+
else
156+
{ast, seen}
157+
end
158+
end)
159+
|> elem(0)
160+
end
161+
125162
@doc """
126163
Compute a synopsis from a document by looking at its first paragraph.
127164
"""
@@ -144,14 +181,11 @@ defmodule ExDoc.DocAST do
144181
@doc """
145182
Remove ids from elements.
146183
"""
147-
def remove_ids({tag, attrs, inner, meta}),
148-
do: {tag, Keyword.delete(attrs, :href), remove_ids(inner), meta}
149-
150-
def remove_ids(list) when is_list(list),
151-
do: Enum.map(list, &remove_ids/1)
152-
153-
def remove_ids(other),
154-
do: other
184+
def remove_ids(ast) do
185+
map_tags(ast, fn {tag, attrs, inner, meta} ->
186+
{tag, Keyword.delete(attrs, :href), inner, meta}
187+
end)
188+
end
155189

156190
@doc """
157191
Returns text content from the given AST.
@@ -199,57 +233,52 @@ defmodule ExDoc.DocAST do
199233
defp pivot([head | tail], acc, headers), do: pivot(tail, [head | acc], headers)
200234
defp pivot([], acc, _headers), do: Enum.reverse(acc)
201235

202-
def highlight(html, language, opts \\ []) do
203-
do_highlight(html, language.highlight_info(), opts)
204-
end
205-
206-
defp do_highlight(
207-
{:pre, pre_attrs, [{:code, code_attrs, [code], code_meta}], pre_meta} = ast,
208-
highlight_info,
209-
opts
210-
)
211-
when is_binary(code) do
212-
{lang, code_attrs} = Keyword.pop(code_attrs, :class, "")
213-
214-
case pick_language_and_lexer(lang, highlight_info, code) do
215-
{_lang, nil, _lexer_opts} ->
216-
ast
236+
@doc """
237+
Highlights the code blocks in the AST.
238+
"""
239+
def highlight(ast, language, opts \\ []) do
240+
highlight_info = language.highlight_info()
217241

218-
{lang, lexer, lexer_opts} ->
219-
try do
220-
Makeup.highlight_inner_html(code,
221-
lexer: lexer,
222-
lexer_options: lexer_opts,
223-
formatter_options: opts
224-
)
225-
rescue
226-
exception ->
227-
ExDoc.Utils.warn(
228-
[
229-
"crashed while highlighting #{lang} snippet:\n\n",
230-
ExDoc.DocAST.to_string(ast),
231-
"\n\n",
232-
Exception.format_banner(:error, exception, __STACKTRACE__)
233-
],
234-
__STACKTRACE__
235-
)
242+
map_tags(ast, fn
243+
{:pre, pre_attrs, [{:code, code_attrs, [code], code_meta}], pre_meta} = ast
244+
when is_binary(code) ->
245+
{lang, code_attrs} = Keyword.pop(code_attrs, :class, "")
236246

247+
case pick_language_and_lexer(lang, highlight_info, code) do
248+
{_lang, nil, _lexer_opts} ->
237249
ast
238-
else
239-
highlighted ->
240-
code_attrs = [class: "makeup #{lang}", translate: "no"] ++ code_attrs
241-
code_meta = Map.put(code_meta, :verbatim, true)
242-
{:pre, pre_attrs, [{:code, code_attrs, [highlighted], code_meta}], pre_meta}
243-
end
244-
end
245-
end
246250

247-
defp do_highlight(list, highlight_info, opts) when is_list(list) do
248-
Enum.map(list, &do_highlight(&1, highlight_info, opts))
249-
end
251+
{lang, lexer, lexer_opts} ->
252+
try do
253+
Makeup.highlight_inner_html(code,
254+
lexer: lexer,
255+
lexer_options: lexer_opts,
256+
formatter_options: opts
257+
)
258+
rescue
259+
exception ->
260+
ExDoc.Utils.warn(
261+
[
262+
"crashed while highlighting #{lang} snippet:\n\n",
263+
ExDoc.DocAST.to_string(ast),
264+
"\n\n",
265+
Exception.format_banner(:error, exception, __STACKTRACE__)
266+
],
267+
__STACKTRACE__
268+
)
269+
270+
ast
271+
else
272+
highlighted ->
273+
code_attrs = [class: "makeup #{lang}", translate: "no"] ++ code_attrs
274+
code_meta = Map.put(code_meta, :verbatim, true)
275+
{:pre, pre_attrs, [{:code, code_attrs, [highlighted], code_meta}], pre_meta}
276+
end
277+
end
250278

251-
defp do_highlight(other, _highlight_info, _opts) do
252-
other
279+
ast ->
280+
ast
281+
end)
253282
end
254283

255284
defp pick_language_and_lexer("", _highlight_info, "$ " <> _) do
@@ -270,4 +299,44 @@ defmodule ExDoc.DocAST do
270299
:error -> {lang, nil, []}
271300
end
272301
end
302+
303+
## Traversal helpers
304+
305+
@doc """
306+
Maps the tags in the AST, first mapping children tags, then the tag itself.
307+
"""
308+
def map_tags({tag, attrs, inner, meta}, fun),
309+
do: fun.({tag, attrs, Enum.map(inner, &map_tags(&1, fun)), meta})
310+
311+
def map_tags(list, fun) when is_list(list),
312+
do: Enum.map(list, &map_tags(&1, fun))
313+
314+
def map_tags(other, _fun),
315+
do: other
316+
317+
@doc """
318+
Reduces the tags in the AST, first reducing children tags, then the tag itself.
319+
"""
320+
def reduce_tags({tag, attrs, inner, meta}, acc, fun),
321+
do: fun.({tag, attrs, inner, meta}, Enum.reduce(inner, acc, &reduce_tags(&1, &2, fun)))
322+
323+
def reduce_tags(list, acc, fun) when is_list(list),
324+
do: Enum.reduce(list, acc, &reduce_tags(&1, &2, fun))
325+
326+
def reduce_tags(_other, acc, _fun),
327+
do: acc
328+
329+
@doc """
330+
Map-reduces the tags in the AST, first mapping children tags, then the tag itself.
331+
"""
332+
def map_reduce_tags({tag, attrs, inner, meta}, acc, fun) do
333+
{inner, acc} = Enum.map_reduce(inner, acc, &map_reduce_tags(&1, &2, fun))
334+
fun.({tag, attrs, inner, meta}, acc)
335+
end
336+
337+
def map_reduce_tags(list, acc, fun) when is_list(list),
338+
do: Enum.map_reduce(list, acc, &map_reduce_tags(&1, &2, fun))
339+
340+
def map_reduce_tags(other, acc, _fun),
341+
do: {other, acc}
273342
end

lib/ex_doc/formatter/html.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ defmodule ExDoc.Formatter.HTML do
414414
case extension_name(input) do
415415
extension when extension in ["", ".txt"] ->
416416
source = File.read!(input)
417-
ast = [{:pre, [], "\n" <> source, %{}}]
417+
ast = [{:pre, [], ["\n" <> source], %{}}]
418418
{source, ast}
419419

420420
extension when extension in [".md", ".livemd", ".cheatmd"] ->

test/ex_doc/doc_ast_test.exs

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ defmodule ExDoc.DocASTTest do
126126

127127
defp synopsis(markdown) do
128128
markdown
129-
|> ExDoc.DocAST.parse!("text/markdown")
130-
|> ExDoc.DocAST.synopsis()
129+
|> DocAST.parse!("text/markdown")
130+
|> DocAST.synopsis()
131131
end
132132
end
133133

@@ -151,8 +151,36 @@ defmodule ExDoc.DocASTTest do
151151

152152
defp extract_headers(markdown) do
153153
markdown
154-
|> ExDoc.DocAST.parse!("text/markdown")
155-
|> ExDoc.DocAST.extract_headers([:h2])
154+
|> DocAST.parse!("text/markdown")
155+
|> DocAST.extract_headers([:h2])
156+
end
157+
end
158+
159+
describe "headers" do
160+
test "adds and extracts anchored headers" do
161+
assert """
162+
# h1
163+
164+
## h2
165+
166+
### h3 repeat
167+
168+
## h2 > h3
169+
170+
### h3 repeat
171+
172+
> ## inside `blockquote`
173+
"""
174+
|> DocAST.parse!("text/markdown")
175+
|> DocAST.add_ids_to_headers([:h2, :h3])
176+
|> DocAST.extract_headers_with_ids([:h2, :h3]) ==
177+
[
178+
{:h2, "h2", "h2"},
179+
{:h3, "h3 repeat", "h3-repeat"},
180+
{:h2, "h2 > h3", "h2-h3"},
181+
{:h3, "h3 repeat", "h3-repeat-1"},
182+
{:h2, "inside blockquote", "inside-blockquote"}
183+
]
156184
end
157185
end
158186

@@ -196,35 +224,55 @@ defmodule ExDoc.DocASTTest do
196224
```
197225
""") =~
198226
~r{<pre><code class=\"makeup shell\" translate="no"><span class="gp unselectable">\$.*}
227+
228+
# Nested in another element
229+
assert highlight("""
230+
> ```elixir
231+
> hello
232+
> ```
233+
""") =~
234+
~r{<blockquote><pre><code class=\"makeup elixir\" translate="no">.*}
199235
end
200236

201237
defp highlight(markdown) do
202238
markdown
203-
|> ExDoc.DocAST.parse!("text/markdown")
204-
|> ExDoc.DocAST.highlight(ExDoc.Language.Elixir)
205-
|> ExDoc.DocAST.to_string()
239+
|> DocAST.parse!("text/markdown")
240+
|> DocAST.highlight(ExDoc.Language.Elixir)
241+
|> DocAST.to_string()
206242
end
207243
end
208244

209245
describe "sectionize" do
210246
test "sectioninize" do
211-
list = [
212-
{:h1, [], ["H1"], %{}},
213-
{:h2, [class: "example"], ["H2-1"], %{}},
214-
{:p, [], ["p1"], %{}},
215-
{:h3, [], ["H3-1"], %{}},
216-
{:p, [], ["p2"], %{}},
217-
{:h3, [], ["H3-2"], %{}},
218-
{:p, [], ["p3"], %{}},
219-
{:h3, [], ["H3-3"], %{}},
220-
{:p, [], ["p4"], %{}},
221-
{:h2, [], ["H2-2"], %{}},
222-
{:p, [], ["p5"], %{}},
223-
{:h3, [class: "last"], ["H3-1"], %{}},
224-
{:p, [], ["p6"], %{}}
225-
]
226-
227-
assert DocAST.sectionize(list, [:h2, :h3]) ==
247+
assert """
248+
# H1
249+
250+
## H2-1 {:class="example"}
251+
252+
p1
253+
254+
### H3-1
255+
256+
p2
257+
258+
### H3-2
259+
260+
p3
261+
262+
### H3-3
263+
264+
p4
265+
266+
## H2-2
267+
268+
p5
269+
270+
### H3-1 {:class="last"}
271+
272+
p6
273+
"""
274+
|> DocAST.parse!("text/markdown")
275+
|> DocAST.sectionize([:h2, :h3]) ==
228276
[
229277
{:h1, [], ["H1"], %{}},
230278
{:section, [class: "h2 example"],

0 commit comments

Comments
 (0)