+# Nick's web site: css_darkmode filter. Cut out dark-mode media queries
+# from a stylesheet and either re-insert them at the end of the same
+# stylesheet, or produce a separate stylesheet for use as an alternate.
+#
+# Copyright © 2022 Nick Bowler
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+class CssDarkModeFilter < Nanoc::Filter
+ identifier :css_darkmode
+
+ require 'crass'
+
+ def filter_whitespace(nodes)
+ return nodes.reject {|x| x[:node] == :whitespace }
+ end
+
+ # Return a new list of nodes where consecutive whitespace nodes have been
+ # replaced with a single whitespace node, and if the whitespace contains
+ # one or more newlines, everything before the final newline is removed.
+ def simplify_whitespace(nodes)
+ nodes.slice_when do |a,b|
+ a[:node] != :whitespace or b[:node] != :whitespace
+ end.map do |x|
+ if x[0][:node] == :whitespace
+ combined = x.map{|y| y[:raw]}.join.sub(/.*\n/m, "\n")
+ x[0].merge({ raw: combined})
+ else
+ x[0]
+ end
+ end
+ end
+
+ def is_media_dark_block(x)
+ return false unless x[:node] == :simple_block
+
+ list = filter_whitespace(x[:value])
+ list.length == 3 and
+ list[0][:node] == :ident and
+ list[0][:value] == "prefers-color-scheme" and
+ list[1][:node] == :colon and
+ list[2][:node] == :ident and
+ list[2][:value] == "dark"
+ end
+
+ def is_media_dark(x)
+ return false unless x[:node] == :at_rule and x[:name] == "media"
+
+ x[:prelude].index {|y| is_media_dark_block(y)}
+ end
+
+ def is_supports_block(x)
+ true if x[:node] == :at_rule and x[:name] == "supports"
+ end
+
+ # Remove (prefers-color-scheme: dark) conditions from a media query.
+ # If the resulting query is empty, returns the query's block alone.
+ # Otherwise, returns the modified query.
+ def prune_media_dark(x)
+ start_index = 0
+ end_index = 0
+ state = :init
+
+ x[:prelude].each do |y|
+ case state
+ when :init
+ end_index += 1
+ if is_media_dark_block(y)
+ state = :post
+ elsif y[:node] == :ident and y[:value] == "and"
+ state = :pre
+ else
+ start_index = end_index
+ state = :init
+ end
+ when :pre
+ end_index += 1
+ if is_media_dark_block(y)
+ state = :post
+ elsif y[:node] == :whitespace
+ state = :pre
+ else
+ start_index = end_index
+ state = :init
+ end
+ when :post
+ if y[:node] == :whitespace
+ end_index += 1
+ state = :post
+ elsif y[:node] == :ident and y[:value] == "and"
+ end_index += 1
+ state = :post
+ else
+ break
+ end
+ end
+ end
+
+ x[:prelude].slice!(start_index..end_index-1)
+ return x[:block] if filter_whitespace(x[:prelude]).empty?
+ x
+ end
+
+ def equiv_query(a, b)
+ return false unless a.class == b.class
+
+ case a
+ when Array
+ a = filter_whitespace(a)
+ b = filter_whitespace(b)
+
+ return false unless a.length == b.length
+ return a.map.with_index{|x,i| equiv_query(x, b[i])}.all?
+ when Hash
+ return false unless a[:node] == b[:node]
+ case a[:node]
+ when :at_rule
+ return equiv_query(a[:prelude], b[:prelude])
+ when :simple_block
+ return equiv_query(a[:value], b[:value])
+ else
+ return (a[:value] == b[:value] and a[:unit] == b[:unit])
+ end
+ end
+ end
+
+ def process(tree, params)
+ last_visited = {}
+ darknodes = []
+
+ tree.delete_if do |x|
+ sep = { node: :whitespace, raw: "\n" }
+ if last_visited[:node] == :whitespace
+ # Try to maintain indentation
+ sep[:raw] += last_visited[:raw].sub(/[^\n]*\z|.*\n/m, "")
+ end
+ last_visited = x
+
+ if is_supports_block(x)
+ # Re-parse the block as a list of rules
+ s = Crass::Parser.stringify(x[:block])
+ x[:block] = Crass::Parser.parse_rules(s, params)
+
+ block = process(x[:block], params)
+ unless block.empty?
+ block << { node: :whitespace, raw: "\n" }
+ darknodes << [sep, x.merge({ block: block })]
+ end
+
+ Crass::Parser.stringify(x[:block]).strip.empty?
+ elsif is_media_dark(x)
+ if params[:alternate]
+ x = prune_media_dark(x)
+ end
+ darknodes << [sep, x]
+ end
+ end
+
+ # Combine consecutive equivalent media queries into a single query
+ result = darknodes.slice_when do |a,b|
+ !equiv_query(a[1], b[1])
+ end.each.map do |x|
+ case x[0][1]
+ when Hash
+ g = x.map{ |sep, node| node[:block] }.flatten
+ [ x[0][0], x[0][1].merge({ block: simplify_whitespace(g) }) ]
+ else
+ x
+ end
+ end.flatten
+
+ simplify_whitespace(result)
+ end
+
+ def run(content, params = {})
+ params = {
+ preserve_comments: true,
+ preserve_hacks: true
+ }.merge(params)
+
+ tree = Crass.parse(content, params)
+
+ prologue = tree.take_while do |x|
+ x[:node] == :comment or x[:node] == :whitespace
+ end
+ tree.slice!(0, prologue.length)
+
+ output = "#{Crass::Parser.stringify(prologue).rstrip}\n"
+ darknodes = process(tree, params)
+ unless params[:alternate]
+ tree = simplify_whitespace(tree)
+ output += "#{Crass::Parser.stringify(tree).rstrip}\n"
+ end
+ output += "#{Crass::Parser.stringify(darknodes).rstrip}\n"
+ end
+end