]> git.draconx.ca Git - homepage.git/blob - lib/css-darkmode.rb
cdecl99-1.3 bash-5 hotfix
[homepage.git] / lib / css-darkmode.rb
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.
4 #
5 # Copyright © 2022 Nick Bowler
6 #
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.
11 #
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.
16 #
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/>.
19
20 class CssDarkModeFilter < Nanoc::Filter
21     identifier :css_darkmode
22
23     require 'crass'
24
25     def filter_whitespace(nodes)
26         return nodes.reject {|x| x[:node] == :whitespace }
27     end
28
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
35         end.map do |x|
36             if x[0][:node] == :whitespace
37                 combined = x.map{|y| y[:raw]}.join.sub(/.*\n/m, "\n")
38                 x[0].merge({ raw: combined})
39             else
40                 x[0]
41             end
42         end
43     end
44
45     def is_media_dark_block(x)
46         return false unless x[:node] == :simple_block
47
48         list = filter_whitespace(x[:value])
49         list.length == 3 and
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"
55     end
56
57     def is_media_dark(x)
58         return false unless x[:node] == :at_rule and x[:name] == "media"
59
60         x[:prelude].index {|y| is_media_dark_block(y)}
61     end
62
63     def is_supports_block(x)
64         true if x[:node] == :at_rule and x[:name] == "supports"
65     end
66
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)
71         start_index = 0
72         end_index = 0
73         state = :init
74
75         x[:prelude].each do |y|
76             case state
77             when :init
78                 end_index += 1
79                 if is_media_dark_block(y)
80                     state = :post
81                 elsif y[:node] == :ident and y[:value] == "and"
82                     state = :pre
83                 else
84                     start_index = end_index
85                     state = :init
86                 end
87             when :pre
88                 end_index += 1
89                 if is_media_dark_block(y)
90                     state = :post
91                 elsif y[:node] == :whitespace
92                     state = :pre
93                 else
94                     start_index = end_index
95                     state = :init
96                 end
97             when :post
98                 if y[:node] == :whitespace
99                     end_index += 1
100                     state = :post
101                 elsif y[:node] == :ident and y[:value] == "and"
102                     end_index += 1
103                     state = :post
104                 else
105                     break
106                 end
107             end
108         end
109
110         x[:prelude].slice!(start_index..end_index-1)
111         return x[:block] if filter_whitespace(x[:prelude]).empty?
112         x
113     end
114
115     def equiv_query(a, b)
116         return false unless a.class == b.class
117
118         case a
119         when Array
120             a = filter_whitespace(a)
121             b = filter_whitespace(b)
122
123             return false unless a.length == b.length
124             return a.map.with_index{|x,i| equiv_query(x, b[i])}.all?
125         when Hash
126             return false unless a[:node] == b[:node]
127             case a[:node]
128             when :at_rule
129                 return equiv_query(a[:prelude], b[:prelude])
130             when :simple_block
131                 return equiv_query(a[:value], b[:value])
132             else
133                 return (a[:value] == b[:value] and a[:unit] == b[:unit])
134             end
135         end
136     end
137
138     def process(tree, params)
139         last_visited = {}
140         darknodes = []
141
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, "")
147             end
148             last_visited = x
149
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)
154
155                 block = process(x[:block], params)
156                 unless block.empty?
157                     block << { node: :whitespace, raw: "\n" }
158                     darknodes << [sep, x.merge({ block: block })]
159                 end
160
161                 Crass::Parser.stringify(x[:block]).strip.empty?
162             elsif is_media_dark(x)
163                 if params[:alternate]
164                     x = prune_media_dark(x)
165                 end
166                 darknodes << [sep, x]
167             end
168         end
169
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])
173         end.each.map do |x|
174             case x[0][1]
175             when Hash
176                 g = x.map{ |sep, node| node[:block] }.flatten
177                 [ x[0][0], x[0][1].merge({ block: simplify_whitespace(g) }) ]
178             else
179                 x
180             end
181         end.flatten
182
183         simplify_whitespace(result)
184     end
185
186     def run(content, params = {})
187         params = {
188             preserve_comments: true,
189             preserve_hacks: true
190             }.merge(params)
191
192         tree = Crass.parse(content, params)
193
194         prologue = tree.take_while do |x|
195             x[:node] == :comment or x[:node] == :whitespace
196         end
197         tree.slice!(0, prologue.length)
198
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"
204         end
205         output += "#{Crass::Parser.stringify(darknodes).rstrip}\n"
206     end
207 end