Skip to content

Commit

Permalink
Merge pull request #209 from gjtorikian/syntax-highlighting
Browse files Browse the repository at this point in the history
Implement native syntax highlighting
  • Loading branch information
gjtorikian authored Dec 28, 2022
2 parents 3017536 + 36a49b5 commit 13c49c2
Show file tree
Hide file tree
Showing 18 changed files with 402 additions and 51 deletions.
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:{
Expand Down Expand Up @@ -95,15 +97,44 @@ 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" } })

# <pre style="background-color:#ffffff;" lang="ruby"><code>
# <span style="font-weight:bold;color:#a71d5d;">def </span><span style="font-weight:bold;color:#795da3;">hello
# </span><span style="color:#323232;"> </span><span style="color:#62a35c;">puts </span><span style="color:#183691;">&quot;hello&quot;
# </span><span style="font-weight:bold;color:#a71d5d;">end
# </span>
# </code></pre>

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.

### HTML

```ruby
html = CommonMarker.to_html('*Hello* world!', :DEFAULT)
puts(html)
puts Commonmarker.to_html('*Hello* world!')

# <p><em>Hello</em> world!</p>
```
Expand Down
80 changes: 68 additions & 12 deletions ext/commonmarker/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<String, magnus::Error> {
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<RHash>, Option<RHash>), ()>(
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(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -126,11 +128,3 @@ pub fn iterate_options_hash(
}
Ok(ForEach::Continue)
}

fn try_convert_string(value: Value) -> Option<String> {
if value.is_kind_of(class::string()) {
Some(value.try_convert::<String>().unwrap())
} else {
None
}
}
21 changes: 21 additions & 0 deletions ext/commonmarker/src/plugins.rs
Original file line number Diff line number Diff line change
@@ -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<ForEach, magnus::Error> {
// if key.name().unwrap() == SYNTAX_HIGHLIGHTER_PLUGIN {
// theme = fetch_syntax_highlighter_theme(value)?;
// }

// Ok(ForEach::Continue)
// }
30 changes: 30 additions & 0 deletions ext/commonmarker/src/plugins/syntax_highlighting.rs
Original file line number Diff line number Diff line change
@@ -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<String, magnus::Error> {
if value.is_nil() {
// `syntax_highlighter: nil`
return Ok(EMPTY_STR.to_string());
}

let syntax_highlighter_plugin = value.try_convert::<RHash>()?;
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::<String>()?)
}
None => {
// `syntax_highlighter: { }`
Ok(EMPTY_STR.to_string())
}
}
}
8 changes: 8 additions & 0 deletions ext/commonmarker/src/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use magnus::Value;

pub fn try_convert_string(value: Value) -> Option<String> {
match value.try_convert::<String>() {
Ok(s) => Some(s),
Err(_) => None,
}
}
11 changes: 8 additions & 3 deletions lib/commonmarker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require_relative "commonmarker/extension"

require "commonmarker/utils"
require "commonmarker/config"
require "commonmarker/renderer"
require "commonmarker/version"
Expand All @@ -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
57 changes: 40 additions & 17 deletions lib/commonmarker/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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)
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/commonmarker/constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Commonmarker
module Constants
BOOLS = [true, false].freeze
end
end
2 changes: 1 addition & 1 deletion lib/commonmarker/extension.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
begin
# native precompiled gems package shared libraries in <gem_dir>/lib/commonmarker/<ruby_version>
# 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.
Expand Down
Loading

0 comments on commit 13c49c2

Please sign in to comment.