diff --git a/README.md b/README.md index 881edf60..1ad27525 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ Commonmarker.to_html('"Hi *there*"', options: { The second argument is optional--[see below](#options) for more information. -## Parse and Render Options +## Options and plugins -Commonmarker accepts the same options that comrak does, as a hash dictionary with symbol keys: +### Options + +Commonmarker accepts the same parse, render, and extensions options that comrak does, as a hash dictionary with symbol keys: ```ruby Commonmarker.to_html('"Hi *there*"', options:{ @@ -95,6 +97,36 @@ Commonmarker.to_html('"Hi *there*"', options: { For more information on these options, see [the comrak documentation](https://github.com/kivikakk/comrak#usage). +### Plugins + +In addition to the possibilities provided by generic CommonMark rendering, Commonmarker also supports plugins as a means of +providing further niceties. For example: + + code = <<~CODE + ```ruby + def hello + puts "hello" + end + + CODE + + Commonmarker.to_html(code, plugins: { syntax_highlighter: { theme: "Inspired GitHub" } }) + + #

+    # def hello
+    #  puts "hello"
+    # end
+    # 
+    # 
+ +You can disable plugins just the same as with options, by passing `nil`: + +```ruby +Commonmarker.to_html(code, plugins: { syntax_highlighter: nil }) +# or +Commonmarker.to_html(code, plugins: { syntax_highlighter: { theme: nil } }) +``` + ## Output formats Commonmarker can currently only generate output in one format: HTML. @@ -102,8 +134,7 @@ Commonmarker can currently only generate output in one format: HTML. ### HTML ```ruby -html = CommonMarker.to_html('*Hello* world!', :DEFAULT) -puts(html) +puts Commonmarker.to_html('*Hello* world!') #

Hello world!

``` diff --git a/ext/commonmarker/src/lib.rs b/ext/commonmarker/src/lib.rs index 1abd859d..6d80ab83 100644 --- a/ext/commonmarker/src/lib.rs +++ b/ext/commonmarker/src/lib.rs @@ -1,29 +1,85 @@ extern crate core; -use comrak::{markdown_to_html, ComrakOptions}; -use magnus::{define_module, function, r_hash::ForEach, Error, RHash, Symbol}; +use comrak::{ + adapters::SyntaxHighlighterAdapter, markdown_to_html, markdown_to_html_with_plugins, + plugins::syntect::SyntectAdapter, ComrakOptions, ComrakPlugins, +}; +use magnus::{define_module, function, r_hash::ForEach, scan_args, Error, RHash, Symbol, Value}; -mod comrak_options; -use comrak_options::iterate_options_hash; +mod options; +use options::iterate_options_hash; + +mod plugins; +use plugins::{ + syntax_highlighting::{ + fetch_syntax_highlighter_theme, SYNTAX_HIGHLIGHTER_PLUGIN_DEFAULT_THEME, + }, + SYNTAX_HIGHLIGHTER_PLUGIN, +}; + +mod utils; + +pub const EMPTY_STR: &str = ""; + +fn commonmark_to_html<'a>(args: &[Value]) -> Result { + let args = scan_args::scan_args(args)?; + let (rb_commonmark,): (String,) = args.required; + let _: () = args.optional; + let _: () = args.splat; + let _: () = args.trailing; + let _: () = args.block; + + let kwargs = scan_args::get_kwargs::<_, (), (Option, Option), ()>( + args.keywords, + &[], + &["options", "plugins"], + )?; + let (rb_options, rb_plugins) = kwargs.optional; -fn commonmark_to_html(rb_commonmark: String, rb_options: magnus::RHash) -> String { let mut comrak_options = ComrakOptions::default(); - rb_options - .foreach(|key: Symbol, value: RHash| { - iterate_options_hash(&mut comrak_options, key, value).unwrap(); + if let Some(rb_options) = rb_options { + rb_options.foreach(|key: Symbol, value: RHash| { + iterate_options_hash(&mut comrak_options, key, value)?; Ok(ForEach::Continue) - }) - .unwrap(); + })?; + } + + if let Some(rb_plugins) = rb_plugins { + let mut comrak_plugins = ComrakPlugins::default(); + + let syntax_highlighter: Option<&dyn SyntaxHighlighterAdapter>; + let adapter: SyntectAdapter; + + let theme = match rb_plugins.get(Symbol::new(SYNTAX_HIGHLIGHTER_PLUGIN)) { + Some(theme_val) => fetch_syntax_highlighter_theme(theme_val)?, + None => SYNTAX_HIGHLIGHTER_PLUGIN_DEFAULT_THEME.to_string(), // no `syntax_highlighter:` defined + }; + + if theme.is_empty() || theme == "none" { + syntax_highlighter = None; + } else { + adapter = SyntectAdapter::new(&theme); + syntax_highlighter = Some(&adapter); + } + + comrak_plugins.render.codefence_syntax_highlighter = syntax_highlighter; - markdown_to_html(&rb_commonmark, &comrak_options) + Ok(markdown_to_html_with_plugins( + &rb_commonmark, + &comrak_options, + &comrak_plugins, + )) + } else { + Ok(markdown_to_html(&rb_commonmark, &comrak_options)) + } } #[magnus::init] fn init() -> Result<(), Error> { let module = define_module("Commonmarker")?; - module.define_module_function("commonmark_to_html", function!(commonmark_to_html, 2))?; + module.define_module_function("commonmark_to_html", function!(commonmark_to_html, -1))?; Ok(()) } diff --git a/ext/commonmarker/src/comrak_options.rs b/ext/commonmarker/src/options.rs similarity index 96% rename from ext/commonmarker/src/comrak_options.rs rename to ext/commonmarker/src/options.rs index 5d87f24f..86fde804 100644 --- a/ext/commonmarker/src/comrak_options.rs +++ b/ext/commonmarker/src/options.rs @@ -4,6 +4,8 @@ use comrak::ComrakOptions; use magnus::{class, r_hash::ForEach, Error, RHash, Symbol, Value}; +use crate::utils::try_convert_string; + const PARSE_SMART: &str = "smart"; const PARSE_DEFAULT_INFO_STRING: &str = "default_info_string"; @@ -126,11 +128,3 @@ pub fn iterate_options_hash( } Ok(ForEach::Continue) } - -fn try_convert_string(value: Value) -> Option { - if value.is_kind_of(class::string()) { - Some(value.try_convert::().unwrap()) - } else { - None - } -} diff --git a/ext/commonmarker/src/plugins.rs b/ext/commonmarker/src/plugins.rs new file mode 100644 index 00000000..344e4d2b --- /dev/null +++ b/ext/commonmarker/src/plugins.rs @@ -0,0 +1,21 @@ +// use comrak::ComrakPlugins; +// use magnus::{class, r_hash::ForEach, RHash, Symbol, Value}; + +// use crate::plugins::syntax_highlighting::fetch_syntax_highlighter_theme; + +pub mod syntax_highlighting; + +pub const SYNTAX_HIGHLIGHTER_PLUGIN: &str = "syntax_highlighter"; + +// pub fn iterate_plugins_hash( +// comrak_plugins: &mut ComrakPlugins, +// mut theme: String, +// key: Symbol, +// value: Value, +// ) -> Result { +// if key.name().unwrap() == SYNTAX_HIGHLIGHTER_PLUGIN { +// theme = fetch_syntax_highlighter_theme(value)?; +// } + +// Ok(ForEach::Continue) +// } diff --git a/ext/commonmarker/src/plugins/syntax_highlighting.rs b/ext/commonmarker/src/plugins/syntax_highlighting.rs new file mode 100644 index 00000000..e94c46b0 --- /dev/null +++ b/ext/commonmarker/src/plugins/syntax_highlighting.rs @@ -0,0 +1,30 @@ +use magnus::{RHash, Symbol, Value}; + +use crate::EMPTY_STR; + +pub const SYNTAX_HIGHLIGHTER_PLUGIN_THEME_KEY: &str = "theme"; +pub const SYNTAX_HIGHLIGHTER_PLUGIN_DEFAULT_THEME: &str = "base16-ocean.dark"; + +pub fn fetch_syntax_highlighter_theme(value: Value) -> Result { + if value.is_nil() { + // `syntax_highlighter: nil` + return Ok(EMPTY_STR.to_string()); + } + + let syntax_highlighter_plugin = value.try_convert::()?; + let theme_key = Symbol::new(SYNTAX_HIGHLIGHTER_PLUGIN_THEME_KEY); + + match syntax_highlighter_plugin.get(theme_key) { + Some(theme) => { + if theme.is_nil() { + // `syntax_highlighter: { theme: nil }` + return Ok(EMPTY_STR.to_string()); + } + Ok(theme.try_convert::()?) + } + None => { + // `syntax_highlighter: { }` + Ok(EMPTY_STR.to_string()) + } + } +} diff --git a/ext/commonmarker/src/utils.rs b/ext/commonmarker/src/utils.rs new file mode 100644 index 00000000..d89168dd --- /dev/null +++ b/ext/commonmarker/src/utils.rs @@ -0,0 +1,8 @@ +use magnus::Value; + +pub fn try_convert_string(value: Value) -> Option { + match value.try_convert::() { + Ok(s) => Some(s), + Err(_) => None, + } +} diff --git a/lib/commonmarker.rb b/lib/commonmarker.rb index 1384b77e..7f309749 100755 --- a/lib/commonmarker.rb +++ b/lib/commonmarker.rb @@ -2,6 +2,7 @@ require_relative "commonmarker/extension" +require "commonmarker/utils" require "commonmarker/config" require "commonmarker/renderer" require "commonmarker/version" @@ -16,15 +17,19 @@ class << self # Public: Parses a CommonMark string into an HTML string. # # text - A {String} of text - # option - A {Hash} of render, parse, and extension options to transform the text. + # options - A {Hash} of render, parse, and extension options to transform the text. + # plugins - A {Hash} of additional plugins. # # Returns a {String} of converted HTML. - def to_html(text, options: Commonmarker::Config::OPTS) + def to_html(text, options: Commonmarker::Config::OPTIONS, plugins: Commonmarker::Config::PLUGINS) raise TypeError, "text must be a String; got a #{text.class}!" unless text.is_a?(String) + raise TypeError, "text must be UTF-8 encoded; got #{text.encoding}!" unless text.encoding.name == "UTF-8" raise TypeError, "options must be a Hash; got a #{options.class}!" unless options.is_a?(Hash) opts = Config.process_options(options) - commonmark_to_html(text.encode("UTF-8"), opts) + plugins = Config.process_plugins(plugins) + + commonmark_to_html(text, options: opts, plugins: plugins) end end end diff --git a/lib/commonmarker/config.rb b/lib/commonmarker/config.rb index 33add7e0..3183d104 100644 --- a/lib/commonmarker/config.rb +++ b/lib/commonmarker/config.rb @@ -4,7 +4,7 @@ module Commonmarker module Config # For details, see # https://github.com/kivikakk/comrak/blob/162ef9354deb2c9b4a4e05be495aa372ba5bb696/src/main.rs#L201 - OPTS = { + OPTIONS = { parse: { smart: false, default_info_string: "", @@ -31,9 +31,17 @@ module Config format: [:html].freeze, }.freeze + PLUGINS = { + syntax_highlighter: { + theme: "base16-ocean.dark", + }, + } + class << self + include Commonmarker::Utils + def merged_with_defaults(options) - Commonmarker::Config::OPTS.merge(process_options(options)) + Commonmarker::Config::OPTIONS.merge(process_options(options)) end def process_options(options) @@ -43,29 +51,44 @@ def process_options(options) extension: process_extension_options(options[:extension]), } end - end - BOOLS = [true, false] - ["parse", "render", "extension"].each do |type| - define_singleton_method :"process_#{type}_options" do |options| - Commonmarker::Config::OPTS[type.to_sym].each_with_object({}) do |(key, value), hash| - if options.nil? # option not provided, go for the default + def process_plugins(plugins) + { + syntax_highlighter: process_syntax_highlighter_plugin(plugins&.fetch(:syntax_highlighter, nil)), + } + end + end + + [:parse, :render, :extension].each do |type| + define_singleton_method :"process_#{type}_options" do |option| + Commonmarker::Config::OPTIONS[type].each_with_object({}) do |(key, value), hash| + if option.nil? # option not provided, go for the default hash[key] = value next end # option explicitly not included, remove it - next if options[key].nil? + next if option[key].nil? - value_klass = value.class - if BOOLS.include?(value) && BOOLS.include?(options[key]) - hash[key] = options[key] - elsif options[key].is_a?(value_klass) - hash[key] = options[key] - else - expected_type = BOOLS.include?(value) ? "Boolean" : value_klass.to_s - raise TypeError, "#{type}_options[:#{key}] must be a #{expected_type}; got #{options[key].class}" + hash[key] = fetch_kv(option, key, value, type) + end + end + end + + [:syntax_highlighter].each do |type| + define_singleton_method :"process_#{type}_plugin" do |plugin| + return nil if plugin.nil? # plugin explicitly nil, remove it + + Commonmarker::Config::PLUGINS[type].each_with_object({}) do |(key, value), hash| + if plugin.nil? # option not provided, go for the default + hash[key] = value + next end + + # option explicitly not included, remove it + next if plugin[key].nil? + + hash[key] = fetch_kv(plugin, key, value, type) end end end diff --git a/lib/commonmarker/constants.rb b/lib/commonmarker/constants.rb new file mode 100644 index 00000000..f5da0a3b --- /dev/null +++ b/lib/commonmarker/constants.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Commonmarker + module Constants + BOOLS = [true, false].freeze + end +end diff --git a/lib/commonmarker/extension.rb b/lib/commonmarker/extension.rb index 710cb3e4..56bad9ca 100644 --- a/lib/commonmarker/extension.rb +++ b/lib/commonmarker/extension.rb @@ -3,7 +3,7 @@ begin # native precompiled gems package shared libraries in /lib/commonmarker/ # load the precompiled extension file - ruby_version = /\d+\.\d+/.match(::RUBY_VERSION) + ruby_version = /\d+\.\d+/.match(RUBY_VERSION) require_relative "#{ruby_version}/commonmarker" rescue LoadError # fall back to the extension compiled upon installation. diff --git a/lib/commonmarker/utils.rb b/lib/commonmarker/utils.rb new file mode 100644 index 00000000..974de948 --- /dev/null +++ b/lib/commonmarker/utils.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "commonmarker/constants" + +module Commonmarker + module Utils + include Commonmarker::Constants + + def fetch_kv(option, key, value, type) + value_klass = value.class + + if Constants::BOOLS.include?(value) && BOOLS.include?(option[key]) + option[key] + elsif option[key].is_a?(value_klass) + option[key] + else + expected_type = Constants::BOOLS.include?(value) ? "Boolean" : value_klass.to_s + raise TypeError, "#{type} option `:#{key}` must be #{expected_type}; got #{option[key].class}" + end + end + end +end diff --git a/lib/commonmarker/version.rb b/lib/commonmarker/version.rb index 4ed96fa2..8f9172c3 100644 --- a/lib/commonmarker/version.rb +++ b/lib/commonmarker/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Commonmarker - VERSION = "1.0.0.pre3" + VERSION = "1.0.0.pre4" end diff --git a/script/test-gem-installation b/script/test-gem-installation index 0441e599..81f54972 100755 --- a/script/test-gem-installation +++ b/script/test-gem-installation @@ -44,7 +44,7 @@ Minitest::Reporters.use!([Minitest::Reporters::SpecReporter.new]) puts "Testing #{gemspec.full_name} installed in #{gemspec.base_dir}" describe gemspec.full_name do - let(:ruby_maj_min) { Gem::Version.new(::RUBY_VERSION).segments[0..1].join(".") } + let(:ruby_maj_min) { Gem::Version.new(RUBY_VERSION).segments[0..1].join(".") } let(:commonmarker_lib_dir) { File.join(gemspec.gem_dir, "lib/commonmarker") } let(:commonmarker_ext_dir) { File.join(gemspec.gem_dir, "ext/commonmarker") } let(:commonmarker_include_dir) { File.join(commonmarker_ext_dir, "include") } diff --git a/test/test_basics.rb b/test/test_basics.rb index 3b55b521..f8426678 100644 --- a/test/test_basics.rb +++ b/test/test_basics.rb @@ -13,7 +13,7 @@ def test_to_html def test_to_html_accept_default_options text = "Hello **world** -- how are _you_ today? I'm ~~fine~~, ~yourself~?" - html = Commonmarker.to_html(text, options: Commonmarker::Config::OPTS).rstrip + html = Commonmarker.to_html(text, options: Commonmarker::Config::OPTIONS).rstrip assert_equal("

Hello world -- how are you today? I'm fine, yourself?

", html) end diff --git a/test/test_frontmatter.rb b/test/test_frontmatter.rb index 5e7fb506..db37ecaa 100644 --- a/test/test_frontmatter.rb +++ b/test/test_frontmatter.rb @@ -12,6 +12,6 @@ def test_frontmatter_does_not_interfere_with_codeblock HTML - assert_equal(expected, Commonmarker.to_html(md)) + assert_equal(expected, Commonmarker.to_html(md, plugins: nil)) end end diff --git a/test/test_maliciousness.rb b/test/test_maliciousness.rb index 5968d28a..7d30eaf8 100644 --- a/test/test_maliciousness.rb +++ b/test/test_maliciousness.rb @@ -46,6 +46,14 @@ def test_bad_options_value Commonmarker.to_html("foo \n baz", options: { parse: { smart: 111 } }) end - assert_equal("parse_options[:smart] must be a Boolean; got Integer", err.message) + assert_equal("parse option `:smart` must be Boolean; got Integer", err.message) + end + + def test_non_utf8 + err = assert_raises(TypeError) do + Commonmarker.to_html("foo \n baz".encode("US-ASCII")) + end + + assert_equal("text must be UTF-8 encoded; got US-ASCII!", err.message) end end diff --git a/test/test_spec.rb b/test/test_spec.rb index cb7d41c0..5009c6a4 100644 --- a/test/test_spec.rb +++ b/test/test_spec.rb @@ -20,7 +20,7 @@ class TestSpec < Minitest::Test options = Commonmarker::Config.merged_with_defaults(opts) options[:extension].delete(:header_ids) # this interefers with the spec.txt extension-less capability options[:extension][:tasklist] = true - actual = Commonmarker.to_html(testcase[:markdown], options: options).rstrip + actual = Commonmarker.to_html(testcase[:markdown], options: options, plugins: nil).rstrip assert_equal testcase[:html], actual, testcase[:markdown] end diff --git a/test/test_syntax_highlighting.rb b/test/test_syntax_highlighting.rb new file mode 100644 index 00000000..dc8ee69c --- /dev/null +++ b/test/test_syntax_highlighting.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestSyntaxHighlighting < Minitest::Test + def test_default_is_to_highlight + code = <<~CODE + ```ruby + def hello + puts "hello" + end + ``` + CODE + + html = Commonmarker.to_html(code) + + result = <<~HTML + def hello + puts "hello" + end + + + HTML + + lang = %(lang="ruby") + background = %(style="background-color:#2b303b;") + + assert_match(result, html) + # doing this because sometimes comrak returns
+    # and other times 
+    assert_match(lang, html)
+    assert_match(background, html)
+  end
+
+  def test_can_disable_highlighting
+    code = <<~CODE
+      ```ruby
+      def hello
+        puts "hello"
+      end
+      ```
+    CODE
+
+    html = Commonmarker.to_html(code, plugins: { syntax_highlighter: nil })
+
+    result = <<~CODE
+      
def hello
+        puts "hello"
+      end
+      
+ CODE + + assert_equal(result, html) + end + + def test_lack_of_theme_has_no_highlighting + code = <<~CODE + ```ruby + def hello + puts "hello" + end + ``` + CODE + + html = Commonmarker.to_html(code, plugins: { syntax_highlighter: {} }) + + result = <<~CODE +
def hello
+        puts "hello"
+      end
+      
+ CODE + + assert_match(result, html) + end + + def test_nil_theme_removes_highlighting + code = <<~CODE + ```ruby + def hello + puts "hello" + end + ``` + CODE + + html = Commonmarker.to_html(code, plugins: { syntax_highlighter: { theme: nil } }) + + result = <<~CODE +
def hello
+        puts "hello"
+      end
+      
+ CODE + + assert_equal(result, html) + end + + def test_empty_theme_is_no_highlighting + code = <<~CODE + ```ruby + def hello + puts "hello" + end + ``` + CODE + + html = Commonmarker.to_html(code, plugins: { syntax_highlighter: { theme: "" } }) + + result = <<~CODE +
def hello
+        puts "hello"
+      end
+      
+ CODE + + assert_equal(result, html) + end + + def test_can_change_highlighting_theme + code = <<~CODE + ```ruby + def hello + puts "hello" + end + ``` + CODE + + html = Commonmarker.to_html(code, plugins: { syntax_highlighter: { theme: "InspiredGitHub" } }) + result = <<~HTML + def hello + puts "hello" + end + +
+ HTML + + lang = %(lang="ruby") + background = %(style="background-color:#ffffff;") + + assert_match(result, html) + # doing this because sometimes comrak returns
+    # and other times 
+    assert_match(lang, html)
+    assert_match(background, html)
+  end
+end