1 # Nick's web site: css_darkmode filter. Cut out dark-mode media queries
2 # from a stylesheet and either re-insert them at the end of the same
3 # stylesheet, or produce a separate stylesheet for use as an alternate.
5 # Copyright © 2022 Nick Bowler
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <https://www.gnu.org/licenses/>.
20 class CssDarkModeFilter < Nanoc::Filter
21 identifier :css_darkmode
25 def filter_whitespace(nodes)
26 return nodes.reject {|x| x[:node] == :whitespace }
29 # Return a new list of nodes where consecutive whitespace nodes have been
30 # replaced with a single whitespace node, and if the whitespace contains
31 # one or more newlines, everything before the final newline is removed.
32 def simplify_whitespace(nodes)
33 nodes.slice_when do |a,b|
34 a[:node] != :whitespace or b[:node] != :whitespace
36 if x[0][:node] == :whitespace
37 combined = x.map{|y| y[:raw]}.join.sub(/.*\n/m, "\n")
38 x[0].merge({ raw: combined})
45 def is_media_dark_block(x)
46 return false unless x[:node] == :simple_block
48 list = filter_whitespace(x[:value])
50 list[0][:node] == :ident and
51 list[0][:value] == "prefers-color-scheme" and
52 list[1][:node] == :colon and
53 list[2][:node] == :ident and
54 list[2][:value] == "dark"
58 return false unless x[:node] == :at_rule and x[:name] == "media"
60 x[:prelude].index {|y| is_media_dark_block(y)}
63 def is_supports_block(x)
64 true if x[:node] == :at_rule and x[:name] == "supports"
67 # Remove (prefers-color-scheme: dark) conditions from a media query.
68 # If the resulting query is empty, returns the query's block alone.
69 # Otherwise, returns the modified query.
70 def prune_media_dark(x)
75 x[:prelude].each do |y|
79 if is_media_dark_block(y)
81 elsif y[:node] == :ident and y[:value] == "and"
84 start_index = end_index
89 if is_media_dark_block(y)
91 elsif y[:node] == :whitespace
94 start_index = end_index
98 if y[:node] == :whitespace
101 elsif y[:node] == :ident and y[:value] == "and"
110 x[:prelude].slice!(start_index..end_index-1)
111 return x[:block] if filter_whitespace(x[:prelude]).empty?
115 def equiv_query(a, b)
116 return false unless a.class == b.class
120 a = filter_whitespace(a)
121 b = filter_whitespace(b)
123 return false unless a.length == b.length
124 return a.map.with_index{|x,i| equiv_query(x, b[i])}.all?
126 return false unless a[:node] == b[:node]
129 return equiv_query(a[:prelude], b[:prelude])
131 return equiv_query(a[:value], b[:value])
133 return (a[:value] == b[:value] and a[:unit] == b[:unit])
138 def process(tree, params)
142 tree.delete_if do |x|
143 sep = { node: :whitespace, raw: "\n" }
144 if last_visited[:node] == :whitespace
145 # Try to maintain indentation
146 sep[:raw] += last_visited[:raw].sub(/[^\n]*\z|.*\n/m, "")
150 if is_supports_block(x)
151 # Re-parse the block as a list of rules
152 s = Crass::Parser.stringify(x[:block])
153 x[:block] = Crass::Parser.parse_rules(s, params)
155 block = process(x[:block], params)
157 block << { node: :whitespace, raw: "\n" }
158 darknodes << [sep, x.merge({ block: block })]
161 Crass::Parser.stringify(x[:block]).strip.empty?
162 elsif is_media_dark(x)
163 if params[:alternate]
164 x = prune_media_dark(x)
166 darknodes << [sep, x]
170 # Combine consecutive equivalent media queries into a single query
171 result = darknodes.slice_when do |a,b|
172 !equiv_query(a[1], b[1])
176 g = x.map{ |sep, node| node[:block] }.flatten
177 [ x[0][0], x[0][1].merge({ block: simplify_whitespace(g) }) ]
183 simplify_whitespace(result)
186 def run(content, params = {})
188 preserve_comments: true,
192 tree = Crass.parse(content, params)
194 prologue = tree.take_while do |x|
195 x[:node] == :comment or x[:node] == :whitespace
197 tree.slice!(0, prologue.length)
199 output = "#{Crass::Parser.stringify(prologue).rstrip}\n"
200 darknodes = process(tree, params)
201 unless params[:alternate]
202 tree = simplify_whitespace(tree)
203 output += "#{Crass::Parser.stringify(tree).rstrip}\n"
205 output += "#{Crass::Parser.stringify(darknodes).rstrip}\n"