]> git.draconx.ca Git - homepage.git/blob - lib/css-darkmode.rb
50662e337b94de454d681a0996289810524f6228
[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     def is_media_dark_block(x)
30         return false unless x[:node] == :simple_block
31
32         list = filter_whitespace(x[:value])
33         list.length == 3 and
34             list[0][:node] == :ident and
35             list[0][:value] == "prefers-color-scheme" and
36             list[1][:node] == :colon and
37             list[2][:node] == :ident and
38             list[2][:value] == "dark"
39     end
40
41     def is_media_dark(x)
42         return false unless x[:node] == :at_rule and x[:name] == "media"
43
44         x[:prelude].index {|y| is_media_dark_block(y)}
45     end
46
47     # Remove (prefers-color-scheme: dark) conditions from a media query.
48     # If the resulting query is empty, returns the query's block alone.
49     # Otherwise, returns the modified query.
50     def prune_media_dark(x)
51         start_index = 0
52         end_index = 0
53         state = :init
54
55         x[:prelude].each do |y|
56             case state
57             when :init
58                 end_index += 1
59                 if is_media_dark_block(y)
60                     state = :post
61                 elsif y[:node] == :ident and y[:value] == "and"
62                     state = :pre
63                 else
64                     start_index = end_index
65                     state = :init
66                 end
67             when :pre
68                 end_index += 1
69                 if is_media_dark_block(y)
70                     state = :post
71                 elsif y[:node] == :whitespace
72                     state = :pre
73                 else
74                     start_index = end_index
75                     state = :init
76                 end
77             when :post
78                 if y[:node] == :whitespace
79                     end_index += 1
80                     state = :post
81                 elsif y[:node] == :ident and y[:value] == "and"
82                     end_index += 1
83                     state = :post
84                 else
85                     break
86                 end
87             end
88         end
89
90         x[:prelude].slice!(start_index..end_index-1)
91         return x[:block] if filter_whitespace(x[:prelude]).empty?
92         x
93     end
94
95     def equiv_query(a, b)
96         return false unless a.class == b.class
97
98         case a
99         when Array
100             a = filter_whitespace(a)
101             b = filter_whitespace(b)
102
103             return false unless a.length == b.length
104             return a.map.with_index{|x,i| equiv_query(x, b[i])}.all?
105         when Hash
106             return false unless a[:node] == b[:node]
107             case a[:node]
108             when :at_rule
109                 return equiv_query(a[:prelude], b[:prelude])
110             when :simple_block
111                 return equiv_query(a[:value], b[:value])
112             else
113                 return (a[:value] == b[:value] and a[:unit] == b[:unit])
114             end
115         end
116     end
117
118     def run(content, params = {})
119         params = {
120             preserve_comments: true,
121             preserve_hacks: true
122             }.merge(params)
123
124         tree = Crass.parse(content, params)
125
126         darknodes = []
127         tree.delete_if { |x| darknodes << x if is_media_dark(x) }
128
129         # Combine consecutive equivalent media queries into a single query
130         darknodes = darknodes.slice_when{|a,b| !equiv_query(a, b)}.each.map \
131         do |x|
132             combined = x[0].merge({block: x.map{|x| x[:block]}.flatten})
133         end
134
135         # In alternate mode, remove prefers-color-scheme queries.
136         if params[:alternate]
137             darknodes.map!{|x| prune_media_dark(x)}
138         end
139
140         darkcss = ""
141         darknodes.each do |x|
142             darkcss += "#{Crass::Parser.stringify(x).rstrip}\n"
143         end
144         darkcss.sub!(/^\n*/, "")
145
146         if params[:alternate]
147             prologue = tree.take_while do |x|
148                 x[:node] == :comment or x[:node] == :whitespace
149             end
150
151             "#{Crass::Parser.stringify(prologue).rstrip}\n\n#{darkcss}"
152         else
153             output = "#{Crass::Parser.stringify(tree).rstrip}\n"
154             output += "\n#{darkcss}" unless darkcss.empty?
155             output
156         end
157     end
158 end