Current section

86 Versions

Jump to

Compare versions

12 files changed
+514 additions
-376 deletions
  @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
7 7
8 8 ## [Unreleased][unreleased]
9 9
10 + ## [0.38.0] - 2025-06-14
11 +
12 + ### Added
13 +
14 + - This version adds initial support for the `:has` pseudo-selector.
15 + It is a great addition that enables finding elements containing
16 + matching children.
17 +
18 + Examples for selectors:
19 +
20 + * `"div:has(h1)"`
21 + * `"div:has(h1, p, span)"`
22 + * `"div:has(p.foo)"`
23 + * `"div:has(img[src='https://example.com'])"`
24 + * `"tr:has(*:fl-contains('TEST'))"`
25 +
26 + Note that combinators like `">"` are not allowed yet.
27 +
28 + Thank you [@bvobart](https://github.com/bvobart) for this feature!
29 +
30 + ### Fixed
31 +
32 + - Add `:style` option documentation to `Floki.text/2`.
33 + Thanks [@s3cur3](https://github.com/s3cur3) for the fix.
34 +
35 + - Fix deprecation warnings for upcoming Elixir 1.19.
36 +
37 + - Prevent from crashing when selector is an empty string.
38 +
39 + ### Removed
40 +
41 + - Remove support for Elixir 1.14 and OTP 23.
42 +
43 + - Remove deprecated functions and function clauses
44 + that were accepting strings (binaries).
45 +
46 + Affected functions:
47 +
48 + * `parse/1` - removed function
49 + * `map/2` - removed function
50 + * `attr/4` - removed clause
51 + * `find/2` - removed clause
52 + * `text/3` - removed clause
53 + * `text/3` - removed clause
54 + * `attribute/2` - removed clause
55 + * `filter_out/2` - removed clause
56 +
10 57 ## [0.37.1] - 2025-03-22
11 58
12 59 ### Fixed
  @@ -820,7 +867,8 @@ of the parent element inside HTML.
820 867
821 868 - Elixir version requirement from "~> 1.0.0" to ">= 1.0.0".
822 869
823 - [unreleased]: https://github.com/philss/floki/compare/v0.37.1...HEAD
870 + [unreleased]: https://github.com/philss/floki/compare/v0.38.0...HEAD
871 + [0.38.0]: https://github.com/philss/floki/compare/v0.37.1...v0.38.0
824 872 [0.37.1]: https://github.com/philss/floki/compare/v0.37.0...v0.37.1
825 873 [0.37.0]: https://github.com/philss/floki/compare/v0.36.3...v0.37.0
826 874 [0.36.3]: https://github.com/philss/floki/compare/v0.36.2...v0.36.3
  @@ -16,17 +16,17 @@
16 16 Take this HTML as an example:
17 17
18 18 ```html
19 - <!doctype html>
19 + <!DOCTYPE html>
20 20 <html>
21 - <body>
22 - <section id="content">
23 - <p class="headline">Floki</p>
24 - <span class="headline">Enables search using CSS selectors</span>
25 - <a href="https://github.com/philss/floki">Github page</a>
26 - <span data-model="user">philss</span>
27 - </section>
28 - <a href="https://hex.pm/packages/floki">Hex package</a>
29 - </body>
21 + <body>
22 + <section id="content">
23 + <p class="headline">Floki</p>
24 + <span class="headline">Enables search using CSS selectors</span>
25 + <a href="https://github.com/philss/floki">Github page</a>
26 + <span data-model="user">philss</span>
27 + </section>
28 + <a href="https://hex.pm/packages/floki">Hex package</a>
29 + </body>
30 30 </html>
31 31 ```
32 32
  @@ -40,7 +40,7 @@ Floki.find(document, "p.headline")
40 40
41 41 document
42 42 |> Floki.find("p.headline")
43 - |> Floki.raw_html
43 + |> Floki.raw_html()
44 44 # => <p class="headline">Floki</p>
45 45 ```
46 46
  @@ -61,7 +61,7 @@ Add Floki to your `mix.exs`:
61 61 ```elixir
62 62 defp deps do
63 63 [
64 - {:floki, "~> 0.37.0"}
64 + {:floki, "~> 0.38.0"}
65 65 ]
66 66 end
67 67 ```
  @@ -72,7 +72,7 @@ If you are running on [Livebook](https://livebook.dev) or a script, you can inst
72 72
73 73 ```elixir
74 74 Mix.install([
75 - {:floki, "~> 0.37.0"}
75 + {:floki, "~> 0.38.0"}
76 76 ])
77 77 ```
78 78
  @@ -128,8 +128,8 @@ you don't need to install anything to compile it thanks to [RustlerPrecompiled](
128 128 ```elixir
129 129 defp deps do
130 130 [
131 - {:floki, "~> 0.37.0"},
132 - {:html5ever, "~> 0.15.0"}
131 + {:floki, "~> 0.38.0"},
132 + {:html5ever, "~> 0.16.0"}
133 133 ]
134 134 end
135 135 ```
  @@ -156,7 +156,7 @@ First, add `fast_html` to your dependencies:
156 156 ```elixir
157 157 defp deps do
158 158 [
159 - {:floki, "~> 0.37.0"},
159 + {:floki, "~> 0.38.0"},
160 160 {:fast_html, "~> 2.0"}
161 161 ]
162 162 end
  @@ -201,7 +201,7 @@ To convert your node tree back to raw HTML (spaces are ignored):
201 201 ```elixir
202 202 document
203 203 |> Floki.find(".example")
204 - |> Floki.raw_html
204 + |> Floki.raw_html()
205 205 # => <div class="example"></div>
206 206 ```
207 207
  @@ -226,7 +226,7 @@ If you want to get the text from an element, try:
226 226 ```elixir
227 227 document
228 228 |> Floki.find(".headline")
229 - |> Floki.text
229 + |> Floki.text()
230 230
231 231 # => "Floki"
232 232 ```
  @@ -235,43 +235,50 @@ document
235 235
236 236 Here you find all the [CSS selectors](https://www.w3.org/TR/selectors/#selectors) supported in the current version:
237 237
238 - | Pattern | Description |
239 - |-----------------|------------------------------|
240 - | * | any element |
241 - | E | an element of type `E` |
242 - | E[foo] | an `E` element with a "foo" attribute |
243 - | E[foo="bar"] | an E element whose "foo" attribute value is exactly equal to "bar" |
244 - | E[foo~="bar"] | an E element whose "foo" attribute value is a list of whitespace-separated values, one of which is exactly equal to "bar" |
245 - | E[foo^="bar"] | an E element whose "foo" attribute value begins exactly with the string "bar" |
246 - | E[foo$="bar"] | an E element whose "foo" attribute value ends exactly with the string "bar" |
247 - | E[foo*="bar"] | an E element whose "foo" attribute value contains the substring "bar" |
248 - | E[foo\|="en"] | an E element whose "foo" attribute has a hyphen-separated list of values beginning (from the left) with "en" |
249 - | E:nth-child(n) | an E element, the n-th child of its parent |
250 - | E:nth-last-child(n) | an E element, the n-th child of its parent, counting from bottom to up |
251 - | E:first-child | an E element, first child of its parent |
252 - | E:last-child | an E element, last child of its parent |
253 - | E:nth-of-type(n) | an E element, the n-th child of its type among its siblings |
254 - | E:nth-last-of-type(n) | an E element, the n-th child of its type among its siblings, counting from bottom to up |
255 - | E:first-of-type | an E element, first child of its type among its siblings |
256 - | E:last-of-type | an E element, last child of its type among its siblings |
257 - | E:checked | An E element (checkbox, radio, or option) that is checked |
258 - | E:disabled | An E element (button, input, select, textarea, or option) that is disabled |
259 - | E.warning | an E element whose class is "warning" |
260 - | E#myid | an E element with ID equal to "myid" (for ids containing periods, use `#my\\.id` or `[id="my.id"]`) |
261 - | E:not(s) | an E element that does not match simple selector s |
262 - | :root | the root node or nodes (in case of fragments) of the document. Most of the times this is the `html` tag |
263 - | E F | an F element descendant of an E element |
264 - | E > F | an F element child of an E element |
265 - | E + F | an F element immediately preceded by an E element |
266 - | E ~ F | an F element preceded by an E element |
238 + | Pattern | Description |
239 + | --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
240 + | \* | any element |
241 + | E | an element of type `E` |
242 + | E[foo] | an `E` element with a "foo" attribute |
243 + | E[foo="bar"] | an E element whose "foo" attribute value is exactly equal to "bar" |
244 + | E[foo~="bar"] | an E element whose "foo" attribute value is a list of whitespace-separated values, one of which is exactly equal to "bar" |
245 + | E[foo^="bar"] | an E element whose "foo" attribute value begins exactly with the string "bar" |
246 + | E[foo$="bar"] | an E element whose "foo" attribute value ends exactly with the string "bar" |
247 + | E[foo*="bar"] | an E element whose "foo" attribute value contains the substring "bar" |
248 + | E[foo\|="en"] | an E element whose "foo" attribute has a hyphen-separated list of values beginning (from the left) with "en" |
249 + | E:nth-child(n) | an E element, the n-th child of its parent |
250 + | E:nth-last-child(n) | an E element, the n-th child of its parent, counting from bottom to up |
251 + | E:first-child | an E element, first child of its parent |
252 + | E:last-child | an E element, last child of its parent |
253 + | E:nth-of-type(n) | an E element, the n-th child of its type among its siblings |
254 + | E:nth-last-of-type(n) | an E element, the n-th child of its type among its siblings, counting from bottom to up |
255 + | E:first-of-type | an E element, first child of its type among its siblings |
256 + | E:last-of-type | an E element, last child of its type among its siblings |
257 + | E:checked | An E element (checkbox, radio, or option) that is checked |
258 + | E:disabled | An E element (button, input, select, textarea, or option) that is disabled |
259 + | E.warning | an E element whose class is "warning" |
260 + | E#myid | an E element with ID equal to "myid" (for ids containing periods, use `#my\\.id` or `[id="my.id"]`) |
261 + | E:not(s) | an E element that does not match simple selector s |
262 + | E:has(s) | an E element that has a child element that matches simple selector s |
263 + | E:has(s1, s2) | an E element that has a child element matching simple selector s1 OR s2 |
264 + | E:has(s1):has(s2) | An E element that has a child element matching simple selectors s1 AND s2 |
265 + | :root | the root node or nodes (in case of fragments) of the document. Most of the times this is the `html` tag |
266 + | E F | an F element descendant of an E element |
267 + | E > F | an F element child of an E element |
268 + | E + F | an F element immediately preceded by an E element |
269 + | E ~ F | an F element preceded by an E element |
267 270
268 271 There are also some selectors based on non-standard specifications. They are:
269 272
270 273 | Pattern | Description |
271 - |-----------------------|------------------------------------------------------------------------|
274 + | --------------------- | ---------------------------------------------------------------------- |
272 275 | E:fl-contains('foo') | an E element that contains "foo" inside a text node |
273 276 | E:fl-icontains('foo') | an E element that contains "foo" inside a text node (case insensitive) |
274 277
278 + ### Simple selectors
279 +
280 + The pseudo-selectors `:has` and `:not` currently only support simple selectors. Simple selectors are all of the selectors except those that take another selector as an argument, so anything except `:not`, `:has`, `>`, `+` and `~`.
281 +
275 282 ## Suppressing log messages
276 283
277 284 Floki may log debug messages related to problems in the parsing of selectors, or parsing of the HTML tree.
  @@ -282,7 +289,7 @@ See https://hexdocs.pm/logger/Logger.html#module-compile-configuration for detai
282 289
283 290 ## Special thanks
284 291
285 - * [@arasatasaygin](https://github.com/arasatasaygin) for Floki's logo from the [Open Logos project](http://openlogos.org/).
292 + - [@arasatasaygin](https://github.com/arasatasaygin) for Floki's logo from the [Open Logos project](http://openlogos.org/).
286 293
287 294 ## License
  @@ -3,10 +3,10 @@
3 3 {<<"GitHub">>,<<"https://github.com/philss/floki">>},
4 4 {<<"Sponsor">>,<<"https://github.com/sponsors/philss">>}]}.
5 5 {<<"name">>,<<"floki">>}.
6 - {<<"version">>,<<"0.37.1">>}.
6 + {<<"version">>,<<"0.38.0">>}.
7 7 {<<"description">>,
8 8 <<"Floki is a simple HTML parser that enables search for nodes using CSS selectors.">>}.
9 - {<<"elixir">>,<<"~> 1.14">>}.
9 + {<<"elixir">>,<<"~> 1.15">>}.
10 10 {<<"app">>,<<"floki">>}.
11 11 {<<"licenses">>,[<<"MIT">>]}.
12 12 {<<"files">>,
  @@ -1,8 +1,6 @@
1 1 defmodule Floki do
2 2 alias Floki.{Finder, FilterOut, HTMLTree}
3 3
4 - require Logger
5 -
6 4 @moduledoc """
7 5 Floki is a simple HTML parser that enables search for nodes using CSS selectors.
8 6
  @@ -82,26 +80,6 @@ defmodule Floki do
82 80 (tuple_size(value) == 2 and elem(value, 0) in [:pi, :comment]) or
83 81 (tuple_size(value) == 4 and elem(value, 0) == :doctype)
84 82
85 - @doc """
86 - Parses a HTML Document from a String.
87 -
88 - The expect string is a valid HTML, but the parser will try
89 - to parse even with errors.
90 - """
91 -
92 - @spec parse(binary()) :: html_tag() | html_tree() | String.t()
93 -
94 - @deprecated "Use `parse_document/1` or `parse_fragment/1` instead."
95 - def parse(html) do
96 - with {:ok, document} <- Floki.HTMLParser.parse_document(html) do
97 - if length(document) == 1 do
98 - hd(document)
99 - else
100 - document
101 - end
102 - end
103 - end
104 -
105 83 @doc """
106 84 Parses an HTML document from a string.
107 85
  @@ -272,18 +250,7 @@ defmodule Floki do
272 250 [{"a", [{"href", "https://google.com"}], ["Google"]}]
273 251
274 252 """
275 -
276 - @spec find(binary() | html_tree() | html_node(), css_selector()) :: html_tree
277 - def find(html, selector) when is_binary(html) do
278 - Logger.info(
279 - "deprecation: parse the HTML with parse_document or parse_fragment before using find/2"
280 - )
281 -
282 - with {:ok, document} <- Floki.parse_document(html) do
283 - Finder.find(document, selector)
284 - end
285 - end
286 -
253 + @spec find(html_tree() | html_node(), css_selector()) :: html_tree()
287 254 def find(html_tree_as_tuple, selector)
288 255 when is_list(html_tree_as_tuple) or is_html_node(html_tree_as_tuple) do
289 256 Finder.find(html_tree_as_tuple, selector)
  @@ -323,23 +290,11 @@ defmodule Floki do
323 290 [{"div", [{"id", "b"}, {"class", "name"}], []}]
324 291
325 292 """
326 - @spec attr(binary | html_tree | html_node, css_selector(), binary, (binary -> binary)) ::
327 - html_tree
328 -
293 + @spec attr(html_tree() | html_node(), css_selector(), binary, (binary -> binary)) :: html_tree()
329 294 def attr(html_elem_tuple, selector, attribute_name, mutation) when is_tuple(html_elem_tuple) do
330 295 attr([html_elem_tuple], selector, attribute_name, mutation)
331 296 end
332 297
333 - def attr(html, selector, attribute_name, mutation) when is_binary(html) do
334 - Logger.info(
335 - "deprecation: parse the HTML with parse_document or parse_fragment before using attr/4"
336 - )
337 -
338 - with {:ok, document} <- Floki.parse_document(html) do
339 - attr(document, selector, attribute_name, mutation)
340 - end
341 - end
342 -
343 298 def attr(html_tree_list, selector, attribute_name, mutation) when is_list(html_tree_list) do
344 299 find_and_update(html_tree_list, selector, fn
345 300 {tag, attrs} ->
  @@ -364,17 +319,6 @@ defmodule Floki do
364 319 end)
365 320 end
366 321
367 - @deprecated """
368 - Use `find_and_update/3` or `Enum.map/2` instead.
369 - """
370 - def map(_html_tree_or_list, _fun)
371 -
372 - def map(html_tree_list, fun) when is_list(html_tree_list) do
373 - Enum.map(html_tree_list, &Finder.map(&1, fun))
374 - end
375 -
376 - def map(html_tree, fun), do: Finder.map(html_tree, fun)
377 -
378 322 @doc """
379 323 Searches for elements inside the HTML tree and update those that matches the selector.
380 324
  @@ -529,7 +473,8 @@ defmodule Floki do
529 473
530 474 By default, it will perform a deep search through the HTML tree.
531 475 You can disable deep search with the option `deep` assigned to false.
532 - You can include content of script tags with the option `js` assigned to true.
476 + You can include content of script or style tags by setting the `:js` or
477 + `:style` flags, respectively, to true.
533 478 You can specify a separator between nodes content.
534 479
535 480 ## Options
  @@ -539,7 +484,10 @@ defmodule Floki do
539 484 or the first level of the HTML document is going to be considered.
540 485 Defaults to `true`.
541 486
542 - * `:js` - A boolean option to control if the contents of script tags
487 + * `:js` - A boolean option to control if the contents of `<script>` tags
488 + should be considered as text. Defaults to `false`.
489 +
490 + * `:style` - A boolean to control if the contents of `<style>` tags
543 491 should be considered as text. Defaults to `false`.
544 492
545 493 * `:sep` - A separator string that is added between text nodes.
  @@ -586,7 +534,7 @@ defmodule Floki do
586 534
587 535 """
588 536
589 - @spec text(html_tree | html_node | binary, Keyword.t()) :: binary
537 + @spec text(html_tree() | html_node(), Keyword.t()) :: binary()
590 538
591 539 def text(html, opts \\ []) do
592 540 defaults = [deep: true, js: false, style: true, sep: "", include_inputs: false]
  @@ -595,7 +543,6 @@ defmodule Floki do
595 543
596 544 cleaned_html_tree =
597 545 html
598 - |> maybe_parse_it()
599 546 |> clean_html_tree(:js, opts[:js])
600 547 |> clean_html_tree(:style, opts[:style])
601 548
  @@ -687,17 +634,7 @@ defmodule Floki do
687 634 ["google"]
688 635 """
689 636
690 - @spec attribute(binary | html_tree | html_node, binary) :: list
691 - def attribute(html, attribute_name) when is_binary(html) do
692 - Logger.info(
693 - "deprecation: parse the HTML with parse_document or parse_fragment before using attribute/2"
694 - )
695 -
696 - with {:ok, document} <- Floki.parse_document(html) do
697 - attribute_values(document, attribute_name)
698 - end
699 - end
700 -
637 + @spec attribute(html_tree() | html_node(), binary()) :: list(binary())
701 638 def attribute(elements, attribute_name) do
702 639 attribute_values(elements, attribute_name)
703 640 end
  @@ -738,17 +675,6 @@ defmodule Floki do
738 675 )
739 676 end
740 677
741 - defp maybe_parse_it(html) when is_binary(html) do
742 - Logger.info(
743 - "deprecation: parse the HTML with parse_document or parse_fragment before using text/2"
744 - )
745 -
746 - {:ok, document} = Floki.parse_document(html)
747 - document
748 - end
749 -
750 - defp maybe_parse_it(html), do: html
751 -
752 678 defp clean_html_tree(html_tree, :js, true), do: html_tree
753 679 defp clean_html_tree(html_tree, :js, _), do: filter_out(html_tree, "script")
754 680
  @@ -774,19 +700,8 @@ defmodule Floki do
774 700
775 701 """
776 702
777 - @spec filter_out(html_node() | html_tree() | binary(), :comment | :text | css_selector()) ::
703 + @spec filter_out(html_node() | html_tree(), :comment | :text | css_selector()) ::
778 704 html_node() | html_tree()
779 -
780 - def filter_out(html, selector) when is_binary(html) do
781 - Logger.info(
782 - "deprecation: parse the HTML with parse_document or parse_fragment before using filter_out/2"
783 - )
784 -
785 - with {:ok, document} <- Floki.parse_document(html) do
786 - FilterOut.filter_out(document, selector)
787 - end
788 - end
789 -
790 705 def filter_out(elements, selector) do
791 706 FilterOut.filter_out(elements, selector)
792 707 end
  @@ -42,10 +42,15 @@ defmodule Floki.Finder do
42 42
43 43 def find(html_tree, selector_as_string) when is_binary(selector_as_string) do
44 44 selectors = Selector.Parser.parse(selector_as_string)
45 - find(html_tree, selectors)
45 +
46 + if selectors != [] do
47 + find(html_tree, selectors)
48 + else
49 + []
50 + end
46 51 end
47 52
48 - def find(html_tree, selector = %Selector{}) do
53 + def find(html_tree, %Selector{} = selector) do
49 54 find(html_tree, [selector])
50 55 end
Loading more files…