# 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 . 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