├── images └── preferences.jpg ├── lib ├── conductor │ ├── version.rb │ ├── boolean.rb │ ├── hash.rb │ ├── array.rb │ ├── command.rb │ ├── script.rb │ ├── env.rb │ ├── config.rb │ ├── string.rb │ ├── yui_compressor.rb │ ├── condition.rb │ └── filter.rb └── conductor.rb ├── README.rdoc ├── Gemfile ├── .gitignore ├── test ├── mmd_test.md ├── test.md ├── linktest.md └── header_test.md ├── .irbrc ├── .rubocop.yml ├── spec ├── bool_spec.rb ├── hash_spec.rb ├── yui_spec.rb ├── config_spec.rb ├── tracks.yaml ├── conductor_spec.rb ├── array_spec.rb ├── command_spec.rb ├── script_spec.rb ├── env_spec.rb ├── filter_spec.rb ├── spec_helper.rb ├── string_spec.rb ├── condition_spec.rb └── filter_string_spec.rb ├── LICENSE.txt ├── bin └── conductor ├── test.sh ├── marked-conductor.gemspec ├── Rakefile ├── Gemfile.lock ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md ├── README.md └── src └── _README.md /images/preferences.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttscoff/marked-conductor/main/images/preferences.jpg -------------------------------------------------------------------------------- /lib/conductor/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Conductor 4 | VERSION = '1.0.39' 5 | end 6 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Marked Conductor 2 | 3 | A command line tool that functions as a custom processor handler for Marked 2. 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in marked-conductor.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | html 10 | .irb_history 11 | Gemfile.lock 12 | -------------------------------------------------------------------------------- /test/mmd_test.md: -------------------------------------------------------------------------------- 1 | title: hello there 2 | date: 2024-05-25 08:00 3 | 4 | # This is a test 5 | 6 | Here's a test file for you. 7 | 8 | ## It's only a test 9 | 10 | With a few paragraphs. 11 | 12 | Just three. 13 | -------------------------------------------------------------------------------- /test/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: hello there 3 | date: 2024-05-25 08:00 4 | --- 5 | 6 | # This is a test 7 | 8 | Here's a test file for you. 9 | 10 | ## It's only a test 11 | 12 | With a few paragraphs. 13 | 14 | Just three. 15 | -------------------------------------------------------------------------------- /.irbrc: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | IRB.conf[:AUTO_INDENT] = true 4 | 5 | require "irb/completion" 6 | require_relative "lib/conductor" 7 | 8 | include Conductor # standard:disable all 9 | 10 | require "awesome_print" 11 | AwesomePrint.irb! 12 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6 3 | 4 | Style/StringLiterals: 5 | Enabled: true 6 | EnforcedStyle: double_quotes 7 | 8 | Style/StringLiteralsInInterpolation: 9 | Enabled: true 10 | EnforcedStyle: double_quotes 11 | 12 | Layout/LineLength: 13 | Max: 120 14 | -------------------------------------------------------------------------------- /spec/bool_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe TrueClass do 6 | describe ".bool?" do 7 | it "TrueClass is boolean" do 8 | expect(true.bool?).to be true 9 | end 10 | end 11 | end 12 | 13 | describe FalseClass do 14 | describe ".bool?" do 15 | it "FalseClass is boolean" do 16 | expect(false.bool?).to be true 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/conductor/boolean.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # True class 4 | class ::TrueClass 5 | ## 6 | ## If TrueClass, it's a boolean 7 | ## 8 | ## @return [Boolean] always true 9 | ## 10 | def bool? 11 | true 12 | end 13 | end 14 | 15 | # False class 16 | class ::FalseClass 17 | ## 18 | ## If FalseClass, it's a boolean 19 | ## 20 | ## @return [Boolean] always true 21 | ## 22 | def bool? 23 | true 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/hash_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe ::Hash do 6 | describe ".symbolize_keys" do 7 | it "symbolizes hash keys" do 8 | expect({ "key" => "value" }.symbolize_keys).to eq({ key: "value" }) 9 | end 10 | end 11 | 12 | describe ".symbolize_keys!" do 13 | it "symbolizes hash keys" do 14 | h = { "key" => "value" } 15 | expect(h.dup.symbolize_keys!).to eq({ key: "value" }) 16 | end 17 | end 18 | end 19 | 20 | -------------------------------------------------------------------------------- /test/linktest.md: -------------------------------------------------------------------------------- 1 | [test link]: https://brettterpstra.com 2 | 3 | [ex. http://RegExr.com?2rjl6] 4 | 5 | Built by gskinner.com with Flex 3 [adobe.com/go/flex] and Spelling Plus Library for text highlighting [gskinner.com/products/spl]. 6 | 7 | https://google.com 8 | 9 | x-marked://refresh 10 | 11 | omnifocus://open?hello=testing 12 | 13 | https:google.com 14 | 15 | www.cool.com.au 16 | 17 | http://www.cool.com.au 18 | 19 | http://www.cool.com.au/ersdfs 20 | 21 | http://www.cool.com.au/ersdfs?dfd=dfgd@s=1 22 | 23 | http://www.cool.com:81/index.html 24 | -------------------------------------------------------------------------------- /spec/yui_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe YuiCompressor::Yui do 6 | subject(:yui) do 7 | YuiCompressor::Yui.new 8 | end 9 | 10 | describe ".new" do 11 | it "makes a new Yui instance" do 12 | expect(yui).to be_a YuiCompressor::Yui 13 | end 14 | end 15 | 16 | describe ".compress" do 17 | it "outputs compressed CSS" do 18 | yui = YuiCompressor::Yui.new 19 | expect(yui.compress(css_content, 60)).to match(/\{transition:transform 100ms ease-in-out\}/) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Conductor::Config do 6 | describe ".test_config" do 7 | config = Conductor::Config.new 8 | 9 | it "creates a new config file" do 10 | config_file = File.expand_path("./spec/test_config.yaml") 11 | config.config_file = config_file 12 | expect(config.configure).to eq false 13 | expect(config.configure).to eq true 14 | FileUtils.rm(config_file) 15 | end 16 | 17 | config = Conductor::Config.new 18 | config.configure 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/tracks.yaml: -------------------------------------------------------------------------------- 1 | tracks: 2 | - title: Boolean test 3 | condition: true 4 | tracks: 5 | - condition: true 6 | continue: true 7 | sequence: 8 | - script: test-cat 9 | - command: cat 10 | - condition: true 11 | continue: true 12 | script: test-cat 13 | - condition: true 14 | continue: true 15 | command: cat 16 | - condition: true 17 | continue: true 18 | filter: nevermind() 19 | - title: Preprocessing 20 | condition: phase is pre 21 | tracks: 22 | - title: Test File 23 | condition: filename ends with test.md 24 | sequence: 25 | - filter: nevermind() 26 | 27 | -------------------------------------------------------------------------------- /lib/conductor/hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Hash helpers 4 | class ::Hash 5 | ## 6 | ## Destructive version of #symbolize_keys 7 | ## 8 | ## @see #symbolize_keys 9 | ## 10 | ## @return [Hash] hash with keys as symbols 11 | ## 12 | def symbolize_keys! 13 | replace symbolize_keys 14 | end 15 | 16 | ## 17 | ## Convert all keys in hash to symbols. Works on nested hashes 18 | ## 19 | ## @see #symbolize_keys! 20 | ## 21 | ## @return [Hash] hash with keys as symbols 22 | ## 23 | def symbolize_keys 24 | each_with_object({}) { |(k, v), hsh| hsh[k.to_sym] = (v.is_a?(Hash) || v.is_a?(Array)) ? v.symbolize_keys : v } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/header_test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: meta test 3 | --- 4 | 7 | ## This is the first headline 8 | 9 | <<[linktest.md] 10 | 11 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 12 | 13 | #### This has no h1s 14 | 15 | Hey there! This should be an h2. 16 | 17 | This is an h2 18 | --- 19 | 20 | Setext is so old fashioned. 21 | 22 | #### This is an h4 23 | 24 | Which should be an h3. 25 | 26 | ## This is an h2 27 | 28 | Well, shit. 29 | 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Brett Terpstra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/conductor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Conductor do 6 | describe ".conduct" do 7 | it "Runs conductor using config" do 8 | Conductor.stdin = test_markdown 9 | Conductor.original_input = test_markdown 10 | 11 | config = Conductor::Config.new 12 | config.config_file = File.expand_path('./spec/tracks.yaml') 13 | config.configure 14 | tracks = config.tracks 15 | _, condition = Conductor.conduct(tracks) 16 | expect(condition).to match(/No change in output/) 17 | end 18 | end 19 | 20 | def execute_tracks(tracks) 21 | out = test_markdown 22 | 23 | tracks.each do |track| 24 | Conductor.stdin = out.dup 25 | 26 | if track.key?(:tracks) 27 | out = execute_tracks(track[:tracks]) 28 | else 29 | out = Conductor.execute_track(track) 30 | end 31 | end 32 | 33 | out 34 | end 35 | 36 | describe ".execute_track" do 37 | it "Runs tracks using config" do 38 | config = Conductor::Config.new 39 | config.config_file = File.expand_path('./spec/tracks.yaml') 40 | config.configure 41 | 42 | out = execute_tracks(config.tracks) 43 | expect(out).to be_a(String) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /bin/conductor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -W1 2 | # frozen_string_literal: true 3 | 4 | require_relative "../lib/conductor" 5 | require "optparse" 6 | 7 | optparse = OptionParser.new do |opts| 8 | opts.banner = "Called from Marked 2 as a Custom Pre/Processor" 9 | 10 | opts.on("-v", "--version", "Show version number") do 11 | puts "conductor v#{Conductor::VERSION}" 12 | Process.exit 0 13 | end 14 | 15 | opts.on("-h", "--help", "Display this screen") do 16 | puts opts 17 | exit 18 | end 19 | end 20 | 21 | optparse.parse! 22 | 23 | config = Conductor::Config.new 24 | res = config.configure 25 | 26 | Process.exit 0 unless res 27 | 28 | Conductor.stdin 29 | Conductor.original_input = Conductor.stdin 30 | 31 | tracks = config.tracks 32 | res, condition = Conductor.conduct(tracks) 33 | 34 | ## 35 | ## Clean up conditions for output 36 | ## 37 | ## @param condition The condition 38 | ## 39 | def clean_condition(condition) 40 | condition.join("").sub(/ *(->|,) *$/, "") 41 | end 42 | 43 | if res.nil? 44 | warn "No conditions satisfied" 45 | # puts Conductor::Env 46 | puts "NOCUSTOM" 47 | elsif res == Conductor.original_input 48 | warn "No change in output" 49 | puts "NOCUSTOM" 50 | else 51 | warn "Met condition: #{clean_condition(condition)}" 52 | puts res 53 | end 54 | -------------------------------------------------------------------------------- /spec/array_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe ::Array do 6 | describe ".symbolize_keys" do 7 | it "symbolizes hash keys" do 8 | expect([{ "key" => "value" }, { "key2" => "value" }].symbolize_keys).to eq([{ key: "value" }, { key2: "value" }]) 9 | end 10 | end 11 | 12 | describe ".symbolize_keys!" do 13 | it "symbolizes hash keys" do 14 | h = [{ "key" => "value" }, { "key2" => "value" }] 15 | expect(h.dup.symbolize_keys!).to eq([{ key: "value" }, { key2: "value" }]) 16 | end 17 | end 18 | 19 | describe ".shell_join" do 20 | it "joins items in an array" do 21 | h = [%w[one two], ["three four"]] 22 | expect(h.shell_join).to eq(["one two", "three\\ four"]) 23 | end 24 | end 25 | 26 | describe ".includes_file?" do 27 | it "test for filename in array" do 28 | h = ["~/test/filename.md", "~/test/other.md"] 29 | expect(h.includes_file?("filename.md")).to be true 30 | expect(h.includes_file?("funky.md")).to be false 31 | end 32 | end 33 | 34 | describe ".includes_frag?" do 35 | it "test for fragment in array" do 36 | h = ["~/test/filename.md", "~/test/other.md"] 37 | expect(h.includes_frag?("test")).to be true 38 | expect(h.includes_frag?("funky")).to be false 39 | end 40 | end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Conductor::Command do 6 | subject(:command) do 7 | Conductor::Command.new("pandoc -f markdown_gfm") 8 | end 9 | 10 | describe ".new" do 11 | it "makes a new Command instance" do 12 | expect(command).to be_a Conductor::Command 13 | end 14 | end 15 | 16 | describe ".path=" do 17 | it "sets command path" do 18 | expect(command.path).to eq "/usr/local/bin/pandoc" 19 | command.path = "~/bin/markdown" 20 | expect(command.path).to eq "/Users/ttscoff/bin/markdown" 21 | command.path = "multimarkdown" 22 | expect(command.path).to eq "/usr/local/bin/multimarkdown" 23 | end 24 | end 25 | 26 | describe ".args=" do 27 | it "sets command arguments" do 28 | command.args = "" 29 | expect(command.args).to eq "" 30 | command.args = [] 31 | expect(command.args).to eq "" 32 | end 33 | end 34 | 35 | describe ".run" do 36 | it "runs the command on STDIN" do 37 | Conductor.stdin = "# Hello\n\nouch." 38 | command.path = "/usr/local/bin/multimarkdown" 39 | command.args = [] 40 | expect(command.run).to eq %(

Hello

\n\n

ouch.

\n) 41 | command.args = "$file".dup 42 | expect(command.run).to match(/

First, there seems to be a misconception that/) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/script_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Conductor::Script do 6 | subject(:script) do 7 | Conductor::Script.new("bear-pro") 8 | end 9 | 10 | describe ".new" do 11 | it "makes a new Script instance" do 12 | expect(script).to be_a Conductor::Script 13 | end 14 | end 15 | 16 | describe ".path=" do 17 | it "sets script path" do 18 | expected = File.expand_path("~/.config/conductor/scripts/bear-pro") 19 | expect(script.path).to eq expected 20 | script.path = "multimarkdown" 21 | expect(script.path).to eq "/usr/local/bin/multimarkdown" 22 | script.path = "/usr/local/bin/multimarkdown" 23 | expect(script.path).to eq "/usr/local/bin/multimarkdown" 24 | 25 | expect { script.path = "flarglebutt" }.to raise_error(RuntimeError) 26 | end 27 | end 28 | 29 | describe ".args=" do 30 | it "sets script arguments" do 31 | script.args = "" 32 | expect(script.args).to eq "" 33 | script.args = [] 34 | expect(script.args).to eq "" 35 | end 36 | end 37 | 38 | describe ".run" do 39 | it "runs the script on STDIN" do 40 | Conductor.stdin = "# Hello\n\nouch." 41 | expect(script.run).to eq %(

Hello

\n

ouch.

\n) 42 | script.path = "/bin/cat" 43 | script.args = ["${file}"] 44 | expect(script.run).to match(/First, there seems to be a misconception/) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/env_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | ENV["CONDUCTOR_TEST"] = "true" 6 | Conductor::Env.env 7 | 8 | describe Conductor::Env do 9 | describe ".load_test_env" do 10 | it "loads dummy data" do 11 | Conductor::Env.load_test_env 12 | expect(Conductor::Env.env[:home]).to eq "/Users/ttscoff" 13 | end 14 | end 15 | 16 | describe ".env" do 17 | it "loads data" do 18 | expect(Conductor::Env.env[:home]).to eq "/Users/ttscoff" 19 | end 20 | end 21 | 22 | describe ".env" do 23 | it "loads environment from variables" do 24 | file = File.expand_path("test/header_test.md") 25 | ENV["OUTLINE"] = "NONE" 26 | ENV["MARKED_ORIGIN"] = file 27 | ENV["MARKED_EXT"] = File.extname(file) 28 | ENV["MARKED_CSS_PATH"] = "/Applications/Marked 2.app/Contents/Resources/swiss.css" 29 | ENV["MARKED_PATH"] = file 30 | ENV["MARKED_INCLUDES"] = '"/Applications/Marked 2.app/Contents/Resources/tocstyle.css",' \ 31 | '"/Applications/Marked 2.app/Contents/Resources/javascript/main.js"' 32 | ENV["MARKED_PHASE"] = "PREPROCESS" 33 | ENV["CONDUCTOR_TEST"] = "false" 34 | expect(Conductor::Env.env).to be_a(Hash) 35 | expect(Conductor::Env.env[:includes]).to be_a(Array) 36 | end 37 | end 38 | 39 | describe ".to_s" do 40 | it "outputs environment as string" do 41 | ENV["CONDUCTOR_TEST"] = "true" 42 | expect(Conductor::Env.to_s).to match(/MARKED_CSS_PATH=/) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Conductor::Filter do 6 | subject(:filter) do 7 | Conductor::Filter.new("insertFile(./insert.md, h1)") 8 | end 9 | 10 | describe ".new" do 11 | it "makes a new Filter instance" do 12 | expect(filter).to be_a Conductor::Filter 13 | end 14 | end 15 | 16 | describe ".process" do 17 | %w[insertFile(filename.md,start) insertStylesheet(filename.css) injectCSS(string) 18 | add_title addTitle(:h1) addTitle(y) injectScript(filename.js) prependFile(filename.txt) 19 | insert_toc insert_toc(3) insert_toc(3,h2) setMeta(key,value) setMeta(key) stripMeta 20 | stripMeta(title) setStyle(Ink) replaceAll(regex,pattern) replaceAll(regex) 21 | replace(regex,pattern) replace(regex) autolink fixHeaders increaseHeaders 22 | increaseHeaders(2) decreaseHeaders decreaseHeaders(2)].each do |f| 23 | filt = Conductor::Filter.new(f) 24 | 25 | it "outputs processed mmd content with filter #{f}" do 26 | Conductor.stdin = test_mmd_meta.dup 27 | expect(filt.process).to be_a String 28 | end 29 | 30 | it "outputs processed yaml content with filter #{f}" do 31 | Conductor.stdin = test_yaml_meta.dup 32 | expect(filt.process).to be_a String 33 | end 34 | 35 | it "outputs processed markdown content with filter #{f}" do 36 | Conductor.stdin = test_markdown.dup 37 | expect(filt.process).to be_a String 38 | end 39 | end 40 | end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /lib/conductor/array.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Array helpers 4 | class ::Array 5 | ## 6 | ## Destructive version of #symbolize_keys 7 | ## 8 | ## @see #symbolize_keys 9 | ## 10 | ## @return [Array] symbolized arrays 11 | ## 12 | def symbolize_keys! 13 | replace symbolize_keys 14 | end 15 | 16 | ## 17 | ## Symbolize the keys of an array of hashes 18 | ## 19 | ## @return [Array] array of hashes with keys converted to symbols 20 | ## 21 | def symbolize_keys 22 | map { |h| h.symbolize_keys } 23 | end 24 | 25 | ## 26 | ## Join components within an array 27 | ## 28 | ## @return [Array] array of strings joined by Shellwords 29 | ## 30 | def shell_join 31 | map { |p| Shellwords.join(p) } 32 | end 33 | 34 | ## 35 | ## Test if any path in array matches filename 36 | ## 37 | ## @param filename [String] The filename 38 | ## 39 | ## @return [Boolean] whether file is found 40 | ## 41 | def includes_file?(filename) 42 | inc = false 43 | each do |path| 44 | path = path.join if path.is_a?(Array) 45 | if path =~ /#{Regexp.escape(filename)}$/i 46 | inc = true 47 | break 48 | end 49 | end 50 | inc 51 | end 52 | 53 | ## 54 | ## Test if any path in an array contains any matching fragment 55 | ## 56 | ## @param frag [String] The fragment 57 | ## 58 | ## @return [Boolean] whether fragment is found 59 | ## 60 | def includes_frag?(frag) 61 | inc = false 62 | each do |path| 63 | path = path.join if path.is_a?(Array) 64 | if path =~ /#{Regexp.escape(frag)}/i 65 | inc = true 66 | break 67 | end 68 | end 69 | inc 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/conductor/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Conductor 4 | # Command runner 5 | class Command 6 | attr_reader :args, :path 7 | 8 | ## 9 | ## Instantiate a command runner 10 | ## 11 | ## @param command [String] The command 12 | ## 13 | def initialize(command) 14 | parts = Shellwords.split(command) 15 | self.path = parts[0] 16 | self.args = parts[1..].join(" ") 17 | end 18 | 19 | ## 20 | ## Writer method for command path 21 | ## 22 | ## @param path [String] The path 23 | ## 24 | ## @return [String] New path 25 | ## 26 | def path=(path) 27 | @path = if %r{^[~/.]}.match?(path) 28 | File.expand_path(path) 29 | else 30 | which = TTY::Which.which(path) 31 | which || path 32 | end 33 | end 34 | 35 | ## 36 | ## Writer method for arguments 37 | ## 38 | ## @param array [Array] Array of arguments 39 | ## 40 | ## @return [String] Arguments as string 41 | ## 42 | def args=(array) 43 | @args = if array.is_a?(Array) 44 | array.join(" ") 45 | else 46 | array 47 | end 48 | end 49 | 50 | ## 51 | ## Run the command 52 | ## 53 | ## @return [String] result of running STDIN through command 54 | ## 55 | def run 56 | stdin = Conductor.stdin 57 | 58 | raise "Command path not found" unless @path 59 | 60 | use_stdin = true 61 | if /\$\{?file\}?/.match?(args) 62 | use_stdin = false 63 | args.sub!(/\$\{?file\}?/, %("#{Env.env[:filepath]}")) 64 | else 65 | raise "No input" unless stdin 66 | 67 | end 68 | 69 | if use_stdin 70 | `echo #{Shellwords.escape(stdin.utf8)} | #{Env} #{path} #{args}` 71 | else 72 | `#{Env} #{path} #{args}` 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # ./test.sh OPTIONS PATH 4 | # test.sh -h for options 5 | 6 | OPTIND=1 7 | 8 | show_help() { 9 | echo "$(basename $0): Shortcut for testing conductor with a given file" 10 | echo "Options:" 11 | echo " -p [pre|pro] (run as preprocessor(*) or processor)" 12 | echo " -o [err|out] (output stderr, stdout, both(*))" 13 | echo " -d (debug, only show stderr)" 14 | echo "Usage:" 15 | echo " $0 [-p PHASE] [-o OUTPUT] FILE_PATH" 16 | } 17 | 18 | if [[ -f bin/conductor ]]; then 19 | CMD="./bin/conductor" 20 | else 21 | CMD="$(which conductor)" 22 | fi 23 | 24 | PHASE="PREPROCESS" 25 | STD="BOTH" 26 | CONFIG="" 27 | 28 | while getopts "h?p:o:d" opt; do 29 | case "$opt" in 30 | h|\?) 31 | show_help 32 | exit 0 33 | ;; 34 | p) PHASE=$OPTARG 35 | ;; 36 | c) CMD="$CMD -c \"$OPTARG\"" 37 | ;; 38 | o) STD=$OPTARG 39 | ;; 40 | d) 41 | STD="ERR" 42 | ;; 43 | esac 44 | done 45 | 46 | shift $((OPTIND-1)) 47 | 48 | [ "${1:-}" = "--" ] && shift 49 | 50 | if [[ -z $1 ]]; then 51 | show_help 52 | exit 1 53 | fi 54 | 55 | FILE=$(realpath "$1") 56 | FILENAME=$(basename -- "$FILE") 57 | EXTENSION="${FILENAME##*.}" 58 | PHASE=$(echo $PHASE | tr [a-z] [A-Z]) 59 | STD=$(echo $STD | tr [a-z] [A-Z]) 60 | 61 | if [[ $PHASE =~ ^PRE ]]; then 62 | PHASE="PREPROCESS" 63 | else 64 | PHASE="PROCESS" 65 | fi 66 | 67 | export OUTLINE="NONE" 68 | export MARKED_ORIGIN="$FILE" 69 | export MARKED_EXT="$EXTENSION" 70 | export MARKED_CSS_PATH="/Applications/Marked 2.app/Contents/Resources/swiss.css" 71 | export PATH="$PATH:$(dirname "$FILE")" 72 | export MARKED_PATH="$FILE" 73 | export MARKED_INCLUDES='"/Applications/Marked 2.app/Contents/Resources/tocstyle.css","/Applications/Marked 2.app/Contents/Resources/javascript/main.js"' 74 | export MARKED_PHASE="$PHASE" 75 | 76 | if [[ $STD =~ ^(STD)?E ]]; then 77 | command cat "$FILE" | $CMD 1>/dev/null 78 | elif [[ $STD =~ ^(STD)?O ]]; then 79 | command cat "$FILE" | $CMD 2>/dev/null 80 | else 81 | command cat "$FILE" | $CMD 82 | fi 83 | 84 | -------------------------------------------------------------------------------- /lib/conductor/script.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Conductor 4 | # Script runner 5 | class Script 6 | attr_reader :args, :path 7 | 8 | ## 9 | ## Initializes the given script. 10 | ## 11 | ## @param script The script/path 12 | ## 13 | def initialize(script) 14 | parts = Shellwords.split(script) 15 | self.path = parts[0] 16 | self.args = parts[1..].join(" ") 17 | end 18 | 19 | ## 20 | ## Set script path, automatically expands/tests 21 | ## 22 | ## @param path The path 23 | ## 24 | def path=(path) 25 | @path = if %r{^[~/.]}.match?(path) 26 | File.expand_path(path) 27 | else 28 | script_dir = File.expand_path("~/.config/conductor/scripts") 29 | if File.exist?(File.join(script_dir, path)) 30 | File.join(script_dir, path) 31 | elsif TTY::Which.exist?(path) 32 | TTY::Which.which(path) 33 | else 34 | raise "Path to #{path} not found" 35 | 36 | end 37 | end 38 | end 39 | 40 | ## 41 | ## Set the args array 42 | ## 43 | ## @param array The array 44 | ## 45 | def args=(array) 46 | @args = if array.is_a?(Array) 47 | array.join(" ") 48 | else 49 | array 50 | end 51 | end 52 | 53 | ## 54 | ## Execute the script 55 | ## 56 | ## @return [String] script results (STDOUT) 57 | ## 58 | def run 59 | stdin = Conductor.stdin unless /\$\{?file\}?/.match?(args) 60 | 61 | raise "Script path not defined" unless @path 62 | 63 | raise "Script not executable" unless File.executable?(@path) 64 | 65 | use_stdin = true 66 | if /\$\{?file\}?/.match?(args) 67 | use_stdin = false 68 | args.sub!(/\$\{?file\}?/, Env.env[:filepath]) 69 | else 70 | raise "No input" unless stdin 71 | 72 | end 73 | 74 | if use_stdin 75 | `echo #{Shellwords.escape(stdin.utf8)} | #{Env} #{@path} #{@args}` 76 | else 77 | `#{Env} #{@path} #{@args}` 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/conductor/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Conductor 4 | # Environment variables 5 | module Env 6 | ## 7 | ## Define @env using Marked environment variables 8 | ## 9 | def self.env 10 | if ENV["CONDUCTOR_TEST"]&.to_bool 11 | load_test_env 12 | else 13 | @env ||= { 14 | home: ENV["HOME"], 15 | css_path: ENV["MARKED_CSS_PATH"], 16 | ext: ENV["MARKED_EXT"], 17 | includes: ENV["MARKED_INCLUDES"].split_list, 18 | origin: ENV["MARKED_ORIGIN"], 19 | filepath: ENV["MARKED_PATH"], 20 | filename: File.basename(ENV["MARKED_PATH"]), 21 | phase: ENV["MARKED_PHASE"], 22 | outline: ENV["OUTLINE"], 23 | path: ENV["PATH"] 24 | } 25 | end 26 | 27 | @env 28 | end 29 | 30 | ## 31 | ## Loads a test environment. 32 | ## 33 | def self.load_test_env 34 | @env = { 35 | home: "/Users/ttscoff", 36 | css_path: "/Applications/Marked 2.app/Contents/Resources/swiss.css", 37 | ext: "md", 38 | includes: "".split_list, 39 | origin: "/Users/ttscoff/Sites/dev/bt/source/_posts/", 40 | filepath: "/Users/ttscoff/Sites/dev/bt/source/_posts/2024-04-01-automating-the-dimspirations-workflow.md", 41 | filename: "advanced-features.md", 42 | phase: "PROCESS", 43 | outline: "NONE", 44 | path: "/Developer/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/Users/ttscoff/Sites/dev/bt/source/_posts" 45 | } 46 | end 47 | 48 | ## 49 | ## env to shell-compatible string 50 | ## 51 | ## @return [String] shell-compatible string representation of @env 52 | ## 53 | def self.to_s 54 | { 55 | "HOME" => @env[:home], 56 | "MARKED_CSS_PATH" => @env[:css_path], 57 | "MARKED_EXT" => @env[:ext], 58 | "MARKED_ORIGIN" => @env[:origin], 59 | "MARKED_INCLUDES" => @env[:includes].shell_join.join(","), 60 | "MARKED_PATH" => @env[:filepath], 61 | "MARKED_PHASE" => @env[:phase], 62 | "OUTLINE" => @env[:outline], 63 | "PATH" => @env[:path] 64 | }.map { |k, v| %(#{k}="#{v}") }.join(" ").force_encoding("utf-8") 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /marked-conductor.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/conductor/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "marked-conductor" 7 | spec.version = Conductor::VERSION 8 | spec.authors = ["Brett Terpstra"] 9 | spec.email = ["me@brettterpstra.com"] 10 | 11 | spec.summary = "A custom processor manager for Marked 2 (Mac)" 12 | spec.description = "Conductor allows easy configuration of multiple scripts" \ 13 | "which are run as custom pre/processors for Marked based on conditional statements." 14 | spec.homepage = "https://github.com/ttscoff/marked-conductor" 15 | spec.license = "MIT" 16 | spec.required_ruby_version = ">= 2.6.0" 17 | 18 | spec.metadata["homepage_uri"] = spec.homepage 19 | spec.metadata["source_code_uri"] = "https://github.com/ttscoff/marked-conductor" 20 | spec.metadata["changelog_uri"] = "https://github.com/ttscoff/marked-conductor/CHANGELOG.md" 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 25 | `git ls-files -z`.split("\x0").reject do |f| 26 | (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 27 | end 28 | end 29 | spec.bindir = "bin" 30 | spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | 33 | spec.add_development_dependency "awesome_print", "~> 1.9.2" 34 | spec.add_development_dependency "bundler", "~> 2.0" 35 | spec.add_development_dependency "gem-release", "~> 2.2" 36 | spec.add_development_dependency "parse_gemspec-cli", "~> 1.0" 37 | spec.add_development_dependency "pry", "~> 0.14.2" 38 | spec.add_development_dependency "rake", "~> 13.0" 39 | spec.add_development_dependency "rspec", "~> 3.0" 40 | spec.add_development_dependency "simplecov", "~> 0.21" 41 | spec.add_development_dependency "simplecov-console", "~> 0.9" 42 | spec.add_development_dependency "yard", "~> 0.9", ">= 0.9.26" 43 | 44 | spec.add_development_dependency "rubocop", "~> 1.21" 45 | 46 | 47 | # Uncomment to register a new dependency of your gem 48 | spec.add_dependency "chronic", "~> 0.10.2" 49 | spec.add_dependency "tty-which", "~> 0.5.0" 50 | # For more information and examples about making a new gem, checkout our 51 | # guide at: https://bundler.io/guides/creating_gem.html 52 | end 53 | -------------------------------------------------------------------------------- /lib/conductor/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Conductor 4 | # Configuration methods 5 | class Config 6 | # Configuration 7 | attr_reader :config 8 | # Tracks element 9 | attr_reader :tracks 10 | # Config file path 11 | attr_writer :config_file 12 | 13 | ## 14 | ## Instantiate a configuration 15 | ## 16 | ## @return [Config] Config object 17 | ## 18 | def initialize 19 | @config_file = File.expand_path("~/.config/conductor/tracks.yaml") 20 | end 21 | 22 | def configure 23 | res = create_config(@config_file) 24 | return false unless res 25 | 26 | @config ||= YAML.safe_load(IO.read(@config_file)) 27 | 28 | @tracks = @config["tracks"].symbolize_keys 29 | 30 | return true 31 | end 32 | 33 | private 34 | 35 | ## 36 | ## Generate a blank config and directory structure 37 | ## 38 | ## @param config_file [String] The configuration file to create 39 | ## 40 | def create_config(config_file = nil) 41 | config_file ||= @config_file 42 | config_dir = File.dirname(config_file) 43 | scripts_dir = File.dirname(File.join(config_dir, "scripts")) 44 | styles_dir = File.dirname(File.join(config_dir, "css")) 45 | js_dir = File.dirname(File.join(config_dir, "js")) 46 | FileUtils.mkdir_p(config_dir) unless File.directory?(config_dir) 47 | FileUtils.mkdir_p(scripts_dir) unless File.directory?(scripts_dir) 48 | FileUtils.mkdir_p(styles_dir) unless File.directory?(styles_dir) 49 | FileUtils.mkdir_p(js_dir) unless File.directory?(js_dir) 50 | unless File.exist?(config_file) 51 | File.open(config_file, "w") { |f| f.puts sample_config } 52 | puts "Sample config created at #{config_file}" 53 | return false 54 | end 55 | 56 | return true 57 | end 58 | 59 | ## 60 | ## Content for sample configuration 61 | ## 62 | ## @return [String] sample config 63 | ## 64 | def sample_config 65 | <<~EOCONFIG 66 | tracks: 67 | - condition: phase is pre 68 | tracks: 69 | - condition: tree contains .obsidian 70 | tracks: 71 | - condition: extension is md 72 | script: obsidian-md-filter 73 | - condition: extension is md 74 | command: rdiscount $file 75 | - condition: yaml includes comments 76 | script: blog-processor 77 | - condition: any 78 | command: echo 'NOCUSTOM' 79 | EOCONFIG 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rubocop/rake_task" 5 | require "rspec/core/rake_task" 6 | require "rdoc/task" 7 | require "yard" 8 | 9 | Rake::RDocTask.new do |rd| 10 | rd.main = "README.rdoc" 11 | rd.rdoc_files.include("README.rdoc", "lib/**/*.rb", "bin/**/*") 12 | rd.title = "Marked Conductor" 13 | end 14 | 15 | YARD::Rake::YardocTask.new do |t| 16 | t.files = ["lib/conductor/*.rb"] 17 | t.options = ["--markup-provider=redcarpet", "--markup=markdown", "--no-private", "-p", "yard_templates"] 18 | # t.stats_options = ['--list-undoc'] 19 | end 20 | 21 | RSpec::Core::RakeTask.new(:spec) do |t| 22 | t.rspec_opts = "--pattern spec/*_spec.rb" 23 | end 24 | 25 | task default: %i[test] 26 | 27 | desc "Alias for build" 28 | task package: :build 29 | 30 | task test: "spec" 31 | task lint: "standard" 32 | task format: "standard:fix" 33 | 34 | desc "Open an interactive ruby console" 35 | task :console do 36 | require "irb" 37 | require "bundler/setup" 38 | require "conductor" 39 | ARGV.clear 40 | IRB.start 41 | end 42 | 43 | RuboCop::RakeTask.new 44 | 45 | task default: :rubocop 46 | 47 | desc "Alias for build" 48 | task package: :build 49 | 50 | desc "Development version check" 51 | task :ver do 52 | gver = `git ver` 53 | cver = IO.read(File.join(File.dirname(__FILE__), "CHANGELOG.md")).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 54 | res = `grep VERSION lib/conductor/version.rb` 55 | version = res.match(/VERSION *= *['"](\d+\.\d+\.\d+(\w+)?)/)[1] 56 | puts "git tag: #{gver}" 57 | puts "version.rb: #{version}" 58 | puts "changelog: #{cver}" 59 | end 60 | 61 | desc "Changelog version check" 62 | task :cver do 63 | puts IO.read(File.join(File.dirname(__FILE__), "CHANGELOG.md")).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 64 | end 65 | 66 | desc "Bump incremental version number" 67 | task :bump, :type do |_, args| 68 | args.with_defaults(type: "inc") 69 | version_file = "lib/conductor/version.rb" 70 | content = IO.read(version_file) 71 | content.sub!(/VERSION = ["'](?\d+)\.(?\d+)\.(?\d+)(?
\S+)?["']/) do
72 |     m = Regexp.last_match
73 |     major = m["major"].to_i
74 |     minor = m["minor"].to_i
75 |     inc = m["inc"].to_i
76 |     pre = m["pre"]
77 | 
78 |     case args[:type]
79 |     when /^maj/
80 |       major += 1
81 |       minor = 0
82 |       inc = 0
83 |     when /^min/
84 |       minor += 1
85 |       inc = 0
86 |     else
87 |       inc += 1
88 |     end
89 | 
90 |     $stdout.puts "At version #{major}.#{minor}.#{inc}#{pre}"
91 |     "VERSION = '#{major}.#{minor}.#{inc}#{pre}'"
92 |   end
93 |   File.open(version_file, "w+") { |f| f.puts content }
94 | end
95 | 


--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
  1 | PATH
  2 |   remote: .
  3 |   specs:
  4 |     marked-conductor (1.0.39)
  5 |       chronic (~> 0.10.2)
  6 |       tty-which (~> 0.5.0)
  7 | 
  8 | GEM
  9 |   remote: https://rubygems.org/
 10 |   specs:
 11 |     ansi (1.5.0)
 12 |     ast (2.4.2)
 13 |     awesome_print (1.9.2)
 14 |     chronic (0.10.2)
 15 |     coderay (1.1.3)
 16 |     diff-lcs (1.5.1)
 17 |     docile (1.4.0)
 18 |     gem-release (2.2.2)
 19 |     json (2.7.2)
 20 |     language_server-protocol (3.17.0.3)
 21 |     method_source (1.1.0)
 22 |     multi_json (1.15.0)
 23 |     parallel (1.24.0)
 24 |     parse_gemspec (1.0.0)
 25 |     parse_gemspec-cli (1.0.0)
 26 |       multi_json
 27 |       parse_gemspec
 28 |       thor
 29 |     parser (3.3.1.0)
 30 |       ast (~> 2.4.1)
 31 |       racc
 32 |     pry (0.14.2)
 33 |       coderay (~> 1.1)
 34 |       method_source (~> 1.0)
 35 |     racc (1.7.3)
 36 |     rainbow (3.1.1)
 37 |     rake (13.2.1)
 38 |     regexp_parser (2.9.0)
 39 |     rexml (3.2.6)
 40 |     rspec (3.13.0)
 41 |       rspec-core (~> 3.13.0)
 42 |       rspec-expectations (~> 3.13.0)
 43 |       rspec-mocks (~> 3.13.0)
 44 |     rspec-core (3.13.0)
 45 |       rspec-support (~> 3.13.0)
 46 |     rspec-expectations (3.13.1)
 47 |       diff-lcs (>= 1.2.0, < 2.0)
 48 |       rspec-support (~> 3.13.0)
 49 |     rspec-mocks (3.13.1)
 50 |       diff-lcs (>= 1.2.0, < 2.0)
 51 |       rspec-support (~> 3.13.0)
 52 |     rspec-support (3.13.1)
 53 |     rubocop (1.62.1)
 54 |       json (~> 2.3)
 55 |       language_server-protocol (>= 3.17.0)
 56 |       parallel (~> 1.10)
 57 |       parser (>= 3.3.0.2)
 58 |       rainbow (>= 2.2.2, < 4.0)
 59 |       regexp_parser (>= 1.8, < 3.0)
 60 |       rexml (>= 3.2.5, < 4.0)
 61 |       rubocop-ast (>= 1.31.1, < 2.0)
 62 |       ruby-progressbar (~> 1.7)
 63 |       unicode-display_width (>= 2.4.0, < 3.0)
 64 |     rubocop-ast (1.31.2)
 65 |       parser (>= 3.3.0.4)
 66 |     ruby-progressbar (1.13.0)
 67 |     simplecov (0.22.0)
 68 |       docile (~> 1.1)
 69 |       simplecov-html (~> 0.11)
 70 |       simplecov_json_formatter (~> 0.1)
 71 |     simplecov-console (0.9.1)
 72 |       ansi
 73 |       simplecov
 74 |       terminal-table
 75 |     simplecov-html (0.12.3)
 76 |     simplecov_json_formatter (0.1.4)
 77 |     terminal-table (3.0.2)
 78 |       unicode-display_width (>= 1.1.1, < 3)
 79 |     thor (1.3.1)
 80 |     tty-which (0.5.0)
 81 |     unicode-display_width (2.5.0)
 82 |     yard (0.9.36)
 83 | 
 84 | PLATFORMS
 85 |   arm64-darwin-20
 86 |   arm64-darwin-24
 87 |   x86_64-darwin-20
 88 | 
 89 | DEPENDENCIES
 90 |   awesome_print (~> 1.9.2)
 91 |   bundler (~> 2.0)
 92 |   gem-release (~> 2.2)
 93 |   marked-conductor!
 94 |   parse_gemspec-cli (~> 1.0)
 95 |   pry (~> 0.14.2)
 96 |   rake (~> 13.0)
 97 |   rspec (~> 3.0)
 98 |   rubocop (~> 1.21)
 99 |   simplecov (~> 0.21)
100 |   simplecov-console (~> 0.9)
101 |   yard (~> 0.9, >= 0.9.26)
102 | 
103 | BUNDLED WITH
104 |    2.2.29
105 | 


--------------------------------------------------------------------------------
/lib/conductor.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require "tty-which"
  4 | require "yaml"
  5 | require "shellwords"
  6 | require "fcntl"
  7 | require "time"
  8 | require "chronic"
  9 | require "fileutils"
 10 | require "erb"
 11 | require_relative "conductor/version"
 12 | require_relative "conductor/env"
 13 | require_relative "conductor/config"
 14 | require_relative "conductor/hash"
 15 | require_relative "conductor/array"
 16 | require_relative "conductor/boolean"
 17 | require_relative "conductor/string"
 18 | require_relative "conductor/filter"
 19 | require_relative "conductor/script"
 20 | require_relative "conductor/command"
 21 | require_relative "conductor/condition"
 22 | require_relative "conductor/yui_compressor"
 23 | 
 24 | # Main Conductor module
 25 | module Conductor
 26 |   class << self
 27 |     attr_accessor :original_input
 28 |     attr_writer :stdin
 29 | 
 30 |     ##
 31 |     ## Return STDIN value, reading from STDIN if needed
 32 |     ##
 33 |     ## @return     [String] STDIN contents
 34 |     ##
 35 |     def stdin
 36 |       warn "input on STDIN required" unless ENV["CONDUCTOR_TEST"] || $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
 37 |       @stdin ||= $stdin.read.force_encoding("utf-8")
 38 |     end
 39 | 
 40 |     ##
 41 |     ## Execute commands/scripts in the track
 42 |     ##
 43 |     ## @param      track  The track
 44 |     ##
 45 |     ## @return     Resulting STDOUT output
 46 |     ##
 47 |     def execute_track(track)
 48 |       if track[:sequence]
 49 |         track[:sequence].each do |cmd|
 50 |           if cmd[:script]
 51 |             script = Script.new(cmd[:script])
 52 | 
 53 |             res = script.run
 54 |           elsif cmd[:command]
 55 |             command = Command.new(cmd[:command])
 56 | 
 57 |             res = command.run
 58 |           elsif cmd[:filter]
 59 |             filter = Filter.new(cmd[:filter])
 60 | 
 61 |             res = filter.process
 62 |           end
 63 | 
 64 |           Conductor.stdin = res unless res.nil?
 65 |         end
 66 |       elsif track[:script]
 67 |         script = Script.new(track[:script])
 68 | 
 69 |         Conductor.stdin = script.run
 70 |       elsif track[:command]
 71 |         command = Command.new(track[:command])
 72 | 
 73 |         Conductor.stdin = command.run
 74 |       elsif track[:filter]
 75 |         filter = Filter.new(track[:filter])
 76 | 
 77 |         Conductor.stdin = filter.process
 78 |       end
 79 | 
 80 |       Conductor.stdin
 81 |     end
 82 | 
 83 |     ##
 84 |     ## Main function to parse conditions and
 85 |     ##             execute actions. Executes recursively for
 86 |     ##             sub-tracks.
 87 |     ##
 88 |     ## @param      tracks     The tracks to process
 89 |     ## @param      res        The current result
 90 |     ## @param      condition  The current condition
 91 |     ##
 92 |     ## @return     [Array] result, matched condition(s)
 93 |     ##
 94 |     def conduct(tracks, res = nil, condition = nil)
 95 |       tracks.each do |track|
 96 |         cond = Condition.new(track[:condition])
 97 | 
 98 |         next unless cond.true?
 99 | 
100 |         # Build "matched condition" message
101 |         title = track[:title] || track[:condition]
102 |         condition ||= [""]
103 |         condition << title
104 |         condition.push(track.key?(:continue) ? " -> " : ", ")
105 | 
106 |         res = execute_track(track)
107 | 
108 |         if track[:tracks]
109 |           ts = track[:tracks]
110 | 
111 |           res, condition = conduct(ts, res, condition)
112 | 
113 |           next if res.nil?
114 |         end
115 | 
116 |         break unless track[:continue]
117 |       end
118 | 
119 |       if res&.strip == Conductor.original_input.strip
120 |         [nil, "No change in output"]
121 |       else
122 |         [res, condition]
123 |       end
124 |     end
125 |   end
126 | end
127 | 


--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | unless ENV['CI'] == 'true'
  4 |   # SimpleCov::Formatter::Codecov # For CI
  5 |   require 'simplecov'
  6 |   SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter
  7 |   SimpleCov.start
  8 | end
  9 | 
 10 | require "conductor"
 11 | require "cli-test"
 12 | 
 13 | # @old_setting = ENV["RUBYOPT"]
 14 | 
 15 | RSpec.configure do |c|
 16 |   c.formatter = 'd'
 17 |   c.expect_with(:rspec) { |e| e.syntax = :expect }
 18 | 
 19 |   c.before(:each) do |tst|
 20 |     ENV["RUBYOPT"] = "-W0"
 21 |     ENV["CONDUCTOR_TEST"] = "true"
 22 | 
 23 |     allow(FileUtils).to receive(:remove_entry_secure).with(anything)
 24 |     Conductor.stdin = test_markdown
 25 |   end
 26 | 
 27 |   c.after(:each) do
 28 |     # ENV["RUBYOPT"] = @old_setting
 29 |   end
 30 | end
 31 | 
 32 | def css_content
 33 |   <<~ENDCSS
 34 |     /* Just a comment */
 35 | 
 36 |     body {
 37 |       --bold-weight: 600;
 38 |       --bold-color: inherit;
 39 | 
 40 |       /* Relative font sizes */
 41 |       --font-smallest: 0.8em;
 42 |       --font-smaller: 0.875em;
 43 |       --font-small: 0.933em;
 44 |     }
 45 | 
 46 |     /*! A preserved comment? */
 47 | 
 48 |     .callout-fold::after {
 49 |       content: "This is a string? /* yep! */";
 50 |     }
 51 | 
 52 |     .callout-fold :pseudo {
 53 |       content: 'Fake';
 54 |       background-position:0;
 55 |       color: rgb(51,102,153);
 56 |       border: none;
 57 |     }
 58 | 
 59 |     .callout-fold .svg-icon {
 60 |       transition: transform 100ms ease-in-out;
 61 |     }
 62 | 
 63 |     .callout-fold.is-collapsed .svg-icon {
 64 |       transform: rotate(-90deg);
 65 |     }
 66 |   ENDCSS
 67 | end
 68 | 
 69 | def test_css
 70 |   File.open("test_style.css", "w") { |f| f.puts css_content }
 71 | end
 72 | 
 73 | def delete_css
 74 |   FileUtils.rm("test_style.css")
 75 | end
 76 | 
 77 | def test_markdown
 78 |   <<~EONOTE
 79 |     # Conductor Test
 80 | 
 81 |     ## Topic Balogna
 82 | 
 83 |     Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor
 84 |     incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
 85 |     nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
 86 |     Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
 87 |     eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident,
 88 |     sunt in culpa qui officia deserunt mollit anim id est laborum.
 89 | 
 90 |     ## Topic Banana
 91 | 
 92 |     This is just another topic.
 93 | 
 94 |     - It has a list in it
 95 |     - That's pretty fun, right?
 96 | 
 97 |     #### Topic Tropic
 98 | 
 99 |     Bermuda, Bahama, something something wanna.
100 | 
101 |     EOF
102 |   EONOTE
103 | end
104 | 
105 | def test_mmd_meta
106 |   <<~EONOTE
107 |     title: This is my document
108 |     author: Brett Terpstra
109 |     value: 5
110 |     comments: true
111 | 
112 |     # Conductor Test
113 | 
114 |     Heyo.
115 | 
116 |     EOF
117 |   EONOTE
118 | end
119 | 
120 | def test_bad_yaml
121 |   <<~EONOTE
122 |     ---
123 |     title: colon:
124 |     author: another: gong!
125 |     ---
126 |     Bad YAML! Bad!
127 |   EONOTE
128 | end
129 | 
130 | def test_yaml_meta
131 |   <<~EONOTE
132 |     ---
133 |     title: This is my document
134 |     author: Brett Terpstra
135 |     comments: true
136 |     value: 5
137 |     ---
138 |     # Conductor Test
139 | 
140 |     ## an h2
141 | 
142 |     Heyo.
143 | 
144 |     EOF
145 |   EONOTE
146 | end
147 | 
148 | def test_pandoc_meta
149 |   <<~EONOTE
150 |     % This is my document
151 |     % Brett Terpstra
152 | 
153 |     # Conductor Test
154 | 
155 |     Heyo.
156 | 
157 |     EOF
158 |   EONOTE
159 | end
160 | 
161 | def test_header_order
162 |   <<~EONOTE
163 |     # First
164 | 
165 |     ### Should be 2
166 | 
167 |     ## Should be huh?
168 | 
169 |     #### Should be 3
170 |   EONOTE
171 | end
172 | 
173 | def test_no_h1
174 |   <<~EONOTE
175 |     ### No H1
176 | 
177 |     #### Conductor Test
178 | 
179 |     Heyo.
180 | 
181 |     EOF
182 |   EONOTE
183 | end
184 | 
185 | def test_two_h1
186 |   <<~EONOTE
187 |     # First H1
188 | 
189 |     #### Conductor Test
190 | 
191 |     Heyo.
192 | 
193 |     # Second H1
194 | 
195 |     EOF
196 |   EONOTE
197 | end
198 | 
199 | def test_setext
200 |   <<~EONOTE
201 |     Header 1
202 |     ========
203 | 
204 |     Header 2
205 |     ---
206 | 
207 |     EOF
208 |   EONOTE
209 | end
210 | 
211 | def test_insert
212 |   note = <<~INSERTED
213 |     This is inserted text
214 |   INSERTED
215 |   File.open("insert.md", "w") { |f| f.puts note }
216 | end
217 | 
218 | def delete_insert
219 |   FileUtils.rm("insert.md")
220 | end
221 | 
222 | def test_javascript
223 |   File.open("test_script.js", "w") { |f| f.puts "void();" }
224 | end
225 | 
226 | def delete_javascript
227 |   FileUtils.rm("test_script.js")
228 | end
229 | 


--------------------------------------------------------------------------------
/spec/string_spec.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require "spec_helper"
  4 | 
  5 | describe ::String do
  6 |   subject(:string) do
  7 |     test_markdown
  8 |   end
  9 | 
 10 |   subject(:mmd) do
 11 |     test_mmd_meta
 12 |   end
 13 | 
 14 |   subject(:yaml) do
 15 |     test_yaml_meta
 16 |   end
 17 | 
 18 |   subject(:pandoc) do
 19 |     test_pandoc_meta
 20 |   end
 21 | 
 22 |   describe ".titleize" do
 23 |     it "capitalizes a title" do
 24 |       ENV["CONDUCTOR_TEST"] = "true"
 25 |       expect(string.title_from_slug.titleize).to match(/Automating The Dim/)
 26 |     end
 27 |   end
 28 | 
 29 |   describe ".split_list" do
 30 |     it "splits string on comma and shell splits members" do
 31 |       expect(%(one, "--two three", -0 four five).split_list).to match_array([["--two three"], ["-0", "four", "five"], ["one"]])
 32 |     end
 33 |   end
 34 | 
 35 |   describe ".normalize_position" do
 36 |     it "convert position string to symbol" do
 37 |       expect("beginning".normalize_position).to eq :start
 38 |       expect("top".normalize_position).to eq :start
 39 |       expect("h1".normalize_position).to eq :h1
 40 |       expect("h2".normalize_position).to eq :h2
 41 |       expect("bottom".normalize_position).to eq :end
 42 |     end
 43 |   end
 44 | 
 45 |   describe ".normalize_include_type" do
 46 |     it "convert include type string to symbol" do
 47 |       expect("code".normalize_include_type).to eq :code
 48 |       expect("c".normalize_include_type).to eq :code
 49 |       expect("raw".normalize_include_type).to eq :raw
 50 |       expect("other".normalize_include_type).to eq :file
 51 |     end
 52 |   end
 53 | 
 54 |   describe ".bool_to_symbol" do
 55 |     it "convert boolean description to symbol" do
 56 |       expect("AND".bool_to_symbol).to eq :and
 57 |       expect("NOT".bool_to_symbol).to eq :not
 58 |       expect("!!".bool_to_symbol).to eq :not
 59 |       expect("OR".bool_to_symbol).to eq :or
 60 |     end
 61 |   end
 62 | 
 63 |   describe ".date?" do
 64 |     it "detect UTC dates" do
 65 |       expect("2004-12-21".date?).to be_truthy
 66 |       expect("ugly".date?).not_to be_truthy
 67 |     end
 68 |   end
 69 | 
 70 |   describe ".time?" do
 71 |     it "detect UTC dates" do
 72 |       expect("2004-12-21 3am".time?).to be_truthy
 73 |       expect("2004-12-21 12:00".time?).to be_truthy
 74 |       expect("2004-12-21".time?).not_to be_truthy
 75 |       expect("ugly".time?).not_to be_truthy
 76 |     end
 77 |   end
 78 | 
 79 |   describe ".to_date" do
 80 |     it "converts natural language to a date object" do
 81 |       expect("tomorrow at noon".to_date).to be_a Time
 82 |       expect("3pm".to_date).to be_a Time
 83 |       expect("no date info".to_date).not_to be_a Time
 84 |     end
 85 |   end
 86 | 
 87 |   describe ".strip_time" do
 88 |     it "removes time from date string" do
 89 |       expect("tomorrow at 3pm".strip_time).to eq "tomorrow at"
 90 |       expect("2024-12-12 14:00".strip_time).to eq "2024-12-12"
 91 |     end
 92 |   end
 93 | 
 94 |   describe ".to_day" do
 95 |     it "rounds a date to a day start or end" do
 96 |       expect("2024-12-12 3pm".to_day(:start).strftime('%H:%M')).to eq '00:00'
 97 |       expect("2024-12-12 3pm".to_day(:end).strftime('%H:%M')).to eq '23:59'
 98 |     end
 99 |   end
100 | 
101 |   describe ".number?" do
102 |     it "detects a number" do
103 |       expect("12345".number?).to be_truthy
104 |       expect("1.5".number?).to be_truthy
105 |       expect("one point five".number?).not_to be_truthy
106 |     end
107 |   end
108 | 
109 |   describe ".bool?" do
110 |     it "detects a boolean" do
111 |       expect("true".bool?).to be_truthy
112 |       expect("no".bool?).to be_truthy
113 |       expect("dunno".bool?).not_to be_truthy
114 |     end
115 |   end
116 | 
117 |   describe ".yaml?" do
118 |     it "detects yaml" do
119 |       expect(yaml.yaml?).to be_truthy
120 |       expect("dunno".yaml?).not_to be_truthy
121 |     end
122 |   end
123 | 
124 |   describe ".meta?" do
125 |     it "detects MMD metadata" do
126 |       expect(mmd.meta?).to be_truthy
127 |       expect("dunno".meta?).not_to be_truthy
128 |     end
129 |   end
130 | 
131 |   describe ".pandoc?" do
132 |     it "detects Pandoc metadata" do
133 |       expect(pandoc.pandoc?).to be_truthy
134 |       expect("dunno".pandoc?).not_to be_truthy
135 |     end
136 |   end
137 | 
138 |   describe ".to_bool" do
139 |     it "converts string to boolean" do
140 |       expect("true".to_bool).to be_truthy
141 |       expect("y".to_bool).to be_truthy
142 |       expect("false".to_bool).not_to be_truthy
143 |       expect("dunno".to_bool).not_to be_truthy
144 |     end
145 |   end
146 | 
147 |   describe ".to_rx" do
148 |     it "converts string to regex" do
149 |       expect('/\d+/'.to_rx).to be_a Regexp
150 |       expect('/\d+/'.to_rx.to_s).to eq '(?-mix:\d+)'
151 |       expect("not+a?regex".to_rx).to be_a Regexp
152 |       expect("not+a?regex".to_rx).to eq (/not\+a\?regex/)
153 |     end
154 |   end
155 | 
156 |   describe ".to_pattern" do
157 |     it "converts string to regex replace pattern" do
158 |       expect('\1 to \2'.to_pattern).to eq '\1 to \2'
159 |       expect("$1 to $2".to_pattern).to eq '\1 to \2'
160 |     end
161 |   end
162 | end
163 | 


--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
 1 | # Contributor Covenant Code of Conduct
 2 | 
 3 | ## Our Pledge
 4 | 
 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
 6 | 
 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
 8 | 
 9 | ## Our Standards
10 | 
11 | Examples of behavior that contributes to a positive environment for our community include:
12 | 
13 | * Demonstrating empathy and kindness toward other people
14 | * Being respectful of differing opinions, viewpoints, and experiences
15 | * Giving and gracefully accepting constructive feedback
16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | * Focusing on what is best not just for us as individuals, but for the overall community
18 | 
19 | Examples of unacceptable behavior include:
20 | 
21 | * The use of sexualized language or imagery, and sexual attention or
22 |   advances of any kind
23 | * Trolling, insulting or derogatory comments, and personal or political attacks
24 | * Public or private harassment
25 | * Publishing others' private information, such as a physical or email
26 |   address, without their explicit permission
27 | * Other conduct which could reasonably be considered inappropriate in a
28 |   professional setting
29 | 
30 | ## Enforcement Responsibilities
31 | 
32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
33 | 
34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
35 | 
36 | ## Scope
37 | 
38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
39 | 
40 | ## Enforcement
41 | 
42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at me@brettterpstra.com. All complaints will be reviewed and investigated promptly and fairly.
43 | 
44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45 | 
46 | ## Enforcement Guidelines
47 | 
48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
49 | 
50 | ### 1. Correction
51 | 
52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
53 | 
54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
55 | 
56 | ### 2. Warning
57 | 
58 | **Community Impact**: A violation through a single incident or series of actions.
59 | 
60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
61 | 
62 | ### 3. Temporary Ban
63 | 
64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
65 | 
66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
67 | 
68 | ### 4. Permanent Ban
69 | 
70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior,  harassment of an individual, or aggression toward or disparagement of classes of individuals.
71 | 
72 | **Consequence**: A permanent ban from any sort of public interaction within the community.
73 | 
74 | ## Attribution
75 | 
76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
78 | 
79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
80 | 
81 | [homepage]: https://www.contributor-covenant.org
82 | 
83 | For answers to common questions about this code of conduct, see the FAQ at
84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
85 | 


--------------------------------------------------------------------------------
/lib/conductor/string.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | # String helpers
  4 | class ::String
  5 |   ##
  6 |   ## Titlecase a string
  7 |   ##
  8 |   ## @return     Titleized string
  9 |   ##
 10 |   def titleize
 11 |     split(/(\W)/).map(&:capitalize).join
 12 |   end
 13 | 
 14 |   def split_list
 15 |     split(/,/).map { |s| Shellwords.shellsplit(s) }
 16 |   end
 17 | 
 18 |   ##
 19 |   ## Normalize positional string to symbol
 20 |   ##
 21 |   ## @return     [Symbol] position symbol (:start, :h1, :h2, :end)
 22 |   ##
 23 |   def normalize_position
 24 |     case self
 25 |     when /^(be|s|t)/
 26 |       :start
 27 |     when /h1/
 28 |       :h1
 29 |     when /h2/
 30 |       :h2
 31 |     else
 32 |       :end
 33 |     end
 34 |   end
 35 | 
 36 |   ##
 37 |   ## Normalize a file include string to symbol
 38 |   ##
 39 |   ## @return     [Symbol] include type symbol (:code, :raw, :file)
 40 |   ##
 41 |   def normalize_include_type
 42 |     case self
 43 |     when /^c/
 44 |       :code
 45 |     when /^r/
 46 |       :raw
 47 |     else
 48 |       :file
 49 |     end
 50 |   end
 51 | 
 52 |   ##
 53 |   ## Convert a string boolean to symbol
 54 |   ##
 55 |   ## @return     [Symbol] symbolized version
 56 |   ##
 57 |   def bool_to_symbol
 58 |     case self
 59 |     when /(NOT|!!)/
 60 |       :not
 61 |     when /(AND|&&)/
 62 |       :and
 63 |     else
 64 |       :or
 65 |     end
 66 |   end
 67 | 
 68 |   ##
 69 |   ## Test a string to see if it's a UTC date
 70 |   ##
 71 |   ## @return     [Boolean] test result
 72 |   ##
 73 |   def date?
 74 |     dup.force_encoding("utf-8") =~ /^\d{4}-\d{2}-\d{2}( \d{1,2}(:\d\d)? *([ap]m)?)?$/ ? true : false
 75 |   end
 76 | 
 77 |   ##
 78 |   ## Test a string to see if it includes a time
 79 |   ##
 80 |   ## @return     [Boolean] test result
 81 |   ##
 82 |   def time?
 83 |     dup.force_encoding("utf-8") =~ / \d{1,2}(:\d\d)? *([ap]m)?/i ? true : false
 84 |   end
 85 | 
 86 |   ##
 87 |   ## Convert a natural language string to a Date
 88 |   ##             object
 89 |   ##
 90 |   ## @return     [Date] Resulting Date object
 91 |   ##
 92 |   def to_date
 93 |     Chronic.parse(dup.force_encoding("utf-8"))
 94 |   end
 95 | 
 96 |   ##
 97 |   ## Remove time from string
 98 |   ##
 99 |   ## @return     [String] string with time removed
100 |   ##
101 |   def strip_time
102 |     dup.force_encoding("utf-8").sub(/ \d{1,2}(:\d\d)? *([ap]m)?/i, "")
103 |   end
104 | 
105 |   ##
106 |   ## Round a date string to a day
107 |   ##
108 |   ## @param      time [Symbol]  :start or :end
109 |   ##
110 |   def to_day(time = :end)
111 |     t = time == :end ? "23:59" : "00:00"
112 |     Chronic.parse("#{strip_time} #{t}")
113 |   end
114 | 
115 |   ##
116 |   ## Test if a string is a number
117 |   ##
118 |   ## @return     [Boolean] test result
119 |   ##
120 |   def number?
121 |     to_f.positive?
122 |   end
123 | 
124 |   ##
125 |   ## Test if a string is a boolean
126 |   ##
127 |   ## @return     [Boolean] test result
128 |   ##
129 |   def bool?
130 |     dup.force_encoding("utf-8").match?(/^(?:y(?:es)?|no?|t(?:rue)?|f(?:alse)?)$/)
131 |   end
132 | 
133 |   ##
134 |   ## Test if string starts with YAML
135 |   ##
136 |   ## @return     [Boolean] test result
137 |   ##
138 |   def yaml?
139 |     dup.force_encoding('utf-8').match?(/^---/m)
140 |   end
141 | 
142 |   ##
143 |   ## Test if a string starts with MMD metadata
144 |   ##
145 |   ## @return     [Boolean] test result
146 |   ##
147 |   def meta?
148 |     dup.force_encoding('utf-8').match?(/^\w+: +\S+/m)
149 |   end
150 | 
151 |   ##
152 |   ## Test if a string starts with Pandoc metadata
153 |   ##
154 |   ## @return     [Boolean] test result
155 |   ##
156 |   def pandoc?
157 |     dup.force_encoding('utf-8').match?(/^% \S/m)
158 |   end
159 | 
160 |   ##
161 |   ## Returns a bool representation of the string.
162 |   ##
163 |   ## @return     [Boolean] Bool representation of the object.
164 |   ##
165 |   def to_bool
166 |     case self.dup.force_encoding('utf-8')
167 |     when /^[yt]/i
168 |       true
169 |     else
170 |       false
171 |     end
172 |   end
173 | 
174 |   ##
175 |   ## Convert a string to a regular expression
176 |   ##
177 |   ## If the string matches /xxx/, it will be interpreted
178 |   ## directly as a regex. Otherwise it will be escaped and
179 |   ## converted to regex.
180 |   ##
181 |   ## @return     [Regexp] Regexp representation of the string.
182 |   ##
183 |   def to_rx
184 |     if self =~ %r{^/(.*?)/([im]+)?$}
185 |       m = Regexp.last_match
186 |       regex = m[1]
187 |       flags = m[2]
188 |       Regexp.new(regex, flags)
189 |     else
190 |       Regexp.new(Regexp.escape(self))
191 |     end
192 |   end
193 | 
194 |   ##
195 |   ## Convert a string containing $1, $2 to a Regexp replace pattern
196 |   ##
197 |   ## @return     [String] Pattern representation of the object.
198 |   ##
199 |   def to_pattern
200 |     gsub(/\$(\d+)/, '\\\\\1').gsub(/(^["']|["']$)/, "")
201 |   end
202 | 
203 |   ##
204 |   ## Discard invalid characters and output a UTF-8 String
205 |   ##
206 |   ## @return     [String] UTF-8 encoded string
207 |   ##
208 |   def utf8
209 |     encode('utf-16', invalid: :replace).encode('utf-8')
210 |   end
211 | 
212 |   ##
213 |   ## Destructive version of #utf8
214 |   ##
215 |   ## @return     [String] UTF-8 encoded string, in place
216 |   ##
217 |   def utf8!
218 |     replace scrub
219 |   end
220 | 
221 |   ##
222 |   ## Get a clean UTF-8 string by forcing an ISO encoding and then re-encoding
223 |   ##
224 |   ## @return     [String] UTF-8 string
225 |   ##
226 |   def clean_encode
227 |     force_encoding("ISO-8859-1").encode("utf-8", replace: nil)
228 |   end
229 | 
230 |   ##
231 |   ## Destructive version of #clean_encode
232 |   ##
233 |   ## @return     [String] UTF-8 string, in place
234 |   ##
235 |   def clean_encode!
236 |     replace clean_encode
237 |   end
238 | end
239 | 


--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
  1 | ### 1.0.39
  2 | 
  3 | 2025-01-01 13:55
  4 | 
  5 | #### FIXED
  6 | 
  7 | - Inert_file messing up file extensions
  8 | 
  9 | ### 1.0.38
 10 | 
 11 | 2024-08-23 11:20
 12 | 
 13 | ### 1.0.37
 14 | 
 15 | 2024-08-23 11:04
 16 | 
 17 | #### FIXED
 18 | 
 19 | - Attempt to fix installation on Ruby 2.6
 20 | 
 21 | ### 1.0.36
 22 | 
 23 | 2024-08-23 11:03
 24 | 
 25 | #### FIXED
 26 | 
 27 | - Attempt to fix installation on Ruby 2.6
 28 | 
 29 | ### 1.0.35
 30 | 
 31 | 2024-08-22 10:14
 32 | 
 33 | #### IMPROVED
 34 | 
 35 | - Code cleanup
 36 | - Cleaner UTF-8 string encoding
 37 | 
 38 | ### 1.0.34
 39 | 
 40 | 2024-08-22 10:13
 41 | 
 42 | #### IMPROVED
 43 | 
 44 | - Code cleanup
 45 | - Cleaner UTF-8 string encoding
 46 | 
 47 | ### 1.0.33
 48 | 
 49 | 2024-08-22 06:35
 50 | 
 51 | #### IMPROVED
 52 | 
 53 | - Clean up string encoding in filters
 54 | 
 55 | ### 1.0.32
 56 | 
 57 | 2024-07-31 11:12
 58 | 
 59 | #### FIXED
 60 | 
 61 | - Force UTF-8 encoding on Env and STDIN in command.rb and script.rb
 62 | 
 63 | ### 1.0.31
 64 | 
 65 | 2024-07-29 15:40
 66 | 
 67 | #### FIXED
 68 | 
 69 | - Nil environment variable failure
 70 | 
 71 | ### 1.0.30
 72 | 
 73 | 2024-07-29 11:47
 74 | 
 75 | ### 1.0.29
 76 | 
 77 | 2024-07-28 09:57
 78 | 
 79 | #### NEW
 80 | 
 81 | - Increase/decreaseHeaders(count) filter
 82 | 
 83 | ### 1.0.28
 84 | 
 85 | 2024-07-27 14:54
 86 | 
 87 | #### IMPROVED
 88 | 
 89 | - Code cleanup, fix for IRB
 90 | - Switch to using #size for character counting
 91 | - Begin adding test suite, fixing bugs as found
 92 | 
 93 | ### 1.0.27
 94 | 
 95 | 2024-07-24 13:55
 96 | 
 97 | #### FIXED
 98 | 
 99 | - StripMeta bad regex
100 | 
101 | ### 1.0.26
102 | 
103 | 2024-07-24 13:49
104 | 
105 | #### FIXED
106 | 
107 | - Don't recognize YAML closing line as settext header
108 | 
109 | ### 1.0.25
110 | 
111 | 2024-07-22 12:36
112 | 
113 | #### NEW
114 | 
115 | - `includes contains [file|path]` testing against included files
116 | 
117 | #### IMPROVED
118 | 
119 | - Better env variable handling
120 | 
121 | #### FIXED
122 | 
123 | - Inserting comment when YAML exists breaks YAML
124 | - Ensure newline after MMD metadata
125 | - Error in ensure_h1 if no headers exist
126 | 
127 | ### 1.0.24
128 | 
129 | 2024-07-18 11:32
130 | 
131 | #### IMPROVED
132 | 
133 | - Use Shellwords.shellsplit/join instead of escaping MARKED_INCLUDES environment variable
134 | 
135 | ### 1.0.23
136 | 
137 | 2024-07-17 15:51
138 | 
139 | #### FIXED
140 | 
141 | - Environment variable escaping was hyperactive, only escape includes array
142 | 
143 | ### 1.0.22
144 | 
145 | 2024-07-16 12:30
146 | 
147 | #### IMPROVED
148 | 
149 | - When injecting CSS or JS paths, URL encode the path
150 | 
151 | #### FIXED
152 | 
153 | - Shell escape environment variables
154 | 
155 | ### 1.0.21
156 | 
157 | 2024-07-10 12:18
158 | 
159 | #### NEW
160 | 
161 | - New filter `fixHeaders` will adapt all headlines in the document to be in semantic order
162 | 
163 | ### 1.0.20
164 | 
165 | 2024-07-04 12:18
166 | 
167 | #### NEW
168 | 
169 | - The `insertTitle` filter can now take an argument of `true` or a number and will shift the remaining headlines in the document by 1 (or the number given in the argument), allowing for title insertion while only having 1 H1 in the document.
170 | 
171 | #### IMPROVED
172 | 
173 | - Ignore self-linking urls in single quotes, just in case they're used in a script line
174 | 
175 | ### 1.0.19
176 | 
177 | 2024-07-02 11:25
178 | 
179 | #### FIXED
180 | 
181 | - Bug in creating default config
182 | 
183 | ### 1.0.18
184 | 
185 | 2024-07-02 11:08
186 | 
187 | #### NEW
188 | 
189 | - InsertScript or insertCSS arguments that are URLs will be inserted properly
190 | 
191 | #### FIXED
192 | 
193 | - When prepending styles/files/titles, always inject AFTER existing metadata (YAML or MMD)
194 | 
195 | ### 1.0.17
196 | 
197 | 2024-07-02 10:27
198 | 
199 | #### NEW
200 | 
201 | - AutoLink() filter will self-link bare URLs
202 | 
203 | ### 1.0.16
204 | 
205 | 2024-06-28 12:40
206 | 
207 | #### NEW
208 | 
209 | - New insertCSS filter to inject a stylesheet
210 | - YUI compression for injected CSS
211 | 
212 | ### 1.0.15
213 | 
214 | 2024-05-25 11:14
215 | 
216 | #### NEW
217 | 
218 | - New filter insertTOC(max, after) to insert a table of contents, optionally with max levels and after (start, *h1, or h2)
219 | - New filter prepend/appendFile(path) to include a file (also pre/appendRaw and pre/appendCode)
220 | 
221 | ### 1.0.14
222 | 
223 | 2024-05-25 06:41
224 | 
225 | #### NEW
226 | 
227 | - InsertTitle filter will extract title from metadata or filename and insert an H1 title into the content
228 | - InsertScript will inject a javascript at the end of the content, allows passing multiple scripts separated by comma, and if the path is just a filename, it will look for it in ~/.config/conductor/javascript and insert an absolute path
229 | 
230 | ### 1.0.13
231 | 
232 | 2024-05-24 13:12
233 | 
234 | #### NEW
235 | 
236 | - New type of command -- filter: filterName(parameters), allows things like setStyle(github) or replace_all(regex, pattern) instead of having to write scripts for simple things like this. Can be run in sequences.
237 | 
238 | ### 1.0.12
239 | 
240 | 2024-05-01 13:06
241 | 
242 | #### FIXED
243 | 
244 | - Attempt to fix encoding error
245 | 
246 | ### 1.0.11
247 | 
248 | 2024-04-29 09:46
249 | 
250 | #### FIXED
251 | 
252 | - Reversed symbols when outputting condition matches to STDERR
253 | - Only assume date if it's not part of a filename
254 | 
255 | ### 1.0.10
256 | 
257 | 2024-04-28 14:05
258 | 
259 | #### IMPROVED
260 | 
261 | - Return NOCUSTOM if changes are not made by scripts/commands, even though condition was matched
262 | - Use YAML.load instead of .safe_load to allow more flexibility
263 | - Trap errors reading YAML and fail gracefully
264 | 
265 | #### FIXED
266 | 
267 | - Encoding errors on string methods
268 | 
269 | ### 1.0.9
270 | 
271 | 2024-04-27 16:00
272 | 
273 | #### NEW
274 | 
275 | - Test for pandoc metadata (%%) with `is pandoc` or `is not pandoc`
276 | 
277 | #### FIXED
278 | 
279 | - Filename comparison not working
280 | 
281 | ### 1.0.8
282 | 
283 | 2024-04-27 14:01
284 | 
285 | #### NEW
286 | 
287 | - Add sequence: key to allow running a series of scripts/commands, each piping to the next
288 | - Add `continue: true` for tracks to allow processing to continue after a script/command is successful
289 | - `filename` key for comparing to just filename (instead of full
290 | - Add `is a` tests for `number`, `integer`, and `float`
291 | - Tracks in YAML config can have a title key that will be shown in STDERR 'Conditions met:' output
292 | - Add `does not contain` handling for string and metadata comparisons
293 | 
294 | #### IMPROVED
295 | 
296 | - Allow `has yaml` or `has meta` (MultiMarkdown) as conditions
297 | 
298 | #### FIXED
299 | 
300 | - Use STDIN instead of reading file for conditionals
301 | - String tests read STDIN input, not reading the file itself, allowing for piping between multiple scripts
302 | 
303 | ### 1.0.7
304 | 
305 | 2024-04-26 11:53
306 | 
307 | #### NEW
308 | 
309 | - Added test for MMD metadata, either for presence of meta or for specific keys or key values
310 | 
311 | #### FIXED
312 | 
313 | - Remove some debugging garbage
314 | 
315 | ### 1.0.6
316 | 
317 | 2024-04-26 11:17
318 | 
319 | #### FIXED
320 | 
321 | - Always wait for STDIN or Marked will crash. Still possible to use $file in script/command values
322 | - More string encoding fixes
323 | - "path contains" was returning $PATH instead of the filepath
324 | 
325 | ### 1.0.5
326 | 
327 | 2024-04-25 17:00
328 | 
329 | #### FIXED
330 | 
331 | - First-run config creating directory instead of file
332 | - Frozen string/encoding issue on string comparisons
333 | 
334 | ### 1.0.3
335 | 
336 | 2024-04-25 14:32
337 | 
338 | #### FIXED
339 | 
340 | - YAML true/false testing
341 | 
342 | ### 1.0.2
343 | 
344 | 2024-04-25 14:15
345 | 
346 | #### CHANGED
347 | 
348 | - Prepped for gem release
349 | 
350 | #### FIXED
351 | 
352 | - Encoding issue affecting Shellwords.escape
353 | 
354 | ### 1.0.0
355 | 
356 | 2024-04-25 10:51
357 | 
358 | #### NEW
359 | 
360 | - 1.0 release
361 | 
362 | ### 0.1.0
363 | 
364 | - Initial release
365 | 


--------------------------------------------------------------------------------
/spec/condition_spec.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require 'spec_helper'
  4 | 
  5 | describe Conductor::Condition do
  6 |   subject(:cond) do
  7 |     Conductor::Condition
  8 |   end
  9 | 
 10 |   describe ".new" do
 11 |     it "makes a new Condition instance" do
 12 |       expect(cond.new('filename ends with md')).to be_a Conductor::Condition
 13 |     end
 14 |   end
 15 | 
 16 |   describe ".true?" do
 17 |     it "tests condition" do
 18 |       ENV["CONDUCTOR_TEST"] = "true"
 19 |       c = cond.new("filename ends with md")
 20 |       expect(c.true?).to be true
 21 |     end
 22 |   end
 23 | 
 24 |   describe ".parse_condition" do
 25 |     it "handles parentheticals" do
 26 |       ENV["CONDUCTOR_TEST"] = "true"
 27 |       c = cond.new("filename ends with md AND (has yaml OR has meta OR has pandoc OR parent contains .obsidian) AND NOT true")
 28 |       expect(c.parse_condition).to be false
 29 |     end
 30 |   end
 31 | 
 32 |   describe ".split_booleans" do
 33 |     it "splits and tests booleans" do
 34 |       ENV["CONDUCTOR_TEST"] = "true"
 35 |       c = cond.new("filename ends with md AND (has yaml OR has meta OR has pandoc OR parent contains .obsidian) AND NOT true")
 36 |       expect(c.split_booleans(c.condition)).to be false
 37 | 
 38 |       c = cond.new("filename ends with md OR true")
 39 |       expect(c.split_booleans(c.condition)).to be true
 40 |     end
 41 |   end
 42 | 
 43 |   describe ".test_operator" do
 44 |     it "tests operators" do
 45 |       c = cond.new("filename ends with md")
 46 |       expect(c.test_operator(1, 2, :lt)).to be true
 47 |       expect(c.test_operator(1, 2, :gt)).to be false
 48 |       expect(c.test_operator(1, 2, :lt)).to be true
 49 |       expect(c.test_operator("a silly test", "silly", :contains)).to be_truthy
 50 |       expect(c.test_operator("a silly test", "silly", :not_contains)).to be false
 51 |       expect(c.test_operator("a silly test", "silly", :starts_with)).to be_falsy
 52 |       expect(c.test_operator("a silly test", "test", :ends_with)).to be_truthy
 53 |       expect(c.test_operator("test", "test", :equal)).to be true
 54 |       expect(c.test_operator("test", "test", :not_equal)).to be false
 55 |     end
 56 |   end
 57 | 
 58 |   describe ".operator_to_symbol" do
 59 |     it "tests operators" do
 60 |       c = cond.new("filename ends with md")
 61 |       operators = {
 62 |                     "greater than" => :gt,
 63 |                     "less than" => :lt,
 64 |                     "not contains file" => :not_includes_file,
 65 |                     "not contains path" => :not_includes_path,
 66 |                     "contains file" => :includes_file,
 67 |                     "contains path" => :includes_path,
 68 |                     "not matches" => :not_contains,
 69 |                     "matches" => :contains,
 70 |                     "has" => :contains,
 71 |                     "*=" => :contains,
 72 |                     "not ends with" => :not_ends_with,
 73 |                     "not begins with" => :not_starts_with,
 74 |                     "ends with" => :ends_with,
 75 |                     "begins with" => :starts_with,
 76 |                     "is not a" => :not_type_of,
 77 |                     "is a" => :type_of,
 78 |                     "does not equal" => :not_equal,
 79 |                     "!==" => :not_equal,
 80 |                     "equals" => :equal
 81 |                   }
 82 |       operators.each do |op, sym|
 83 |         expect(c.operator_to_symbol(op)).to eq sym
 84 |       end
 85 |     end
 86 |   end
 87 | 
 88 |   describe ".test_type" do
 89 |     it "tests types" do
 90 |       c = cond.new("filename ends with md")
 91 |       expect(c.test_type(1, "number", :type_of)).to be true
 92 |       expect(c.test_type(1, "integer", :type_of)).to be true
 93 |       expect(c.test_type(1.00, "float", :type_of)).to be true
 94 |       expect(c.test_type(%w[1], "array", :type_of)).to be true
 95 |       expect(c.test_type("test", "string", :type_of)).to be true
 96 |       expect(c.test_type('2021-04-12 14:00', "date", :type_of)).to be true
 97 |     end
 98 |   end
 99 | 
100 |   describe ".test_includes" do
101 |     it "tests types" do
102 |       includes = ["~/test/filename.md", "~/test/funky.md"]
103 |       c = cond.new("filename ends with md")
104 |       expect(c.test_includes(includes, "filename.md", :includes_file)).to be true
105 |       expect(c.test_includes(includes, "filename2.md", :not_includes_file)).to be true
106 |       expect(c.test_includes(includes, "test", :includes_path)).to be true
107 |       expect(c.test_includes(includes, "funky", :not_includes_path)).to be false
108 |       expect(c.test_includes(includes, "funky", :unknown)).to be false
109 |     end
110 |   end
111 | 
112 |   describe ".test_string" do
113 |     it "tests types" do
114 |       c = cond.new("filename ends with md")
115 |       expect(c.test_string("2024-12-12", "2024-12-11", :gt)).to be true
116 |       expect(c.test_string("2024-12-11 11pm", "2024-12-12 5pm", :lt)).to be true
117 |       expect(c.test_string("2024-12-11 11:00", "2024-12-11 11:00", :equal)).to be true
118 |       expect(c.test_string("2024-12-11 11:00", "2024-12-11 11:00", :not_equal)).to be false
119 |       expect(c.test_string("once upon a time", "once", :not_starts_with)).to be false
120 |       expect(c.test_string("once upon a time", "once", :not_ends_with)).to be true
121 |       expect(c.test_string("once upon a time", "once", :not_equal)).to be true
122 |       expect(c.test_string("once upon a time", "once", :unknown)).to be false
123 |       expect(c.test_string("once upon a time", "/once/", :contains)).to be true
124 |     end
125 |   end
126 | 
127 |   describe ".test_tree" do
128 |     it "tests for file in parent tree" do
129 |       c = cond.new("filename ends with md")
130 |       origin = File.expand_path(File.join(File.dirname(__FILE__), "..", "lib", "conductor", "string.rb"))
131 |       expect(c.test_tree(origin, "hash.rb", :unknown)).to be true
132 |       expect(c.test_tree(origin, "conductor", :unknown)).to be true
133 |       expect(c.test_tree(origin, "finkle.txt", :unknown)).to be false
134 |     end
135 |   end
136 | 
137 |   describe ".test_truthy" do
138 |     it "tests for truthiness" do
139 |       c = cond.new("filename ends with md")
140 |       expect(c.test_truthy(true, "yes", :equal)).to be true
141 |       expect(c.test_truthy(true, "yes", :not_equal)).to be false
142 |       expect(c.test_truthy(false, "false", :equal)).to be true
143 |       expect(c.test_truthy(false, "false", :not_equal)).to be false
144 |     end
145 |   end
146 | 
147 |   describe ".test_yaml" do
148 |     it "tests for yaml" do
149 |       c = cond.new("filename ends with md")
150 |       expect(c.test_yaml(test_yaml_meta, "Brett Terpstra", "author", :equal)).to be true
151 |       expect(c.test_yaml(test_yaml_meta, "Brett Terpstra", "title", :not_equal)).to be true
152 |       expect(c.test_yaml(test_yaml_meta, nil, "title", :equal)).to be true
153 |       expect(c.test_yaml(test_yaml_meta, "string", "title", :type_of)).to be true
154 |       expect(c.test_yaml(test_yaml_meta, true, "comments", :equal)).to be true
155 |       expect(c.test_yaml(test_yaml_meta, 5, "value", :equal)).to be true
156 |       expect(c.test_yaml(test_yaml_meta, "value", nil, :equal)).to be true
157 |       expect(c.test_yaml(test_bad_yaml, "value", nil, :equal)).to be false
158 |     end
159 |   end
160 | 
161 |   describe ".test_meta" do
162 |     it "tests for mmd meta" do
163 |       c = cond.new("filename ends with md")
164 |       expect(c.test_meta(test_mmd_meta, "Brett Terpstra", "author", :equal)).to be true
165 |       expect(c.test_meta(test_mmd_meta, "Brett Terpstra", "title", :not_equal)).to be true
166 |       expect(c.test_meta(test_mmd_meta, nil, "title", :equal)).to be true
167 |       expect(c.test_meta(test_mmd_meta, "string", "title", :type_of)).to be true
168 |       expect(c.test_meta(test_mmd_meta, true, "comments", :equal)).to be true
169 |       expect(c.test_meta(test_mmd_meta, 5, "value", :equal)).to be true
170 |       expect(c.test_meta(test_mmd_meta, "value", nil, :equal)).to be true
171 |       expect(c.test_meta(test_bad_yaml, "value", nil, :equal)).to be false
172 |     end
173 |   end
174 | 
175 |   describe ".test_condition" do
176 |     it "tests a boolean condition" do
177 |       c = cond.new("false")
178 |       expect(c.test_condition("false")).to be false
179 |       expect(c.test_condition("ext is txt")).to be false
180 |       expect(c.test_condition("tree contains hello")).to be false
181 |       expect(c.test_condition("path contains hello")).to be false
182 |       expect(c.test_condition("text contains hello")).to be false
183 |     end
184 | 
185 |     it "tests a file include" do
186 |       c = cond.new("false")
187 |       expect(c.test_condition("include contains style.css")).to be false
188 |     end
189 | 
190 |     it "tests an environment variable" do
191 |       ENV["DONT_FEEL"] = "tardy"
192 |       c = cond.new("false")
193 |       expect(c.test_condition("env:DONT_FEEL is tardy")).to be true
194 |       expect(c.test_condition("env has DONT_FEEL")).to be true
195 |     end
196 |   end
197 | end
198 | 


--------------------------------------------------------------------------------
/lib/conductor/yui_compressor.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | #
  3 | # This is a Ruby port of the YUI CSS compressor
  4 | # See LICENSE for license information
  5 | 
  6 | module YuiCompressor
  7 |   # Compress CSS rules using a variety of techniques
  8 |   class Yui
  9 |     attr_reader :input_size, :output_size
 10 | 
 11 |     ##
 12 |     ## Instantiate compressor
 13 |     ##
 14 |     ## @return     [Yui] self
 15 |     ##
 16 |     def initialize
 17 |       @preserved_tokens = []
 18 |       @comments = []
 19 |       @input_size = 0
 20 |       @output_size = 0
 21 |     end
 22 | 
 23 |     ##
 24 |     ## YUI Compress string
 25 |     ##
 26 |     ## @param      css          [String] The css
 27 |     ## @param      line_length  [Integer] The line length
 28 |     ##
 29 |     def compress(css, line_length = 0)
 30 |       @input_size = css.length
 31 | 
 32 |       css = process_comments_and_strings(css)
 33 | 
 34 |       # Normalize all whitespace strings to single spaces. Easier to work with that way.
 35 |       css.gsub!(/\s+/, " ")
 36 | 
 37 |       # Remove the spaces before the things that should not have spaces before them.
 38 |       # But, be careful not to turn "p :link {...}" into "p:link{...}"
 39 |       # Swap out any pseudo-class colons with the token, and then swap back.
 40 |       css.gsub!(/(?:^|\})[^{:]+\s+:+[^{]*\{/) do |match|
 41 |         match.gsub(":", "___PSEUDOCLASSCOLON___")
 42 |       end
 43 |       css.gsub!(/\s+([!{};:>+()\],])/, '\1')
 44 |       css.gsub!(/([!{}:;>+(\[,])\s+/, '\1')
 45 |       css.gsub!("___PSEUDOCLASSCOLON___", ":")
 46 | 
 47 |       # special case for IE
 48 |       css.gsub!(/:first-(line|letter)(\{|,)/, ':first-\1 \2')
 49 | 
 50 |       # no space after the end of a preserved comment
 51 |       css.gsub!(%r{\*/ }, "*/")
 52 | 
 53 |       # If there is a @charset, then only allow one, and push to the top of the file.
 54 |       css.gsub!(/^(.*)(@charset "[^"]*";)/i, '\2\1')
 55 |       css.gsub!(/^(\s*@charset [^;]+;\s*)+/i, '\1')
 56 | 
 57 |       # Put the space back in some cases, to support stuff like
 58 |       # @media screen and (-webkit-min-device-pixel-ratio:0){
 59 |       css.gsub!(/\band\(/i, "and (")
 60 | 
 61 |       # remove unnecessary semicolons
 62 |       css.gsub!(/;+\}/, "}")
 63 | 
 64 |       # Replace 0(%, em, ex, px, in, cm, mm, pt, pc) with just 0.
 65 |       css.gsub!(/([\s:])([+-]?0)(?:%|em|ex|px|in|cm|mm|pt|pc)/i, '\1\2')
 66 | 
 67 |       # Replace 0 0 0 0; with 0.
 68 |       css.gsub!(/:(?:0 )+0(;|\})/, ':0\1')
 69 | 
 70 |       # Restore background-position:0 0; if required
 71 |       css.gsub!(/(background-position|(?:(?:webkit|moz|o|ms)-)?transform-origin):0(;|\})/i) do
 72 |         "#{::Regexp.last_match(1).downcase}:0 0#{::Regexp.last_match(2)}"
 73 |       end
 74 | 
 75 |       # Replace 0.6 with .6, but only when preceded by : or a space.
 76 |       css.gsub!(/(:|\s)0+\.(\d+)/, '\1.\2')
 77 | 
 78 |       # Shorten colors from rgb(51,102,153) to #336699
 79 |       # This makes it more likely that it'll get further compressed in the next step.
 80 |       css.gsub!(/rgb\s*\(\s*([0-9,\s]+)\s*\)/) do |_match|
 81 |         "#".dup << ::Regexp.last_match(1).scan(/\d+/).map { |n| n.to_i.to_s(16).rjust(2, "0") }.join
 82 |       end
 83 | 
 84 |       # Shorten colors from #AABBCC to #ABC. Note that we want to make sure
 85 |       # the color is not preceded by either ", " or =. Indeed, the property
 86 |       #     filter: chroma(color="#FFFFFF");
 87 |       # would become
 88 |       #     filter: chroma(color="#FFF");
 89 |       # which makes the filter break in IE.
 90 |       css.gsub!(/([^"'=\s])(\s?)\s*#([0-9a-f])\3([0-9a-f])\4([0-9a-f])\5/i, '\1\2#\3\4\5')
 91 | 
 92 |       # border: none -> border:0
 93 |       css.gsub!(/(border|border-(top|right|bottom)|outline|background):none(;|\})/i) do
 94 |         "#{::Regexp.last_match(1).downcase}:0#{::Regexp.last_match(2)}"
 95 |       end
 96 | 
 97 |       # shorter opacity IE filter
 98 |       css.gsub!(/progid:DXImageTransform\.Microsoft\.Alpha\(Opacity=/i, "alpha(opacity=")
 99 | 
100 |       # Remove empty rules.
101 |       css.gsub!(%r{[^\};\{/]+\{\}}, "")
102 | 
103 |       if line_length.positive?
104 |         # Some source control tools don't like it when files containing lines longer
105 |         # than, say 8000 characters, are checked in. The linebreak option is used in
106 |         # that case to split long lines after a specific column.
107 |         start_index = 0
108 |         idx = 0
109 |         length = css.length
110 |         while idx < length
111 |           idx += 1
112 |           if css[idx - 1, 1] == "}" && idx - start_index > line_length
113 |             css = "#{css.slice(0, idx)}\n#{css.slice(idx, length)}"
114 |             start_index = idx
115 |           end
116 |         end
117 |       end
118 | 
119 |       # Replace multiple semi-colons in a row by a single one
120 |       # See SF bug #1980989
121 |       css.gsub!(/;+/, ";")
122 | 
123 |       # restore preserved comments and strings
124 |       css = restore_preserved_comments_and_strings(css)
125 | 
126 |       # top and tail whitespace
127 |       css.strip!
128 | 
129 |       @output_size = css.length
130 |       css
131 |     end
132 | 
133 |     ##
134 |     ## Replace comments and strings with placeholders
135 |     ##
136 |     ## @param      css_text  [String] The css text
137 |     ##
138 |     ## @return [String] css text with strings replaced
139 |     def process_comments_and_strings(css_text)
140 |       css = css_text.dup.clean_encode
141 | 
142 |       start_index = 0
143 |       token = ""
144 |       totallen = css.length
145 |       placeholder = ""
146 | 
147 |       # collect all comment blocks
148 |       while (start_index = css.index(%r{/\*}, start_index))
149 |         end_index = css.index(%r{\*/}, start_index + 2)
150 |         end_index ||= totallen
151 |         token = css.slice(start_index + 2..end_index - 1)
152 |         @comments.push(token)
153 |         css =  "#{css.slice(0..start_index + 1)}___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_#{@comments.length - 1}___#{css.slice(end_index, totallen)}"
154 |         start_index += 2
155 |       end
156 | 
157 |       # preserve strings so their content doesn't get accidentally minified
158 |       css = css.gsub(/("([^\\"]|\\.|\\)*")|('([^\\']|\\.|\\)*')/) do |match|
159 |         quote = match[0, 1]
160 |         string = match.slice(1..-2)
161 | 
162 |         # maybe the string contains a comment-like substring?
163 |         # one, maybe more? put'em back then
164 |         if string =~ /___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_/
165 |           @comments.each_index do |idx|
166 |             string.gsub!(/___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_#{idx}___/, @comments[idx])
167 |           end
168 |         end
169 | 
170 |         # minify alpha opacity in filter strings
171 |         string.gsub!(/progid:DXImageTransform\.Microsoft\.Alpha\(Opacity=/i, "alpha(opacity=")
172 |         @preserved_tokens.push(string)
173 | 
174 |         "#{quote}___YUICSSMIN_PRESERVED_TOKEN_#{@preserved_tokens.length - 1}___#{quote}"
175 |       end
176 | 
177 |       # # used to jump one index in loop
178 |       # ie5_hack = false
179 |       # strings are safe, now wrestle the comments
180 |       @comments.each_index do |idx|
181 |         # if ie5_hack
182 |         #   ie5_hack = false
183 |         #   next
184 |         # end
185 | 
186 |         token = @comments[idx]
187 |         placeholder = "___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_#{idx}___"
188 | 
189 |         # ! in the first position of the comment means preserve
190 |         # so push to the preserved tokens keeping the !
191 |         if token[0, 1] == "!"
192 |           @preserved_tokens.push(token)
193 |           css.gsub!(/#{placeholder}/i, "___YUICSSMIN_PRESERVED_TOKEN_#{@preserved_tokens.length - 1}___")
194 |           next
195 |         end
196 | 
197 |         # # \ in the last position looks like hack for Mac/IE5
198 |         # # shorten that to /*\*/ and the next one to /**/
199 |         # if token[-1, 1] == "\\"
200 |         #   @preserved_tokens.push("\\")
201 |         #   css.gsub!(/#{placeholder}/, "___YUICSSMIN_PRESERVED_TOKEN_#{@preserved_tokens.length - 1}___")
202 |         #   # keep the next comment but remove its content
203 |         #   @preserved_tokens.push("")
204 |         #   css.gsub!(/___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_#{idx + 1}___/,
205 |         #             "___YUICSSMIN_PRESERVED_TOKEN_#{@preserved_tokens.length - 1}___")
206 |         #   ie5_hack = true
207 |         #   next
208 |         # end
209 | 
210 |         # # keep empty comments after child selectors (IE7 hack)
211 |         # # e.g. html >/**/ body
212 |         # if token.empty? && (start_index = css.index(/#{placeholder}/)) &&
213 |         #    (start_index > 2) && (css[start_index - 3, 1] == ">")
214 |         #   @preserved_tokens.push("")
215 |         #   css.gsub!(/#{placeholder}/, "___YUICSSMIN_PRESERVED_TOKEN_#{@preserved_tokens.length - 1}___")
216 |         # end
217 | 
218 |         # in all other cases kill the comment
219 |         css.gsub!(%r{/\*#{placeholder}\*/}, "")
220 |       end
221 | 
222 |       css
223 |     end
224 | 
225 |     ##
226 |     ## Restore saved comments and strings
227 |     ##
228 |     ## @param      clean_css  [String] The processed css
229 |     ##
230 |     ## @return     [String] restored CSS
231 |     ##
232 |     def restore_preserved_comments_and_strings(clean_css)
233 |       css = clean_css.clone
234 |       css_length = css.length
235 |       @preserved_tokens.each_index do |idx|
236 |         # slice these back into place rather than regex, because
237 |         # complex nested strings cause the replacement to fail
238 |         placeholder = "___YUICSSMIN_PRESERVED_TOKEN_#{idx}___"
239 |         start_index = css.index(placeholder, 0)
240 |         next unless start_index # skip if nil
241 | 
242 |         end_index = start_index + placeholder.length
243 | 
244 |         css = css.slice(0..start_index - 1).to_s + @preserved_tokens[idx] + css.slice(end_index, css_length).to_s
245 |       end
246 | 
247 |       css
248 |     end
249 |   end
250 | end
251 | 


--------------------------------------------------------------------------------
/spec/filter_string_spec.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require "spec_helper"
  4 | 
  5 | describe ::String do
  6 |   subject(:string) do
  7 |     test_markdown
  8 |   end
  9 | 
 10 |   subject(:mmd) do
 11 |     test_mmd_meta
 12 |   end
 13 | 
 14 |   subject(:yaml) do
 15 |     test_yaml_meta
 16 |   end
 17 | 
 18 |   subject(:pandoc) do
 19 |     test_pandoc_meta
 20 |   end
 21 | 
 22 |   describe ".normalize_filter" do
 23 |     it "outputs array of filter and arguments" do
 24 |       filter1 = "insert_file"
 25 |       filter2 = "insertCSS(filename.css)"
 26 |       filter3 = "addmonster(cookie, monster)"
 27 |       expect(filter1.normalize_filter[0]).to match(/insertfile/)
 28 |       expect(filter1.normalize_filter).to match_array(["insertfile", nil])
 29 |       expect(filter2.normalize_filter[0]).to match(/insertcss/)
 30 |       expect(filter2.normalize_filter).to match_array(["insertcss", ["filename.css"]])
 31 |       expect(filter3.normalize_filter[0]).to match(/addmonster/)
 32 |       expect(filter3.normalize_filter).to match_array(["addmonster", %w[cookie monster]])
 33 |     end
 34 |   end
 35 | 
 36 |   describe ".meta_type" do
 37 |     it "Detects metadata type" do
 38 |       expect(mmd.meta_type).to eq :mmd
 39 |       expect(pandoc.meta_type).to eq :pandoc
 40 |       expect(yaml.meta_type).to eq :yaml
 41 |     end
 42 |   end
 43 | 
 44 |   describe ".meta_insert_point" do
 45 |     it "Detects metadata position" do
 46 |       expect(mmd.meta_insert_point).to eq 4
 47 |       expect(yaml.meta_insert_point).to eq 5
 48 |       expect(pandoc.meta_insert_point).to eq 2
 49 |     end
 50 |   end
 51 | 
 52 |   describe ".first_h1" do
 53 |     it "finds the first h1" do
 54 |       expect(test_no_h1.first_h1).to eq nil
 55 |       expect(string.first_h1).to eq 0
 56 |       expect(yaml.first_h1).to eq 6
 57 |     end
 58 |   end
 59 | 
 60 |   describe ".first_h2" do
 61 |     it "finds the first h2" do
 62 |       expect(test_no_h1.first_h2).to eq nil
 63 |       expect(string.first_h2).to eq 2
 64 |       expect(yaml.first_h2).to eq 8
 65 |     end
 66 |   end
 67 | 
 68 |   describe ".decrease_headers" do
 69 |     it "decreases all headers" do
 70 |       expect(test_no_h1.decrease_headers(1)).to match(/^## No H1/)
 71 |       expect(string.decrease_headers(2)).to match(/^# Conductor Test/)
 72 |     end
 73 |   end
 74 | 
 75 |   describe ".increase_headers" do
 76 |     it "increases all headers" do
 77 |       expect(test_no_h1.increase_headers(1)).to match(/^#### No H1/)
 78 |       expect(string.increase_headers(2)).to match(/^### Conductor Test/)
 79 |     end
 80 |   end
 81 | 
 82 |   describe ".insert_toc" do
 83 |     it "Inserts toc tag appropriately" do
 84 |       expect(string.insert_toc(3, :h1)).to match(//)
 85 |       expect(string.insert_toc(nil, :h2)).to match(//)
 86 |     end
 87 |   end
 88 | 
 89 |   describe ".append" do
 90 |     it "appends string" do
 91 |       expect(string.append("test append")).to match(/EOF\n\ntest append/)
 92 |       expect(string.dup.append!("test append")).to match(/EOF\n\ntest append/)
 93 |     end
 94 |   end
 95 | 
 96 |   describe ".inject_after_meta" do
 97 |     it "appends string" do
 98 |       expect(mmd.inject_after_meta("test append")).to match(/true\n\ntest append/)
 99 |       expect(yaml.inject_after_meta("test append")).to match(/---\ntest append/)
100 |       expect(pandoc.inject_after_meta("test append")).to match(/Terpstra\n\ntest append/)
101 |     end
102 |   end
103 | 
104 |   describe ".wrap_style" do
105 |     it "correctly wraps content in style tags" do
106 |       string1 = "body{width:100%}"
107 |       string2 = ""
108 |       expect(string1.wrap_style).to match(%r{})
109 |       expect(string2.wrap_style).to match(%r{})
110 |     end
111 |   end
112 | 
113 |   describe ".insert_stylesheet" do
114 |     it "correctly inserts style tag" do
115 |       test_css
116 |       expect(yaml.insert_stylesheet("./test_style.css")).to match(%r{---\n})
117 |     end
118 |   end
119 | 
120 |   describe ".insert_css" do
121 |     it "outputs content with injected, compressed CSS" do
122 |       content = test_markdown
123 |       test_css
124 |       expect(content.insert_css("./test_style.css")).to match(/\{transition:transform 100ms ease-in-out\}/)
125 |       delete_css
126 |     end
127 |   end
128 | 
129 |   describe ".inject_after_meta" do
130 |     it "outputs content with injected string" do
131 |       expect(yaml.inject_after_meta("test inject")).to match(/---\ntest inject/)
132 |       expect(mmd.inject_after_meta("test inject")).to match(/true\n\ntest inject/)
133 |       expect(pandoc.inject_after_meta("test inject")).to match(/Terpstra\n\ntest inject/)
134 |       expect(string.inject_after_meta("test inject")).to match(/^test inject/)
135 |     end
136 |   end
137 | 
138 |   describe ".insert_file" do
139 |     it "inserts file include syntax in string" do
140 |       # expect {
141 |       #   ENV['RUBYOPT'] = '-W1'
142 |       #   string.insert_file('not_exists.md')
143 |       # }.to output(/not found/).to_stderr
144 |       test_insert
145 |       expect(string.insert_file("./insert.md", :file, :h2)).to match(/Balogna\n\n<<\[/)
146 |       expect(yaml.insert_file("./insert.md", :file, :start)).to match(/---\n\n<<\[/)
147 |       expect(yaml.insert_file("./insert.md", :code, :h2)).to match(/h2\n\n<<\(/)
148 |       expect(string.insert_file("./insert.md", :raw, :h1)).to match(/Conductor Test\n\n<<\{/)
149 |       delete_insert
150 |     end
151 |   end
152 | 
153 |   describe ".insert_javascript" do
154 |     it "inserts a javascript tag" do
155 |       expect(string.insert_javascript('test.js')).to match(%r{\n\n\Z})
156 |     end
157 |   end
158 | 
159 |   describe ".insert_raw_javascript" do
160 |     it "inserts a raw script tag" do
161 |       expect(string.insert_raw_javascript('void();')).to match(%r{\n\n\n\Z})
162 |     end
163 |   end
164 | 
165 |   describe ".insert_script" do
166 |     it "inserts a raw script tag" do
167 |       expect(string.insert_script('void();')).to match(%r{\n\n\n\Z})
168 |     end
169 | 
170 |     it "inserts a script tag" do
171 |       expect(string.insert_script('https://brettterpstra.com/scripts/script.js')).to match(%r{\n\n\Z})
172 |     end
173 | 
174 |     it "inserts a file reference" do
175 |       test_javascript
176 |       expect(string.insert_script('./test_script.js')).to match(%r{\n\n\Z})
177 |       delete_javascript
178 |     end
179 |   end
180 | 
181 |   describe ".title_from_slug" do
182 |     it "determines a correct title" do
183 |       ENV["CONDUCTOR_TEST"] = "true"
184 |       expect(string.title_from_slug).to match(/automating the dim/)
185 |     end
186 |   end
187 | 
188 |   describe ".read_title" do
189 |     it "determines correct title" do
190 |       ENV["CONDUCTOR_TEST"] = "true"
191 |       expect(string.read_title).to match(/Automating The Dim/)
192 |       expect(yaml.read_title).to match(/This is my document/)
193 |       expect(mmd.read_title).to match(/This is my document/)
194 |       expect(pandoc.read_title).to match(/This is my document/)
195 |     end
196 |   end
197 | 
198 |   describe ".insert_title" do
199 |     it "inserts correct title" do
200 |       ENV["CONDUCTOR_TEST"] = "true"
201 |       expect(string.insert_title(shift: 1)).to match(/^# Automating The Dim/)
202 |       expect(yaml.insert_title(shift: 1)).to match(/^# This is my document/)
203 |     end
204 |   end
205 | 
206 |   describe ".set_meta" do
207 |     it "sets meta correctly based on type" do
208 |       expect(string.set_meta("title", "Replaced title", style: string.meta_type)).to match(/^/)
216 |     end
217 |   end
218 | 
219 |   describe ".add_comment" do
220 |     it "adds and updates comment meta" do
221 |       out = string.add_comment('style', 'grump')
222 |       expect(out).to match(/^`. 
104 |     - If the test value is surrounded by forward slashes, it will be treated as a regular expression. Regexes are always flagged as case insensitive. Use it like `text contains /@\w+/`.
105 | - `yaml`, `headers`, or `frontmatter` will test for YAML headers. If a `yaml:KEY` is defined, a specific YAML key will be tested for. If a value is defined with an operator, it will be tested against the value key.
106 |     - `yaml` tests for the presence of YAML frontmatter.
107 |     - `yaml:comments` tests for the presence of a `comments` key.
108 |     - `yaml:comments is true` tests whether `comments: true` exists.
109 |     - `yaml:tags contains appreview` will test whether the tags array contains `appreview`.
110 |     - If the YAML key is a date, it can be tested against with `before`, `after`, and `is`, and the value can be a natural language date, e.g. `yaml:date is after may 3, 2024`
111 |     - If both the YAML key value and the test value are numbers, you can use operators `greater than` (`>`), `less than` (`<`), `equal`/`is` (`=`/`==`), and `is not equal`/`not equals` (`!=`/`!==`). Numbers will be interpreted as floats.
112 |     - If the YAML value is a boolean, you can test with `is true` or `is not true` (or `is false`)
113 | - `mmd` or `meta` will test for MultiMarkdown metadata using the same formatting as `yaml` above.
114 | - `includes` are files included in the document with special syntax (Marked, IA Writer, etc.)
115 |     - `includes contain file` or `includes not contains file` will test all included files for filename matches
116 |     - `includes contain path` or `includes not contains path` will test all included files for fragment matches anywhere in the path
117 | - `env:KEY matches VALUE` will test for matching values in a environment key. All string matching operators are available, and `env[KEY]` syntax will also work.
118 |     - `env contains KEY` tests just for the existence of an environment variable key (can include variables set by Marked).
119 | - The following keywords act as a catchall and can be used as the last track in the config to act on any documents that aren't matched by preceding rules:
120 |     - `any`
121 |     - `else`
122 |     - `all`
123 |     - `true`
124 |     - `catchall`
125 | 
126 | Available comparison operators are:
127 | 
128 | - `is` or `equals` (negate with `is not` or `does not equal`) tests for equality on strings, numbers, or dates
129 | - `contains` or `includes` (negate with `does not contain`) tests on strings or array values
130 | - `begins with` (or `starts with`) or `ends with` (negate with `does not begin with`) tests on strings
131 | - `greater than` or `less than` (tests on numbers or dates)
132 | 
133 | Conditions can be combined with AND or OR (must be uppercase) and simple parenthetical operations will work (parenthesis can not be nested). A boolean condition would look like `path contains _posts AND extension is md` or `(tree includes .obsidian AND extension is todo) OR extension is taskpaper`.
134 | 
135 | ### Actions
136 | 
137 | The action can be `script`, `command`, or `filter`. 
138 | 
139 | #### Scripts
140 | 
141 | **Scripts** are located in `~/.config/conductor/scripts/` and should be executable files that take input on STDIN (unless `$file` is specified in the `script` definition). If a script is defined starting with `~` or `/`, that will be interpreted as a full path to an alternate location.
142 | 
143 | > Example:
144 | > 
145 | >    script: github_pre
146 | 
147 | #### Commands
148 | 
149 | **Commands** are interpreted as shell commands. If a command exists in the `$PATH`, a full path will automatically be determined, so a command can be as simple as just `pandoc`. Add any arguments needed after the command.
150 | 
151 | > Example:
152 | > 
153 | >    command: multimarkdown
154 | 
155 | 
156 | > Using `$file` as an argument to a script or command will bypass processing of STDIN input, and instead use the value of $MARKED_PATH to read the contents of the specified file.
157 | 
158 | #### Filters
159 | 
160 | **Filters** are simple actions that can be run on the content without having to write a separate script for it. Available filters are:
161 | 
162 | | filter | description |
163 | | :----  | :---------- |
164 | | `setMeta(key, value)` | adds or updates a meta key, aware of YAML and MMD |
165 | | `stripMeta` | strips all metadata (YAML or MMD) from the content |
166 | | `deleteMeta(key)` | removes a specific key (YAML or MMD) |
167 | | `setStyle(name)` | sets the Marked preview style to a preconfigured Style name |
168 | | `replace(search, replace)` | performs a (single) search and replace on content | 
169 | | `replaceAll(search, replace)` | global version of `replaceAll`) |
170 | | `insertTitle` | adds a title to the document, either from metadata or filename |
171 | | `insertScript(path[,path])` | injects javascript(s) |
172 | | `insertTOC(max, after)` | insert TOC (max=max levels, after=start, \*h1, or h2) |
173 | | `prepend/appendFile(path)` | insert a file as Markdown at beginning or end of content |
174 | | `prepend/appendRaw(path)` | insert a file as raw HTML at beginning or end of content |
175 | | `prepend/appendCode(path)` | insert a file as a code block at beginning or end of content |
176 | | `insertCSS(path)` | insert custom CSS into document |
177 | | `autoLink()` | Turn bare URLs into \ urls |
178 | | `fixHeaders()` | Reorganize headline levels to semantic order |
179 | | `increaseHeaders(count) | Increase header levels by count (default 1) |
180 | | `decreaseHeaders(count) | Decrease header levels by count (default 1) |
181 | 
182 | For `replace` and `replaceAll`: If *search* is surrounded with forward slashes followed by optional flags (*i* for case-insensitive, *m* to make dot match newlines), e.g. `/contribut(ing)?/i`, it will be interpreted as a regular expression. The *replace* value can include numeric capture groups, e.g. `Follow$2`.
183 | 
184 | For `insertScript`, if path is just a filename it will look for a match in `~/.config/conductor/javascript` or `~/.config/conductor/scripts` and turn that into an absolute path if the file is found.
185 | 
186 | For `insertCSS`, if path is just a filename (with or without .css extension), the file will be searched for in `~/.config/conductor/css` or `~/.config/conductor/files` and injected. CSS will be compressed using the YUI algorithm and inserted at the top of the document, but after any existing metadata.
187 | 
188 | For `insertTitle`, if an argument of `true` or a number is given (e.g. `insertTitle(true)`, the headers in the document will be shifted by 1 (or by the number given) so that there's only one H1 in the document.
189 | 
190 | If the path for `insertScript` or `insertCSS` is a URL instead of a filename, the URL will be properly inserted instead of a file path. Inserted scripts will be surrounded with `
` tags, which fixes a quirk with javascript in Marked. 191 | 192 | For all of the prepend/append file filters, you can store files in `~/.config/conductor/files` and reference them with just a filename. Otherwise a full path will be assumed. 193 | 194 | For `autoLink`, any URL that's not contained in parenthesis or following a `[]: url` pattern will be autolinked (surrounded by angle brackets). URLs must contain `//` to be recognized, but any protocol will work, e.g. `x-marked://refresh`. Must be run on Markdown (prior to any postprocessor HTML conversion). 195 | 196 | For `fixHeaders`, it will be ensured that the document has an h1, and all header levels will be adapted to never jump more than one header level when increasing. If no H1 exists in the document, the first header of the lowest existing level will be turned into an H1 and all other headers will be decremented to fit the hierarchy. It's not perfect, but it does a pretty good job. When saving the document as Markdown from Marked, the new headers will be applied. Must be run on Markdown (prior to any postprocessor HTML conversion). 197 | 198 | **Note:** successive filters in a sequence that insert or prepend will always insert content before/above the result of the previous insert filter. So if you have an `insertTitle` filter followed by an `insertCSS` filter, the CSS will appear above the inserted title. If you want elements inserted in reverse order, reverse the order of the inserts in the sequence. 199 | 200 | > Example: 201 | > 202 | > filter: setStyle(github) 203 | 204 | 205 | > Filters can be camel case (replaceAll) or snake case (replace_all), either will work, case insensitive. 206 | 207 | ## Custom Processors 208 | 209 | All of the [capabilities and requirements](https://marked2app.com/help/Custom_Processor) of a Custom Processor script or command still apply, and all of the [environment variables that Marked sets](https://marked2app.com/help/Custom_Processor#environmentvariables) are still available. You just no longer have to have one huge script that forks on the various environment variables and you don't have to write your own tests for handling different scenarios. 210 | 211 | A script run by Conductor already knows it has the right type of file with the expected data and path, so your script can focus on just processing one file type. It's recommended to separate all of that logic you may already have written out into separate scripts and let Conductor handle the forking based on various criteria. 212 | 213 | > Custom processors **must** wait for input on STDIN. Most markdown CLIs will do this automatically, but scripts should include a call to read STDIN. This will pause the script and wait for the data to be sent. Without this, Marked will launch the script, and if it closes the pipe, it will try to write data to a closed pipe and crash immediately. This is a very difficult error to trap in Marked, so it's crucial that all scripts keep the STDIN pipe open. 214 | 215 | 216 | ## Tips 217 | 218 | - Config file must be valid YAML. Any value containing colons, brackets, or other special characters should be quoted, e.g. (`condition: "text contains my:text"`) 219 | - You can see what condition matched in Marked by opening **Help->Show Custom Processor Log** and checking the STDERR output. 220 | - To run [a custom processor for Bear](https://brettterpstra.com/2023/10/08/marked-and-bear/), use the condition `"text contains "`. You might consider running a commonmark CLI with Bear to support more of its syntax. 221 | - To run a [custom processor for Obsidian](https://brettterpstra.com/2024/05/16/marked-2-and-obsidian/), use the condition `tree contains .obsidian` 222 | 223 | ## Testing 224 | 225 | You can test conductor setups using Marked's `Help->Show Custom Processor Log`, or by running from the command line. The easiest way to test conditions is to set the track's command to `echo "meaningful definition"` and see what conditions are met when conductor is run. 226 | 227 | In Marked's Custom Processor Log, you can see both the STDOUT output and the STDERR messages. When running Conductor, the STDERR output will show what conditions were met (as well as any errors reported). 228 | 229 | ### From the command line 230 | 231 | > There's a script included in the repo called [test.sh](https://github.com/ttscoff/marked-conductor/blob/main/test.sh) that will take a file path as an argument and set all of the environment variables for testing. Run `test.sh -h` for usage instructions. 232 | 233 | In order to test from the command line, you'll need certain environment variables set. This can be done by exporting the following variables with your own definitions, or by running conductor with all of the variables preceding the command, e.g. `$ MARKED_ORIGIN=/path/to/markdown_file.md [...] conductor`. 234 | 235 | The following need to be defined. Some can be left as empty or to defaults, such as `MARKED_INCLUDES` and `MARKED_OUTLINE`, but all need to be set to something. 236 | 237 | ``` 238 | HOME=$HOME 239 | MARKED_CSS_PATH="" # The path to CSS, can be empty 240 | MARKED_EXT="md" # The extension of the current file in Marked, set as needed for testing 241 | MARKED_INCLUDES="" # Files included in the document, can be empty 242 | MARKED_ORIGIN="/Users/ttscoff/notes/" # Base directory for the file being tested 243 | MARKED_PATH="/Users/ttscoff/notes/markdown_file.md" # Full path to Markdown file 244 | MARKED_PHASE="PREPROCESS" # either "PROCESS" or "PREPROCESS" 245 | OUTLINE="none" # Outline mode, can be "none" 246 | PATH=$PATH # The system $PATH variable 247 | ``` 248 | 249 | Further, input on STDIN is required, unless the script/command being matched contains `$file`, in which case $MARKED_PATH will be read and operated on. For the purpose of testing, you can use `echo` or `cat FILE` and pipe to conductor, e.g. `echo "TESTING" | conductor`. 250 | 251 | To test which conditions are being met, you can just set the `command:` for a track to `echo "meaningful message"`, where the message is something that indicates which condition(s) have passed. 252 | 253 | 254 | 255 | ## Contributing 256 | 257 | Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ttscoff/marked-conductor/blob/main/CODE_OF_CONDUCT.md). 258 | 259 | ## License 260 | 261 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 262 | 263 | ## Code of Conduct 264 | 265 | Everyone interacting in the Marked::Conductor project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ttscoff/marked-conductor/blob/main/CODE_OF_CONDUCT.md). 266 | 267 | -------------------------------------------------------------------------------- /src/_README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![RubyGems.org](https://img.shields.io/gem/v/marked-conductor)](https://rubygems.org/gems/marked-conductor) 4 | 5 | # Marked Conductor 6 | 7 | A "train conductor" for [Marked 2](https://marked2app.com) (Mac only). Conductor can be set up as a Custom Preprocessor or Custom Processor for Marked, and can run different commands and scripts based on conditions in a YAML configuration file, allowing you to have multiple processors that run based on predicates. 8 | 9 | Conductor configuration uses "natural language," allowing for complex 10 | operations without having to write any code. A condition can look like 11 | "name contains work-" to match a file named `work-project1.md`. The 12 | actions you can take include scripts, commands, and built in filters for 13 | an array of common operations. 14 | 15 | ## Installation 16 | 17 | $ gem install marked-conductor 18 | 19 | If you run into errors, try running with the `--user-install` flag: 20 | 21 | $ gem install --user-install marked-conductor 22 | 23 | > I've noticed lately with `asdf` that I have to run `asdf reshim` after installing gems containing binaries. 24 | 25 | If you use Homebrew, you can run 26 | 27 | $ brew gem install marked-conductor 28 | 29 | ## Usage 30 | 31 | To use Conductor, you need to set up a configuration file in `~/.config/conductor/tracks.yaml`. Run `conductor` once to create the directory and an empty configuration. See [Configuration](#configuration) below for details on setting up your "tracks." 32 | 33 | Once configured, you can set up conductor as a Custom Processor in Marked. Run `which conductor | pbcopy` to get the full path to the binary and copy it, then open **Marked Preferences > Advanced** and select either Custom Processor or Custom Preprocessor (or both) and paste into the **Path:** field. You can select *Automatically enable for new windows* to have the processor enabled by default when opening documents. 34 | 35 | 36 | ![Marked preferences](images/preferences.jpg) 37 | 38 | Conductor requires that it be run from Marked 2, and won't function on the command line. This is because Marked defines special environment variables that can be used in scripts, and these won't exist when running from your shell. If you want to be able to test Conductor from the command line, see [Testing](#testing). 39 | 40 | ## Configuration 41 | 42 | Configuration is done in a YAML file located at `~/.config/conductor/tracks.yaml`. Run `conductor` from the command line to generate the necessary directories and sample config file if it doesn't already exist. 43 | 44 | The top level key in the YAML file is `tracks:`. This is an array of hashes, each hash containing a `condition` and either a `script` or `command` key. 45 | 46 | A simple config would look like: 47 | 48 | ```yaml 49 | tracks: 50 | - condition: yaml includes comments 51 | script: blog-processor 52 | - condition: any 53 | command: echo 'NOCUSTOM' 54 | ``` 55 | 56 | This would run a script at `~/.config/conductor/scripts/blog-processor` if there was YAML present in the document and it included a key called `comments`. If not, the `condition: any` would echo `NOCUSTOM` to Marked, indicating it should skip any Custom Processor. If no condition is met, NOCUSTOM is automatically sent, so this particular example is redundant. In practice you would include a catchall processor to act as the default if no prior conditions were met. 57 | 58 | Instead of a `script` or `command`, a track can contain another `tracks` key, in which case the parent condition will branch and it will cycle through the tracks contained in the `tracks` key for the hash. `tracks` keys can be repeatedly nested to create AND conditions. 59 | 60 | For example, the following functions the same as `condition: phase is pre AND tree contains .obsidian AND (extension is md or extension is markdown)`: 61 | 62 | ```yaml 63 | tracks: 64 | - condition: phase is pre 65 | tracks: 66 | - condition: tree contains .obsidian 67 | tracks: 68 | - condition: extension is md 69 | command: obsidian-md-filter 70 | - condition: extension is markdown 71 | command: obsidian-md-filter 72 | ``` 73 | 74 | #### Adding a title 75 | 76 | Tracks can contain a `title` key. This is only used in the STDERR output of the track, where 'Met condition: ...' is shown for debugging. If a title is not present, the condition itself will be shown for debugging. If a title is defined, it replaces the condition in the STDERR output. This is mostly for shortening long condition strings to something more meaningful for debugging. 77 | 78 | ### Sequencing 79 | 80 | A track can also contain a sequence of scripts and/or commands. STDIN will be passed into the first script/command, then the STDOUT of that will be piped to the next script/command. To do this, add a key called `sequence` that contains an array of scripts and commands: 81 | 82 | ```yaml 83 | tracks: 84 | - condition: phase is pro AND path contains README.md 85 | sequence: 86 | - script: strip_emoji 87 | - command: rdiscount 88 | ``` 89 | 90 | A sequence can not contain nested tracks. 91 | 92 | By default, processing stops when a condition is met. If you want to continue processing after a condition is successful, add the `continue: true` to the track. This will only apply to tracks containing this key, and processing will stop when it gets to a successful condition that doesn't contain the `continue` key (or reaches the end of the tracks without another match). 93 | 94 | ### Conditions 95 | 96 | Available conditions are: 97 | 98 | - `extension` (or `ext`): This will test the extension of the file, e.g. `ext is md` or `ext contains task` 99 | - `tree contains ...`: This will test whether a given file or directory exists in any of the parent folders of the current file, starting with the current directory of the file. Example: `tree contains .obsidian` would test whether there was an `.obsidian` directory in any of the directories above the file (indicating it's within an Obsidian vault) 100 | - `path`: This tests just the path to the file itself, allowing conditions like `path contains _drafts` or `path does not contain _posts`. 101 | - `filename`: Tests only the filename, can be any string comparison (`starts with`, `is`, `contains`, etc.). 102 | - `phase`: Tests whether Marked is in Preprocessor or Processor phase, allowing conditions like `phase is preprocess` or `phase is process` (which can be shortened to `pre` and `pro`). 103 | - `text`: This tests for any string match within the text of the document being processed. This can be used with operators `starts with`, `ends with`, or `contains`, e.g. `text contains @taskpaper` or `text does not contain `. 104 | - If the test value is surrounded by forward slashes, it will be treated as a regular expression. Regexes are always flagged as case insensitive. Use it like `text contains /@\w+/`. 105 | - `yaml`, `headers`, or `frontmatter` will test for YAML headers. If a `yaml:KEY` is defined, a specific YAML key will be tested for. If a value is defined with an operator, it will be tested against the value key. 106 | - `yaml` tests for the presence of YAML frontmatter. 107 | - `yaml:comments` tests for the presence of a `comments` key. 108 | - `yaml:comments is true` tests whether `comments: true` exists. 109 | - `yaml:tags contains appreview` will test whether the tags array contains `appreview`. 110 | - If the YAML key is a date, it can be tested against with `before`, `after`, and `is`, and the value can be a natural language date, e.g. `yaml:date is after may 3, 2024` 111 | - If both the YAML key value and the test value are numbers, you can use operators `greater than` (`>`), `less than` (`<`), `equal`/`is` (`=`/`==`), and `is not equal`/`not equals` (`!=`/`!==`). Numbers will be interpreted as floats. 112 | - If the YAML value is a boolean, you can test with `is true` or `is not true` (or `is false`) 113 | - `mmd` or `meta` will test for MultiMarkdown metadata using the same formatting as `yaml` above. 114 | - `includes` are files included in the document with special syntax (Marked, IA Writer, etc.) 115 | - `includes contain file` or `includes not contains file` will test all included files for filename matches 116 | - `includes contain path` or `includes not contains path` will test all included files for fragment matches anywhere in the path 117 | - `env:KEY matches VALUE` will test for matching values in a environment key. All string matching operators are available, and `env[KEY]` syntax will also work. 118 | - `env contains KEY` tests just for the existence of an environment variable key (can include variables set by Marked). 119 | - The following keywords act as a catchall and can be used as the last track in the config to act on any documents that aren't matched by preceding rules: 120 | - `any` 121 | - `else` 122 | - `all` 123 | - `true` 124 | - `catchall` 125 | 126 | Available comparison operators are: 127 | 128 | - `is` or `equals` (negate with `is not` or `does not equal`) tests for equality on strings, numbers, or dates 129 | - `contains` or `includes` (negate with `does not contain`) tests on strings or array values 130 | - `begins with` (or `starts with`) or `ends with` (negate with `does not begin with`) tests on strings 131 | - `greater than` or `less than` (tests on numbers or dates) 132 | 133 | Conditions can be combined with AND or OR (must be uppercase) and simple parenthetical operations will work (parenthesis can not be nested). A boolean condition would look like `path contains _posts AND extension is md` or `(tree includes .obsidian AND extension is todo) OR extension is taskpaper`. 134 | 135 | ### Actions 136 | 137 | The action can be `script`, `command`, or `filter`. 138 | 139 | #### Scripts 140 | 141 | **Scripts** are located in `~/.config/conductor/scripts/` and should be executable files that take input on STDIN (unless `$file` is specified in the `script` definition). If a script is defined starting with `~` or `/`, that will be interpreted as a full path to an alternate location. 142 | 143 | > Example: 144 | > 145 | > script: github_pre 146 | 147 | #### Commands 148 | 149 | **Commands** are interpreted as shell commands. If a command exists in the `$PATH`, a full path will automatically be determined, so a command can be as simple as just `pandoc`. Add any arguments needed after the command. 150 | 151 | > Example: 152 | > 153 | > command: multimarkdown 154 | 155 | 156 | > Using `$file` as an argument to a script or command will bypass processing of STDIN input, and instead use the value of $MARKED_PATH to read the contents of the specified file. 157 | 158 | #### Filters 159 | 160 | **Filters** are simple actions that can be run on the content without having to write a separate script for it. Available filters are: 161 | 162 | | filter | description | 163 | | :---- | :---------- | 164 | | `setMeta(key, value)` | adds or updates a meta key, aware of YAML and MMD | 165 | | `stripMeta` | strips all metadata (YAML or MMD) from the content | 166 | | `deleteMeta(key)` | removes a specific key (YAML or MMD) | 167 | | `setStyle(name)` | sets the Marked preview style to a preconfigured Style name | 168 | | `replace(search, replace)` | performs a (single) search and replace on content | 169 | | `replaceAll(search, replace)` | global version of `replaceAll`) | 170 | | `insertTitle` | adds a title to the document, either from metadata or filename | 171 | | `insertScript(path[,path])` | injects javascript(s) | 172 | | `insertTOC(max, after)` | insert TOC (max=max levels, after=start, \*h1, or h2) | 173 | | `prepend/appendFile(path)` | insert a file as Markdown at beginning or end of content | 174 | | `prepend/appendRaw(path)` | insert a file as raw HTML at beginning or end of content | 175 | | `prepend/appendCode(path)` | insert a file as a code block at beginning or end of content | 176 | | `insertCSS(path)` | insert custom CSS into document | 177 | | `autoLink()` | Turn bare URLs into \ urls | 178 | | `fixHeaders()` | Reorganize headline levels to semantic order | 179 | | `increaseHeaders(count) | Increase header levels by count (default 1) | 180 | | `decreaseHeaders(count) | Decrease header levels by count (default 1) | 181 | 182 | For `replace` and `replaceAll`: If *search* is surrounded with forward slashes followed by optional flags (*i* for case-insensitive, *m* to make dot match newlines), e.g. `/contribut(ing)?/i`, it will be interpreted as a regular expression. The *replace* value can include numeric capture groups, e.g. `Follow$2`. 183 | 184 | For `insertScript`, if path is just a filename it will look for a match in `~/.config/conductor/javascript` or `~/.config/conductor/scripts` and turn that into an absolute path if the file is found. 185 | 186 | For `insertCSS`, if path is just a filename (with or without .css extension), the file will be searched for in `~/.config/conductor/css` or `~/.config/conductor/files` and injected. CSS will be compressed using the YUI algorithm and inserted at the top of the document, but after any existing metadata. 187 | 188 | For `insertTitle`, if an argument of `true` or a number is given (e.g. `insertTitle(true)`, the headers in the document will be shifted by 1 (or by the number given) so that there's only one H1 in the document. 189 | 190 | If the path for `insertScript` or `insertCSS` is a URL instead of a filename, the URL will be properly inserted instead of a file path. Inserted scripts will be surrounded with `
` tags, which fixes a quirk with javascript in Marked. 191 | 192 | For all of the prepend/append file filters, you can store files in `~/.config/conductor/files` and reference them with just a filename. Otherwise a full path will be assumed. 193 | 194 | For `autoLink`, any URL that's not contained in parenthesis or following a `[]: url` pattern will be autolinked (surrounded by angle brackets). URLs must contain `//` to be recognized, but any protocol will work, e.g. `x-marked://refresh`. Must be run on Markdown (prior to any postprocessor HTML conversion). 195 | 196 | For `fixHeaders`, it will be ensured that the document has an h1, and all header levels will be adapted to never jump more than one header level when increasing. If no H1 exists in the document, the first header of the lowest existing level will be turned into an H1 and all other headers will be decremented to fit the hierarchy. It's not perfect, but it does a pretty good job. When saving the document as Markdown from Marked, the new headers will be applied. Must be run on Markdown (prior to any postprocessor HTML conversion). 197 | 198 | **Note:** successive filters in a sequence that insert or prepend will always insert content before/above the result of the previous insert filter. So if you have an `insertTitle` filter followed by an `insertCSS` filter, the CSS will appear above the inserted title. If you want elements inserted in reverse order, reverse the order of the inserts in the sequence. 199 | 200 | > Example: 201 | > 202 | > filter: setStyle(github) 203 | 204 | 205 | > Filters can be camel case (replaceAll) or snake case (replace_all), either will work, case insensitive. 206 | 207 | ## Custom Processors 208 | 209 | All of the [capabilities and requirements](https://marked2app.com/help/Custom_Processor) of a Custom Processor script or command still apply, and all of the [environment variables that Marked sets](https://marked2app.com/help/Custom_Processor#environmentvariables) are still available. You just no longer have to have one huge script that forks on the various environment variables and you don't have to write your own tests for handling different scenarios. 210 | 211 | A script run by Conductor already knows it has the right type of file with the expected data and path, so your script can focus on just processing one file type. It's recommended to separate all of that logic you may already have written out into separate scripts and let Conductor handle the forking based on various criteria. 212 | 213 | > Custom processors **must** wait for input on STDIN. Most markdown CLIs will do this automatically, but scripts should include a call to read STDIN. This will pause the script and wait for the data to be sent. Without this, Marked will launch the script, and if it closes the pipe, it will try to write data to a closed pipe and crash immediately. This is a very difficult error to trap in Marked, so it's crucial that all scripts keep the STDIN pipe open. 214 | 215 | 216 | ## Tips 217 | 218 | - Config file must be valid YAML. Any value containing colons, brackets, or other special characters should be quoted, e.g. (`condition: "text contains my:text"`) 219 | - You can see what condition matched in Marked by opening **Help->Show Custom Processor Log** and checking the STDERR output. 220 | - To run [a custom processor for Bear](https://brettterpstra.com/2023/10/08/marked-and-bear/), use the condition `"text contains "`. You might consider running a commonmark CLI with Bear to support more of its syntax. 221 | - To run a [custom processor for Obsidian](https://brettterpstra.com/2024/05/16/marked-2-and-obsidian/), use the condition `tree contains .obsidian` 222 | 223 | ## Testing 224 | 225 | You can test conductor setups using Marked's `Help->Show Custom Processor Log`, or by running from the command line. The easiest way to test conditions is to set the track's command to `echo "meaningful definition"` and see what conditions are met when conductor is run. 226 | 227 | In Marked's Custom Processor Log, you can see both the STDOUT output and the STDERR messages. When running Conductor, the STDERR output will show what conditions were met (as well as any errors reported). 228 | 229 | ### From the command line 230 | 231 | > There's a script included in the repo called [test.sh](https://github.com/ttscoff/marked-conductor/blob/main/test.sh) that will take a file path as an argument and set all of the environment variables for testing. Run `test.sh -h` for usage instructions. 232 | 233 | In order to test from the command line, you'll need certain environment variables set. This can be done by exporting the following variables with your own definitions, or by running conductor with all of the variables preceding the command, e.g. `$ MARKED_ORIGIN=/path/to/markdown_file.md [...] conductor`. 234 | 235 | The following need to be defined. Some can be left as empty or to defaults, such as `MARKED_INCLUDES` and `MARKED_OUTLINE`, but all need to be set to something. 236 | 237 | ``` 238 | HOME=$HOME 239 | MARKED_CSS_PATH="" # The path to CSS, can be empty 240 | MARKED_EXT="md" # The extension of the current file in Marked, set as needed for testing 241 | MARKED_INCLUDES="" # Files included in the document, can be empty 242 | MARKED_ORIGIN="/Users/ttscoff/notes/" # Base directory for the file being tested 243 | MARKED_PATH="/Users/ttscoff/notes/markdown_file.md" # Full path to Markdown file 244 | MARKED_PHASE="PREPROCESS" # either "PROCESS" or "PREPROCESS" 245 | OUTLINE="none" # Outline mode, can be "none" 246 | PATH=$PATH # The system $PATH variable 247 | ``` 248 | 249 | Further, input on STDIN is required, unless the script/command being matched contains `$file`, in which case $MARKED_PATH will be read and operated on. For the purpose of testing, you can use `echo` or `cat FILE` and pipe to conductor, e.g. `echo "TESTING" | conductor`. 250 | 251 | To test which conditions are being met, you can just set the `command:` for a track to `echo "meaningful message"`, where the message is something that indicates which condition(s) have passed. 252 | 253 | 254 | 255 | ## Contributing 256 | 257 | Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ttscoff/marked-conductor/blob/main/CODE_OF_CONDUCT.md). 258 | 259 | ## License 260 | 261 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 262 | 263 | ## Code of Conduct 264 | 265 | Everyone interacting in the Marked::Conductor project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ttscoff/marked-conductor/blob/main/CODE_OF_CONDUCT.md). 266 | 267 | 268 | -------------------------------------------------------------------------------- /lib/conductor/filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # String helpers 4 | class ::String 5 | ## 6 | ## Search config folder for multiple subfolders and content 7 | ## 8 | ## @param paths [Array] The possible directory names 9 | ## @param filename [String] The filename to search for 10 | ## @param ext [String] The file extension 11 | ## 12 | def find_file_in(paths, filename, ext) 13 | return filename if File.exist?(filename) 14 | 15 | ext = ext.sub(/^\./, "") 16 | 17 | filename = File.basename(filename, ".#{ext}") 18 | 19 | paths.each do |path| 20 | exp = File.join(File.expand_path("~/.config/conductor/"), path, "#{filename}.#{ext}") 21 | return exp if File.exist?(exp) 22 | end 23 | "#{filename}.#{ext}" 24 | end 25 | 26 | ## 27 | ## Normalize the filter name and parameters, downcasing and removing spaces, 28 | ## underscores, splitting params by comma 29 | ## 30 | ## @return [Array] array containing normalize filter and 31 | ## array of parameters 32 | ## 33 | def normalize_filter 34 | parts = match(/(?[\w_]+)(?:\((?.*?)\))?$/i) 35 | filter = parts["filter"].downcase.gsub(/_/, "") 36 | params = parts["paren"]&.split(/ *, */) 37 | [filter, params] 38 | end 39 | 40 | ## 41 | ## Determine type of metadata (yaml, mmd, none) 42 | ## 43 | ## @return [Symbol] metadata type 44 | ## 45 | def meta_type 46 | lines = utf8.split(/\n/) 47 | case lines[0] 48 | when /^--- *$/ 49 | :yaml 50 | when /^ *[ \w]+: +\S+/ 51 | :mmd 52 | when /^% +\S/ 53 | :pandoc 54 | else 55 | :none 56 | end 57 | end 58 | 59 | ## 60 | ## Determine which line to use to insert after existing metadata 61 | ## 62 | ## @return [Integer] line index 63 | ## 64 | def meta_insert_point 65 | insert_point = 0 66 | 67 | case meta_type 68 | when :yaml 69 | lines = utf8.split(/\n/) 70 | lines.shift 71 | lines.each_with_index do |line, idx| 72 | next unless line =~ /^(\.\.\.|---) *$/ 73 | 74 | insert_point = idx + 1 75 | break 76 | end 77 | when :mmd 78 | lines = utf8.split(/\n/) 79 | lines.each_with_index do |line, idx| 80 | next if line =~ /^ *[ \w]+: +\S+/ 81 | 82 | insert_point = idx 83 | break 84 | end 85 | when :pandoc 86 | lines = utf8.split(/\n/) 87 | lines.each_with_index do |line, idx| 88 | next if line =~ /^% +\S/ 89 | 90 | insert_point = idx 91 | break 92 | end 93 | end 94 | 95 | insert_point 96 | end 97 | 98 | ## 99 | ## Locate the first H1 in the document 100 | ## 101 | ## @return [Integer] index of first H1 102 | ## 103 | def first_h1 104 | first = nil 105 | utf8.split(/\n/).each_with_index do |line, idx| 106 | if line =~ /^(# *[^#]|={2,} *$)/ 107 | first = idx 108 | break 109 | end 110 | end 111 | first 112 | end 113 | 114 | ## 115 | ## Locate the first H2 in the document 116 | ## 117 | ## @return [Integer] index of first H2 118 | ## 119 | def first_h2 120 | first = nil 121 | meta_end = meta_insert_point 122 | utf8.split(/\n/).each_with_index do |line, idx| 123 | next if idx <= meta_end 124 | 125 | if line =~ /^(## *[^#]|-{2,} *$)/ 126 | first = idx 127 | break 128 | end 129 | end 130 | first 131 | end 132 | 133 | ## 134 | ## Decrease all headers by given amount 135 | ## 136 | ## @param amt [Integer] The amount to decrease 137 | ## 138 | def decrease_headers(amt = 1) 139 | normalize_headers.gsub(/^(\#{1,6})(?!=#)/) do 140 | m = Regexp.last_match 141 | level = m[1].size 142 | level -= amt 143 | level = 1 if level < 1 144 | "#" * level 145 | end 146 | end 147 | 148 | ## 149 | ## Increase all header levels by amount 150 | ## 151 | ## @param amt [Integer] number to increase by (1-5) 152 | ## 153 | ## @return [String] content with headers increased 154 | ## 155 | def increase_headers(amt = 1) 156 | normalize_headers.gsub(/^#/, "#{"#" * amt}#").gsub(/^\#{7,}/, "######") 157 | end 158 | 159 | ## 160 | ## Destructive version of #increase_headers 161 | ## 162 | ## @see #increase_headers 163 | ## 164 | ## @param amt [Integer] The amount 165 | ## 166 | ## @return [String] content with headers increased 167 | ## 168 | def increase_headers!(amt = 1) 169 | replace increase_headers(amt) 170 | end 171 | 172 | ## 173 | ## Insert a Table of Contents at given position 174 | ## 175 | ## @param max [Integer] The maximum depth of the TOC 176 | ## @param after [Symbol] Where to place TOC after (:top, :h1, :h2) 177 | ## 178 | ## @return [String] content with TOC tag added 179 | ## 180 | def insert_toc(max = nil, after = :h1) 181 | lines = utf8.split(/\n/) 182 | max = max.to_i&.positive? ? " max#{max}" : "" 183 | line = case after.to_sym 184 | when :h2 185 | first_h2.nil? ? 0 : first_h2 + 1 186 | when :h1 187 | first_h1.nil? ? 0 : first_h1 + 1 188 | else 189 | meta_insert_point.positive? ? meta_insert_point + 1 : 0 190 | end 191 | 192 | lines.insert(line, "\n\n").join("\n") 193 | end 194 | 195 | ## 196 | ## Wrap content in }m) 202 | self 203 | else 204 | "" 205 | end 206 | end 207 | 208 | ## 209 | ## Insert a tag for the given path 210 | ## 211 | ## @param path [String] path to CSS files 212 | ## 213 | ## @return [String] path with