Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement native syntax highlighting #209

Merged
merged 6 commits into from
Dec 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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