1
+ import Kernel , except: [ to_string: 1 ]
2
+
1
3
defmodule ExDoc.DocAST do
2
4
# General helpers for dealing with the documentation AST
3
5
# (which is the Markdown -> HTML AST).
@@ -31,35 +33,32 @@ defmodule ExDoc.DocAST do
31
33
@ doc """
32
34
Transform AST into string.
33
35
"""
34
- def to_string ( ast , fun \\ fn _ast , string -> string end )
36
+ def to_string ( binary ) do
37
+ IO . iodata_to_binary ( to_iodata ( binary ) )
38
+ end
35
39
36
- def to_string ( binary , _fun ) when is_binary ( binary ) do
40
+ defp to_iodata ( binary ) when is_binary ( binary ) do
37
41
ExDoc.Utils . h ( binary )
38
42
end
39
43
40
- def to_string ( list , fun ) when is_list ( list ) do
41
- result = Enum . map_join ( list , "" , & to_string ( & 1 , fun ) )
42
- fun . ( list , result )
44
+ defp to_iodata ( list ) when is_list ( list ) do
45
+ Enum . map ( list , & to_iodata / 1 )
43
46
end
44
47
45
- def to_string ( { :comment , _attrs , inner , _meta } = ast , fun ) do
46
- fun . ( ast , "<!--#{ inner } -->" )
48
+ defp to_iodata ( { :comment , _attrs , inner , _meta } ) do
49
+ [ "<!--" , inner , " -->"]
47
50
end
48
51
49
- def to_string ( { tag , attrs , _inner , _meta } = ast , fun ) when tag in @ void_elements do
50
- result = "<#{ tag } #{ ast_attributes_to_string ( attrs ) } />"
51
- fun . ( ast , result )
52
+ defp to_iodata ( { tag , attrs , _inner , _meta } ) when tag in @ void_elements do
53
+ "<#{ tag } #{ ast_attributes_to_string ( attrs ) } />"
52
54
end
53
55
54
- def to_string ( { tag , attrs , inner , % { verbatim: true } } = ast , fun ) do
55
- inner = Enum . join ( inner , "" )
56
- result = "<#{ tag } #{ ast_attributes_to_string ( attrs ) } >" <> inner <> "</#{ tag } >"
57
- fun . ( ast , result )
56
+ defp to_iodata ( { tag , attrs , inner , % { verbatim: true } } ) do
57
+ [ "<#{ tag } #{ ast_attributes_to_string ( attrs ) } >" , inner , "</#{ tag } >" ]
58
58
end
59
59
60
- def to_string ( { tag , attrs , inner , _meta } = ast , fun ) do
61
- result = "<#{ tag } #{ ast_attributes_to_string ( attrs ) } >" <> to_string ( inner , fun ) <> "</#{ tag } >"
62
- fun . ( ast , result )
60
+ defp to_iodata ( { tag , attrs , inner , _meta } ) do
61
+ [ "<#{ tag } #{ ast_attributes_to_string ( attrs ) } >" , to_iodata ( inner ) , "</#{ tag } >" ]
63
62
end
64
63
65
64
defp ast_attributes_to_string ( attrs ) do
@@ -86,17 +85,17 @@ defmodule ExDoc.DocAST do
86
85
case content do
87
86
# if we already have <pre><code>...</code></pre>, carry on
88
87
[ { :code , _ , _ } ] ->
89
- { :pre , attrs , parse_erl_ast ( content ) , % { } }
88
+ { :pre , attrs , parse_erl_ast ( List . wrap ( content ) ) , % { } }
90
89
91
90
# otherwise, turn <pre>...</pre> into <pre><code>...</code></pre>
92
91
_ ->
93
- content = [ { :code , [ ] , parse_erl_ast ( content ) , % { } } ]
92
+ content = [ { :code , [ ] , parse_erl_ast ( List . wrap ( content ) ) , % { } } ]
94
93
{ :pre , attrs , content , % { } }
95
94
end
96
95
end
97
96
98
97
defp parse_erl_ast ( { tag , attrs , content } ) when is_atom ( tag ) do
99
- { tag , attrs , parse_erl_ast ( content ) , % { } }
98
+ { tag , attrs , parse_erl_ast ( List . wrap ( content ) ) , % { } }
100
99
end
101
100
102
101
@ doc """
@@ -110,6 +109,49 @@ defmodule ExDoc.DocAST do
110
109
def extract_title ( [ { :h1 , _attrs , inner , _meta } | ast ] ) , do: { :ok , inner , ast }
111
110
def extract_title ( _ast ) , do: :error
112
111
112
+ @ doc """
113
+ Extracts the headers which have anchors (aka ids) in them.
114
+ """
115
+ def extract_headers_with_ids ( ast , headers ) do
116
+ ast
117
+ |> reduce_tags ( [ ] , fn { tag , attrs , inner , _ } , acc ->
118
+ with true <- tag in headers ,
119
+ id = Keyword . get ( attrs , :id , "" ) ,
120
+ text = ExDoc.DocAST . text ( inner ) ,
121
+ true <- id != "" and text != "" do
122
+ [ { tag , text , id } | acc ]
123
+ else
124
+ _ -> acc
125
+ end
126
+ end )
127
+ |> Enum . reverse ( )
128
+ end
129
+
130
+ @ doc """
131
+ Adds an id attribute to the given headers.
132
+
133
+ A prefix for the id attribute can be given,
134
+ which is automatically URL encoded to avoid
135
+ issues.
136
+ """
137
+ def add_ids_to_headers ( doc_ast , headers , prefix \\ "" ) do
138
+ prefix = URI . encode ( prefix )
139
+
140
+ doc_ast
141
+ |> map_reduce_tags ( % { } , fn { tag , attrs , inner , meta } = ast , seen ->
142
+ if tag in headers and not Keyword . has_key? ( attrs , :id ) do
143
+ possible_id = inner |> text ( ) |> ExDoc.Utils . text_to_id ( )
144
+ id_count = Map . get ( seen , possible_id , 0 )
145
+ partial_id = if id_count >= 1 , do: "#{ possible_id } -#{ id_count } " , else: possible_id
146
+ seen = Map . put ( seen , possible_id , id_count + 1 )
147
+ { { tag , [ id: prefix <> partial_id ] ++ attrs , inner , meta } , seen }
148
+ else
149
+ { ast , seen }
150
+ end
151
+ end )
152
+ |> elem ( 0 )
153
+ end
154
+
113
155
@ doc """
114
156
Compute a synopsis from a document by looking at its first paragraph.
115
157
"""
@@ -132,41 +174,43 @@ defmodule ExDoc.DocAST do
132
174
@ doc """
133
175
Remove ids from elements.
134
176
"""
135
- def remove_ids ( { tag , attrs , inner , meta } ) ,
136
- do: { tag , Keyword . delete ( attrs , :href ) , remove_ids ( inner ) , meta }
137
-
138
- def remove_ids ( list ) when is_list ( list ) ,
139
- do: Enum . map ( list , & remove_ids / 1 )
140
-
141
- def remove_ids ( other ) ,
142
- do: other
177
+ def remove_ids ( ast ) do
178
+ map_tags ( ast , fn { tag , attrs , inner , meta } ->
179
+ { tag , Keyword . delete ( attrs , :href ) , inner , meta }
180
+ end )
181
+ end
143
182
144
183
@ doc """
145
184
Returns text content from the given AST.
146
185
"""
147
- def text ( ast ) do
186
+ def text ( ast , joiner \\ "" ) do
148
187
ast
149
- |> do_text ( )
188
+ |> do_text ( joiner )
150
189
|> IO . iodata_to_binary ( )
151
190
|> String . trim ( )
152
191
end
153
192
154
- defp do_text ( ast ) when is_list ( ast ) , do: Enum . map ( ast , & do_text / 1 )
155
- defp do_text ( ast ) when is_binary ( ast ) , do: ast
156
- defp do_text ( { _tag , _attr , ast , _meta } ) , do: text ( ast )
193
+ defp do_text ( ast , joiner ) when is_list ( ast ) ,
194
+ do: Enum . map_intersperse ( ast , joiner , & do_text ( & 1 , joiner ) )
195
+
196
+ defp do_text ( ast , _joiner ) when is_binary ( ast ) ,
197
+ do: ast
198
+
199
+ defp do_text ( { _tag , _attr , ast , _meta } , joiner ) ,
200
+ do: do_text ( ast , joiner )
157
201
158
202
@ doc """
159
- Wraps a list of HTML nodes into `<section>` tags whenever `matcher ` returns true.
203
+ Wraps a list of HTML nodes into `<section>` tags whenever `headers ` returns true.
160
204
"""
161
- def sectionize ( list , matcher ) , do: sectionize ( list , matcher , [ ] )
205
+ def sectionize ( list , headers ) , do: sectionize ( list , headers , [ ] )
162
206
163
- defp sectionize ( list , matcher , acc ) do
164
- case pivot ( list , acc , matcher ) do
207
+ defp sectionize ( list , headers , acc ) do
208
+ case pivot ( list , acc , headers ) do
165
209
{ acc , { header_tag , header_attrs , _ , _ } = header , rest } ->
166
210
{ inner , rest } = Enum . split_while ( rest , & not_tag? ( & 1 , header_tag ) )
167
211
class = String . trim_trailing ( "#{ header_tag } #{ header_attrs [ :class ] } " )
168
- section = { :section , [ class: class ] , [ header | sectionize ( inner , matcher , [ ] ) ] , % { } }
169
- sectionize ( rest , matcher , [ section | acc ] )
212
+ section = { :section , [ class: class ] , [ header | sectionize ( inner , headers , [ ] ) ] , % { } }
213
+ sectionize ( rest , headers , [ section | acc ] )
170
214
171
215
acc ->
172
216
acc
@@ -176,53 +220,63 @@ defmodule ExDoc.DocAST do
176
220
defp not_tag? ( { tag , _ , _ , _ } , tag ) , do: false
177
221
defp not_tag? ( _ , _tag ) , do: true
178
222
179
- defp pivot ( [ head | tail ] , acc , fun ) do
180
- case fun . ( head ) do
181
- true -> { acc , head , tail }
182
- false -> pivot ( tail , [ head | acc ] , fun )
223
+ defp pivot ( [ { tag , _ , _ , _ } = head | tail ] , acc , headers ) do
224
+ if tag in headers do
225
+ { acc , head , tail }
226
+ else
227
+ pivot ( tail , [ head | acc ] , headers )
183
228
end
184
229
end
185
230
186
- defp pivot ( [ ] , acc , _fun ) , do: Enum . reverse ( acc )
231
+ defp pivot ( [ head | tail ] , acc , headers ) , do: pivot ( tail , [ head | acc ] , headers )
232
+ defp pivot ( [ ] , acc , _headers ) , do: Enum . reverse ( acc )
187
233
188
234
@ doc """
189
- Highlights a DocAST converted to string .
235
+ Highlights the code blocks in the AST .
190
236
"""
191
- # TODO: Could this be done over the AST instead?
192
- def highlight ( html , language , opts \\ [ ] ) do
237
+ def highlight ( ast , language , opts \\ [ ] ) do
193
238
highlight_info = language . highlight_info ( )
194
239
195
- ## Html cannot be parsed with regex, but we try our best...
196
- Regex . replace (
197
- ~r/ <pre(\s [^>]*)?><code(?:\s +class="([^"\s ]*)")?>([^<]*)<\/ code><\/ pre>/ ,
198
- html ,
199
- & highlight_code_block ( & 1 , & 2 , & 3 , & 4 , highlight_info , opts )
200
- )
201
- end
202
-
203
- defp highlight_code_block ( full_block , pre_attr , lang , code , highlight_info , outer_opts ) do
204
- case pick_language_and_lexer ( lang , highlight_info , code ) do
205
- { _language , nil , _opts } ->
206
- full_block
207
-
208
- { lang , lexer , opts } ->
209
- try do
210
- render_code ( pre_attr , lang , lexer , opts , code , outer_opts )
211
- rescue
212
- exception ->
213
- ExDoc.Utils . warn (
214
- [
215
- "crashed while highlighting #{ lang } snippet:\n \n " ,
216
- full_block ,
217
- "\n \n " ,
218
- Exception . format_banner ( :error , exception , __STACKTRACE__ )
219
- ] ,
220
- __STACKTRACE__
221
- )
222
-
223
- full_block
240
+ map_tags ( ast , fn
241
+ { :pre , pre_attrs , [ { :code , code_attrs , [ code ] , code_meta } ] , pre_meta } = ast
242
+ when is_binary ( code ) ->
243
+ { lang , code_attrs } = Keyword . pop ( code_attrs , :class , "" )
244
+
245
+ case pick_language_and_lexer ( lang , highlight_info , code ) do
246
+ { _lang , nil , _lexer_opts } ->
247
+ ast
248
+
249
+ { lang , lexer , lexer_opts } ->
250
+ try do
251
+ Makeup . highlight_inner_html ( code ,
252
+ lexer: lexer ,
253
+ lexer_options: lexer_opts ,
254
+ formatter_options: opts
255
+ )
256
+ rescue
257
+ exception ->
258
+ ExDoc.Utils . warn (
259
+ [
260
+ "crashed while highlighting #{ lang } snippet:\n \n " ,
261
+ ExDoc.DocAST . to_string ( ast ) ,
262
+ "\n \n " ,
263
+ Exception . format_banner ( :error , exception , __STACKTRACE__ )
264
+ ] ,
265
+ __STACKTRACE__
266
+ )
267
+
268
+ ast
269
+ else
270
+ highlighted ->
271
+ code_attrs = [ class: "makeup #{ lang } " , translate: "no" ] ++ code_attrs
272
+ code_meta = Map . put ( code_meta , :verbatim , true )
273
+ { :pre , pre_attrs , [ { :code , code_attrs , [ highlighted ] , code_meta } ] , pre_meta }
274
+ end
224
275
end
225
- end
276
+
277
+ ast ->
278
+ ast
279
+ end )
226
280
end
227
281
228
282
defp pick_language_and_lexer ( "" , _highlight_info , "$ " <> _ ) do
@@ -244,35 +298,43 @@ defmodule ExDoc.DocAST do
244
298
end
245
299
end
246
300
247
- defp render_code ( pre_attr , lang , lexer , lexer_opts , code , opts ) do
248
- highlight_tag = Keyword . get ( opts , :highlight_tag , "span" )
301
+ ## Traversal helpers
249
302
250
- highlighted =
251
- code
252
- |> unescape_html ( )
253
- |> IO . iodata_to_binary ( )
254
- |> Makeup . highlight_inner_html (
255
- lexer: lexer ,
256
- lexer_options: lexer_opts ,
257
- formatter_options: [ highlight_tag: highlight_tag ]
258
- )
303
+ @ doc """
304
+ Maps the tags in the AST, first mapping children tags, then the tag itself.
305
+ """
306
+ def map_tags ( { tag , attrs , inner , meta } , fun ) ,
307
+ do: fun . ( { tag , attrs , Enum . map ( inner , & map_tags ( & 1 , fun ) ) , meta } )
259
308
260
- ~s ( <pre #{ pre_attr } ><code class="makeup #{ lang } " translate="no"> #{ highlighted } </code></pre> )
261
- end
309
+ def map_tags ( list , fun ) when is_list ( list ) ,
310
+ do: Enum . map ( list , & map_tags ( & 1 , fun ) )
262
311
263
- entities = [ { "&" , ?& } , { "<" , ?< } , { ">" , ?> } , { """ , ?" } , { "'" , ?' } ]
312
+ def map_tags ( other , _fun ) ,
313
+ do: other
264
314
265
- for { encoded , decoded } <- entities do
266
- defp unescape_html ( unquote ( encoded ) <> rest ) do
267
- [ unquote ( decoded ) | unescape_html ( rest ) ]
268
- end
269
- end
315
+ @ doc """
316
+ Reduces the tags in the AST, first reducing children tags, then the tag itself.
317
+ """
318
+ def reduce_tags ( { tag , attrs , inner , meta } , acc , fun ) ,
319
+ do: fun . ( { tag , attrs , inner , meta } , Enum . reduce ( inner , acc , & reduce_tags ( & 1 , & 2 , fun ) ) )
270
320
271
- defp unescape_html ( << c , rest :: binary >> ) do
272
- [ c | unescape_html ( rest ) ]
273
- end
321
+ def reduce_tags ( list , acc , fun ) when is_list ( list ) ,
322
+ do: Enum . reduce ( list , acc , & reduce_tags ( & 1 , & 2 , fun ) )
323
+
324
+ def reduce_tags ( _other , acc , _fun ) ,
325
+ do: acc
274
326
275
- defp unescape_html ( << >> ) do
276
- [ ]
327
+ @ doc """
328
+ Map-reduces the tags in the AST, first mapping children tags, then the tag itself.
329
+ """
330
+ def map_reduce_tags ( { tag , attrs , inner , meta } , acc , fun ) do
331
+ { inner , acc } = Enum . map_reduce ( inner , acc , & map_reduce_tags ( & 1 , & 2 , fun ) )
332
+ fun . ( { tag , attrs , inner , meta } , acc )
277
333
end
334
+
335
+ def map_reduce_tags ( list , acc , fun ) when is_list ( list ) ,
336
+ do: Enum . map_reduce ( list , acc , & map_reduce_tags ( & 1 , & 2 , fun ) )
337
+
338
+ def map_reduce_tags ( other , acc , _fun ) ,
339
+ do: { other , acc }
278
340
end
0 commit comments