--- /dev/null
+# 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
+
+ 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
+
+ # 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 run(content, params = {})
+ params = {
+ preserve_comments: true,
+ preserve_hacks: true
+ }.merge(params)
+
+ tree = Crass.parse(content, params)
+
+ darknodes = []
+ tree.delete_if { |x| darknodes << x if is_media_dark(x) }
+
+ # Combine consecutive equivalent media queries into a single query
+ darknodes = darknodes.slice_when{|a,b| !equiv_query(a, b)}.each.map \
+ do |x|
+ combined = x[0].merge({block: x.map{|x| x[:block]}.flatten})
+ end
+
+ # In alternate mode, remove prefers-color-scheme queries.
+ if params[:alternate]
+ darknodes.map!{|x| prune_media_dark(x)}
+ end
+
+ darkcss = ""
+ darknodes.each do |x|
+ darkcss += "#{Crass::Parser.stringify(x).rstrip}\n"
+ end
+ darkcss.sub!(/^\n*/, "")
+
+ if params[:alternate]
+ prologue = tree.take_while do |x|
+ x[:node] == :comment or x[:node] == :whitespace
+ end
+
+ "#{Crass::Parser.stringify(prologue).rstrip}\n\n#{darkcss}"
+ else
+ output = "#{Crass::Parser.stringify(tree).rstrip}\n"
+ output += "\n#{darkcss}" unless darkcss.empty?
+ output
+ end
+ end
+end