]> git.draconx.ca Git - homepage.git/blobdiff - lib/css-darkmode.rb
Implement dark mode without using CSS variables.
[homepage.git] / lib / css-darkmode.rb
diff --git a/lib/css-darkmode.rb b/lib/css-darkmode.rb
new file mode 100644 (file)
index 0000000..50662e3
--- /dev/null
@@ -0,0 +1,158 @@
+# 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