├── .gitignore ├── .irbrc ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── README.rdoc ├── Rakefile ├── bin └── conductor ├── buildnotes.md ├── images └── preferences.jpg ├── lib ├── conductor.rb └── conductor │ ├── array.rb │ ├── boolean.rb │ ├── command.rb │ ├── condition.rb │ ├── config.rb │ ├── env.rb │ ├── filter.rb │ ├── hash.rb │ ├── script.rb │ ├── string.rb │ ├── version.rb │ └── yui_compressor.rb ├── marked-conductor.gemspec ├── spec ├── array_spec.rb ├── bool_spec.rb ├── command_spec.rb ├── condition_spec.rb ├── conductor_spec.rb ├── config_spec.rb ├── env_spec.rb ├── filter_spec.rb ├── filter_string_spec.rb ├── hash_spec.rb ├── script_spec.rb ├── spec_helper.rb ├── string_spec.rb ├── tracks.yaml └── yui_spec.rb ├── src └── _README.md ├── test.sh └── test ├── header_test.md ├── linktest.md ├── mmd_test.md └── test.md /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 


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


--------------------------------------------------------------------------------
/buildnotes.md:
--------------------------------------------------------------------------------
 1 | template: git, gem, project
 2 | project: conductor
 3 | readme: src/_README.md
 4 | 
 5 | # marked-conductor
 6 | 
 7 | Train conductor for Marked
 8 | 
 9 | ## File Structure
10 | 
11 | Standard gem structure.
12 | 
13 | ## Deploy
14 | 
15 | You no longer need to manually bump the version, it will be incremented when this task runs.
16 | 
17 | ```run Update Changelog
18 | #!/bin/bash
19 | 
20 | changelog -u
21 | ```
22 | 
23 | @include(project:Update GitHub README)
24 | 
25 | ```run Commit with changelog
26 | #!/bin/bash
27 | 
28 | changelog | git commit -a -F -
29 | git pull
30 | git push
31 | ```
32 | 
33 | @include(gem:Release Gem) Release Gem
34 | @include(project:Update Blog Project) Update Blog Project
35 | @run(rake bump[patch]) Bump Version
36 | 
37 | @run(git commit -am 'Version bump')
38 | 
39 | @after
40 | Don't forget to publish the website!
41 | @end
42 | 


--------------------------------------------------------------------------------
/images/preferences.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ttscoff/marked-conductor/aa5e1472c76f04cbe6e9eba8412a94e10786a687/images/preferences.jpg


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


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


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


--------------------------------------------------------------------------------
/lib/conductor/condition.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module Conductor
  4 |   # Condition class
  5 |   class Condition
  6 |     # R/W condition
  7 |     attr_accessor :condition
  8 | 
  9 |     ##
 10 |     ## Initializes the given condition.
 11 |     ##
 12 |     ## @param      condition  The condition
 13 |     ##
 14 |     def initialize(condition)
 15 |       @condition = condition
 16 |       @env = Conductor::Env.env
 17 |     end
 18 | 
 19 |     ##
 20 |     ## Tests condition
 21 |     ##
 22 |     ## @return     [Boolean] test result
 23 |     ##
 24 |     def true?
 25 |       parse_condition
 26 |     end
 27 | 
 28 |     ##
 29 |     ## Splits booleans and tests components.
 30 |     ##
 31 |     ## @param      condition  The condition to test
 32 |     ##
 33 |     ## @return     [Boolean] test result
 34 |     ##
 35 |     def split_booleans(condition)
 36 |       split = condition.split(/ ((?:AND )?NOT|AND|OR) /)
 37 | 
 38 |       if split.count == 1
 39 |         test_condition(split[0])
 40 |       else
 41 |         res = nil
 42 |         bool = nil
 43 |         prev = false
 44 |         split.each do |cond|
 45 |           if /((?:AND )?NOT|AND|OR|&&|\|\||!!)/.match?(cond)
 46 |             bool = cond.bool_to_symbol
 47 |             next
 48 |           end
 49 | 
 50 |           r = split_booleans(cond)
 51 | 
 52 |           if bool == :and && (!r || !prev)
 53 |             res = false
 54 |           elsif bool == :or && (r || prev)
 55 |             return true
 56 |           elsif bool == :not && (r || prev)
 57 |             res = false
 58 |           else
 59 |             res = r
 60 |           end
 61 | 
 62 |           prev = res
 63 |         end
 64 |         res
 65 |       end
 66 |     end
 67 | 
 68 |     ##
 69 |     ## Test operators
 70 |     ##
 71 |     ## @param      value1    Value
 72 |     ## @param      value2    Value to test
 73 |     ## @param      operator  The operator
 74 |     ##
 75 |     ## @return [Boolean] test result
 76 |     ##
 77 |     def test_operator(value1, value2, operator)
 78 |       case operator
 79 |       when :gt
 80 |         value1.to_f > value2.to_f
 81 |       when :lt
 82 |         value1.to_f < value2.to_f
 83 |       when :not_contains
 84 |         value1.to_s !~ /#{value2}/i
 85 |       when :contains
 86 |         value1.to_s =~ /#{value2}/i
 87 |       when :starts_with
 88 |         value1.to_s =~ /^#{value2}/i
 89 |       when :ends_with
 90 |         value1.to_s =~ /#{value2}$/i
 91 |       when :not_equal
 92 |         value1 != value2
 93 |       when :equal
 94 |         value1 == value2
 95 |       end
 96 |     end
 97 | 
 98 |     ##
 99 |     ## Splits a natural language condition.
100 |     ##
101 |     ## @param      condition  The condition
102 |     ## @return [Array] Value, value to compare, operator
103 |     ##
104 |     def split_condition(condition)
105 |       if condition.match(/(?:((?:does )?not)?(?:ha(?:s|ve)|contains?|includes?) +)?(yaml|headers|frontmatter|mmd|meta(?:data)?|pandoc)(:[\S_ ]+)?/i)
106 |         m = Regexp.last_match
107 |         op = m[1].nil? ? :contains : :not_contains
108 |         type = case m[2]
109 |                when /^m/i
110 |                  "mmd"
111 |                when /^p/i
112 |                  "pandoc"
113 |                else
114 |                  "yaml"
115 |                end
116 |         return ["#{type}#{m[3]}", nil, op]
117 |       end
118 | 
119 |       res = condition.match(/(?i)^(?.*?)(?:(?:\s+(?(?:is|do(?:es)?)?\s*(?:not)?\s*)(?(?:an?|type(?:\sof)?|equals?(?:\sto))?|[!*$]?==?|[gl]t|(?:greater|less)(?:\sthan)?|<|>|(?:starts|ends) with|(?:ha(?:s|ve)\s)?(?:prefix|suffix)|(?:contains?|includes?)\s+(?:file|path)|has|contains?|includes?|match(?:es)?)\s+)(?.*?))?$/)
120 |       operator = res["bool"] ? "#{res["bool"]}#{res["op"]}" : res["op"]
121 |       [res["val1"], res["val2"], operator_to_symbol(operator)]
122 |     end
123 | 
124 |     ##
125 |     ## Test for type of value
126 |     ##
127 |     ## @param      val1      value
128 |     ## @param      val2      value to test against
129 |     ## @param      operator  The operator
130 |     ##
131 |     def test_type(val1, val2, operator)
132 |       res = case val2
133 |             when /number/
134 |               val1.is_a?(Numeric)
135 |             when /int(eger)?/
136 |               val1.is_a?(Integer)
137 |             when /(float|decimal)/
138 |               val1.is_a?(Float)
139 |             when /array/i
140 |               val1.is_a?(Array)
141 |             when /(string|text)/i
142 |               val1.is_a?(String)
143 |             when /date/i
144 |               val1.date?
145 |             end
146 |       operator == :type_of ? res : !res
147 |     end
148 | 
149 |     ##
150 |     ## Test for includes
151 |     ##
152 |     ## @param      includes  [Array] array of included files
153 |     ## @param      val      value to test against
154 |     ## @param      operator  The operator
155 |     ##
156 |     def test_includes(includes, val, operator)
157 |       case operator
158 |       when :not_includes_file
159 |         !includes.includes_file?(val)
160 |       when :not_includes_path
161 |         !includes.includes_frag?(val)
162 |       when :includes_file
163 |         includes.includes_file?(val)
164 |       when :includes_path
165 |         includes.includes_frag?(val)
166 |       else
167 |         false
168 |       end
169 |     end
170 | 
171 |     ##
172 |     ## Compare a string based on operator
173 |     ##
174 |     ## @param      val1      The string to test against
175 |     ## @param      val2      The value to test
176 |     ## @param      operator  The operator
177 |     ##
178 |     ## @return     [Boolean] test result
179 |     ##
180 |     def test_string(val1, val2, operator)
181 |       return operator == :not_equal ? val1.nil? : !val1.nil? if val2.nil?
182 | 
183 |       return operator == :not_equal if val1.nil?
184 | 
185 |       val2 = val2.to_s.dup.force_encoding("utf-8")
186 | 
187 |       if val2.bool?
188 |         res = val2.to_bool == val1.to_bool
189 |         return operator === :not_equal ? !res : res
190 |       end
191 | 
192 |       if val1.date?
193 |         if val2.time?
194 |           date1 = val1.to_date
195 |           date2 = val2.to_date
196 |         else
197 |           date1 = operator == :gt ? val1.to_day(:end) : val1.to_day
198 |           date2 = operator == :gt ? val2.to_day(:end) : val2.to_day
199 |         end
200 | 
201 |         res = case operator
202 |               when :gt
203 |                 date1 > date2
204 |               when :lt
205 |                 date1 < date2
206 |               when :equal
207 |                 date1 == date2
208 |               when :not_equal
209 |                 date1 != date2
210 |               end
211 |         return res unless res.nil?
212 |       end
213 | 
214 |       val2 = if %r{^/.*?/$}.match?(val2.strip)
215 |                val2.gsub(%r{(^/|/$)}, "")
216 |              else
217 |                Regexp.escape(val2)
218 |              end
219 |       val1 = val1.dup.to_s.force_encoding("utf-8")
220 |       case operator
221 |       when :contains
222 |         val1 =~ /#{val2}/i ? true : false
223 |       when :not_starts_with
224 |         val1 !~ /^#{val2}/i ? true : false
225 |       when :not_ends_with
226 |         val1 !~ /#{val2}$/i ? true : false
227 |       when :starts_with
228 |         val1 =~ /^#{val2}/i ? true : false
229 |       when :ends_with
230 |         val1 =~ /#{val2}$/i ? true : false
231 |       when :equal
232 |         val1 =~ /^#{val2}$/i ? true : false
233 |       when :not_equal
234 |         val1 !~ /^#{val2}$/i ? true : false
235 |       else
236 |         false
237 |       end
238 |     end
239 | 
240 |     ##
241 |     ## Test for the existince of a
242 |     ##             file/directory in the parent tree
243 |     ##
244 |     ## @param      origin    Starting directory
245 |     ## @param      value     The file/directory to search
246 |     ##                       for
247 |     ## @param      operator  The operator
248 |     ##
249 |     ## @return     [Boolean] test result
250 |     ##
251 |     def test_tree(origin, value, operator)
252 |       return true if File.exist?(File.join(origin, value))
253 | 
254 |       dir = File.dirname(origin)
255 | 
256 |       if Dir.exist?(File.join(dir, value))
257 |         true
258 |       elsif [Dir.home, "/"].include?(dir)
259 |         false
260 |       else
261 |         test_tree(dir, value, operator)
262 |       end
263 |     end
264 | 
265 |     ##
266 |     ## Test "truthiness"
267 |     ##
268 |     ## @param      value1    Value to test against
269 |     ## @param      value2    Value to test
270 |     ## @param      operator  The operator
271 |     ##
272 |     ## @return     [Boolean] test result
273 |     ##
274 |     def test_truthy(value1, value2, operator)
275 |       return false unless value2&.bool?
276 | 
277 |       value2 = value2.to_bool if value2.is_a?(String)
278 | 
279 |       res = value1 == value2
280 | 
281 |       operator == :not_equal ? !res : res
282 |     end
283 | 
284 |     ##
285 |     ## Test for environment variable
286 |     ##
287 |     ## @param      value     [String] The value (test for existence of key if
288 |     ##                       nil)
289 |     ## @param      key       [String] The key
290 |     ## @param      operator  [Symbol] The operator
291 |     ##
292 |     ## @return     [Boolean] test result
293 |     ##
294 |     def test_env(value, key, operator)
295 |       env = Env.env.merge(ENV)
296 |       if key.nil?
297 |         res = !env[value].nil?
298 |         return operator == :not_contains ? !res : res
299 |       end
300 | 
301 |       return test_string(env[key], value, operator)
302 |     end
303 | 
304 |     ##
305 |     ## Test for presence of yaml, optionall for
306 |     ##             a key, optionally for a key's value
307 |     ##
308 |     ## @param      content   Text content containing YAML
309 |     ## @param      value     The value to test for
310 |     ## @param      key       The key to test for
311 |     ## @param      operator  The operator
312 |     ##
313 |     ## @return     [Boolean] test result
314 |     ##
315 |     def test_yaml(content, value, key, operator)
316 |       begin
317 |         yaml = YAML.load(content.split(/^(?:---|\.\.\.)/)[1])
318 |       rescue StandardError
319 |         return false
320 |       end
321 | 
322 |       return operator == :not_equal unless yaml
323 | 
324 |       if key
325 |         value1 = yaml[key]
326 |         return operator == :not_equal if value1.nil?
327 | 
328 |         if value.nil?
329 |           has_key = !value1.nil?
330 |           return operator == :not_equal ? !has_key : has_key
331 |         end
332 | 
333 |         if %i[type_of not_type_of].include?(operator)
334 |           return test_type(value1, value, operator)
335 |         end
336 | 
337 |         value1 = value1.join(",") if value1.is_a?(Array)
338 | 
339 |         if value1.to_s.bool?
340 |           test_truthy(value1, value, operator)
341 |         elsif value1.to_s.number? && value.to_s.number? && %i[gt lt equal not_equal].include?(operator)
342 |           test_operator(value1, value, operator)
343 |         else
344 |           test_string(value1, value, operator)
345 |         end
346 |       else
347 |         res = value ? yaml.key?(value) : true
348 |         (operator == :not_equal) ? !res : res
349 |       end
350 |     end
351 | 
352 |     ##
353 |     ## Test for MultiMarkdown metadata,
354 |     ##             optionally key and value
355 |     ##
356 |     ## @param      content   [String] The text content
357 |     ## @param      value     [String] The value to test for
358 |     ## @param      key       [String] The key to test for
359 |     ## @param      operator  [Symbol] The operator
360 |     ##
361 |     ## @return     [Boolean] test result
362 |     ##
363 |     def test_meta(content, value, key, operator)
364 |       headers = []
365 |       content.split("\n").each do |line|
366 |         break if line == /^ *\n$/ || line !~ /\w+: *\S/
367 | 
368 |         headers << line
369 |       end
370 | 
371 |       return operator == :not_equal if headers.empty?
372 | 
373 |       return operator != :not_equal if value.nil?
374 | 
375 |       meta = {}
376 |       headers.each do |h|
377 |         parts = h.split(/ *: */)
378 |         k = parts[0].strip.downcase.gsub(/ +/, "")
379 |         v = parts[1..].join(":").strip
380 |         meta[k] = v
381 |       end
382 | 
383 |       if key
384 |         if %i[type_of not_type_of].include?(operator)
385 |           return test_type(meta[key], value, operator)
386 |         end
387 | 
388 |         test_string(meta[key], value, operator)
389 |       else
390 |         res = value ? meta.key?(value) : true
391 |         operator == :not_equal ? !res : res
392 |       end
393 |     end
394 | 
395 |     ##
396 |     ## Test for Pandoc headers
397 |     ##
398 |     ## @param      content   [String] The content to test
399 |     ## @param      operator  [Symbol] The operator
400 |     ##
401 |     def test_pandoc(content, operator)
402 |       res = content.meta_type == :pandoc
403 |       %i[not_contains not_equal].include?(operator) ? !res.nil? : res.nil?
404 |     end
405 | 
406 |     def test_condition(condition)
407 |       type, value, operator = split_condition(condition)
408 | 
409 |       if operator.nil?
410 |         return case type
411 |                when /^(true|any|all|else|\*+|catch(all)?)$/i
412 |                  true
413 |                else
414 |                  false
415 |                end
416 |       end
417 | 
418 |       case type
419 |       when /^env(?:ironment)?(?:[:\[\()](.*?)[\]\)]?)?$/i
420 |         key = Regexp.last_match(1) || nil
421 |         test_env(value, key, operator)
422 |       when /^include/i
423 |         test_includes(@env[:includes], value, operator) ? true : false
424 |       when /^ext/i
425 |         test_string(@env[:ext], value, operator) ? true : false
426 |       when /^tree/i
427 |         test_tree(@env[:origin], value, operator)
428 |       when /^(path|dir)/i
429 |         test_string(@env[:filepath], value, operator) ? true : false
430 |       when /^(file)?name/i
431 |         test_string(@env[:filename], value, operator) ? true : false
432 |       when /^phase/i
433 |         test_string(@env[:phase], value, :starts_with) ? true : false
434 |       when /^text/i
435 |         test_string(Conductor.stdin, value, operator) ? true : false
436 |       when /^(?:yaml|headers|frontmatter)(?::(.*?))?$/i
437 |         key = Regexp.last_match(1) || nil
438 |         Conductor.stdin.yaml? ? test_yaml(Conductor.stdin, value, key, operator) : false
439 |       when /^(?:mmd|meta(?:data)?)(?::(.*?))?$/i
440 |         key = Regexp.last_match(1) || nil
441 |         Conductor.stdin.meta? ? test_meta(Conductor.stdin, value, key, operator) : false
442 |       when /^pandoc/
443 |         test_pandoc(Conductor.stdin, operator)
444 |       else
445 |         false
446 |       end
447 |     end
448 | 
449 |     ##
450 |     ## Convert an operator string to a symbol
451 |     ##
452 |     ## @param      operator  [String] The operator
453 |     ##
454 |     ## @return     [Symbol] the operator symbol
455 |     ##
456 |     def operator_to_symbol(operator)
457 |       return operator if operator.nil?
458 | 
459 |       case operator
460 |       when /(gt|greater( than)?|>|(?:is )?after)/i
461 |         :gt
462 |       when /(lt|less( than)?|<|(?:is )?before)/i
463 |         :lt
464 |       when /not (ha(?:s|ve)|contains?|includes?) +file/i
465 |         :not_includes_file
466 |       when /not (ha(?:s|ve)|contains?|includes?) +path/i
467 |         :not_includes_path
468 |       when /(ha(?:s|ve)|contains?|includes?|\*=) +file/i
469 |         :includes_file
470 |       when /(ha(?:s|ve)|contains?|includes?|\*=) +path/i
471 |         :includes_path
472 |       when /not (ha(?:s|ve)|contains|includes|match(es)?)/i
473 |         :not_contains
474 |       when /(ha(?:s|ve)|contains|includes|match(es)?|\*=)/i
475 |         :contains
476 |       when /not (suffix|ends? with)/i
477 |         :not_ends_with
478 |       when /not (prefix|(starts?|begins?) with)/i
479 |         :not_starts_with
480 |       when /(suffix|ends with|\$=)/i
481 |         :ends_with
482 |       when /(prefix|(starts?|begins?) with|\^=)/i
483 |         :starts_with
484 |       when /is not (an?|type( of)?)/i
485 |         :not_type_of
486 |       when /is (an?|type( of)?)/i
487 |         :type_of
488 |       when /((?:(?:is|does) )?not(?: equals?)?|!==?)/i
489 |         :not_equal
490 |       when /(is|==?|equals?)/i
491 |         :equal
492 |       end
493 |     end
494 | 
495 |     ##
496 |     ## Parse a condition, handling parens and booleans
497 |     ##
498 |     ## @return     [Boolean] condition result
499 |     ##
500 |     def parse_condition
501 |       cond = @condition.to_s.gsub(/\((.*?)\)/) do
502 |         condition = Regexp.last_match(1)
503 |         split_booleans(condition)
504 |       end
505 | 
506 |       split_booleans(cond)
507 |     end
508 |   end
509 | end
510 | 


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


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


--------------------------------------------------------------------------------
/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 "
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(/^
  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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------