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