Skip to content

Commit 13c49c2

Browse files
authored
Merge pull request #209 from gjtorikian/syntax-highlighting
Implement native syntax highlighting
2 parents 3017536 + 36a49b5 commit 13c49c2

18 files changed

+402
-51
lines changed

README.md

+35-4
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ Commonmarker.to_html('"Hi *there*"', options: {
3939

4040
The second argument is optional--[see below](#options) for more information.
4141

42-
## Parse and Render Options
42+
## Options and plugins
4343

44-
Commonmarker accepts the same options that comrak does, as a hash dictionary with symbol keys:
44+
### Options
45+
46+
Commonmarker accepts the same parse, render, and extensions options that comrak does, as a hash dictionary with symbol keys:
4547

4648
```ruby
4749
Commonmarker.to_html('"Hi *there*"', options:{
@@ -95,15 +97,44 @@ Commonmarker.to_html('"Hi *there*"', options: {
9597

9698
For more information on these options, see [the comrak documentation](https://github.com/kivikakk/comrak#usage).
9799

100+
### Plugins
101+
102+
In addition to the possibilities provided by generic CommonMark rendering, Commonmarker also supports plugins as a means of
103+
providing further niceties. For example:
104+
105+
code = <<~CODE
106+
```ruby
107+
def hello
108+
puts "hello"
109+
end
110+
111+
CODE
112+
113+
Commonmarker.to_html(code, plugins: { syntax_highlighter: { theme: "Inspired GitHub" } })
114+
115+
# <pre style="background-color:#ffffff;" lang="ruby"><code>
116+
# <span style="font-weight:bold;color:#a71d5d;">def </span><span style="font-weight:bold;color:#795da3;">hello
117+
# </span><span style="color:#323232;"> </span><span style="color:#62a35c;">puts </span><span style="color:#183691;">&quot;hello&quot;
118+
# </span><span style="font-weight:bold;color:#a71d5d;">end
119+
# </span>
120+
# </code></pre>
121+
122+
You can disable plugins just the same as with options, by passing `nil`:
123+
124+
```ruby
125+
Commonmarker.to_html(code, plugins: { syntax_highlighter: nil })
126+
# or
127+
Commonmarker.to_html(code, plugins: { syntax_highlighter: { theme: nil } })
128+
```
129+
98130
## Output formats
99131

100132
Commonmarker can currently only generate output in one format: HTML.
101133

102134
### HTML
103135

104136
```ruby
105-
html = CommonMarker.to_html('*Hello* world!', :DEFAULT)
106-
puts(html)
137+
puts Commonmarker.to_html('*Hello* world!')
107138

108139
# <p><em>Hello</em> world!</p>
109140
```

ext/commonmarker/src/lib.rs

+68-12
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,85 @@
11
extern crate core;
22

3-
use comrak::{markdown_to_html, ComrakOptions};
4-
use magnus::{define_module, function, r_hash::ForEach, Error, RHash, Symbol};
3+
use comrak::{
4+
adapters::SyntaxHighlighterAdapter, markdown_to_html, markdown_to_html_with_plugins,
5+
plugins::syntect::SyntectAdapter, ComrakOptions, ComrakPlugins,
6+
};
7+
use magnus::{define_module, function, r_hash::ForEach, scan_args, Error, RHash, Symbol, Value};
58

6-
mod comrak_options;
7-
use comrak_options::iterate_options_hash;
9+
mod options;
10+
use options::iterate_options_hash;
11+
12+
mod plugins;
13+
use plugins::{
14+
syntax_highlighting::{
15+
fetch_syntax_highlighter_theme, SYNTAX_HIGHLIGHTER_PLUGIN_DEFAULT_THEME,
16+
},
17+
SYNTAX_HIGHLIGHTER_PLUGIN,
18+
};
19+
20+
mod utils;
21+
22+
pub const EMPTY_STR: &str = "";
23+
24+
fn commonmark_to_html<'a>(args: &[Value]) -> Result<String, magnus::Error> {
25+
let args = scan_args::scan_args(args)?;
26+
let (rb_commonmark,): (String,) = args.required;
27+
let _: () = args.optional;
28+
let _: () = args.splat;
29+
let _: () = args.trailing;
30+
let _: () = args.block;
31+
32+
let kwargs = scan_args::get_kwargs::<_, (), (Option<RHash>, Option<RHash>), ()>(
33+
args.keywords,
34+
&[],
35+
&["options", "plugins"],
36+
)?;
37+
let (rb_options, rb_plugins) = kwargs.optional;
838

9-
fn commonmark_to_html(rb_commonmark: String, rb_options: magnus::RHash) -> String {
1039
let mut comrak_options = ComrakOptions::default();
1140

12-
rb_options
13-
.foreach(|key: Symbol, value: RHash| {
14-
iterate_options_hash(&mut comrak_options, key, value).unwrap();
41+
if let Some(rb_options) = rb_options {
42+
rb_options.foreach(|key: Symbol, value: RHash| {
43+
iterate_options_hash(&mut comrak_options, key, value)?;
1544
Ok(ForEach::Continue)
16-
})
17-
.unwrap();
45+
})?;
46+
}
47+
48+
if let Some(rb_plugins) = rb_plugins {
49+
let mut comrak_plugins = ComrakPlugins::default();
50+
51+
let syntax_highlighter: Option<&dyn SyntaxHighlighterAdapter>;
52+
let adapter: SyntectAdapter;
53+
54+
let theme = match rb_plugins.get(Symbol::new(SYNTAX_HIGHLIGHTER_PLUGIN)) {
55+
Some(theme_val) => fetch_syntax_highlighter_theme(theme_val)?,
56+
None => SYNTAX_HIGHLIGHTER_PLUGIN_DEFAULT_THEME.to_string(), // no `syntax_highlighter:` defined
57+
};
58+
59+
if theme.is_empty() || theme == "none" {
60+
syntax_highlighter = None;
61+
} else {
62+
adapter = SyntectAdapter::new(&theme);
63+
syntax_highlighter = Some(&adapter);
64+
}
65+
66+
comrak_plugins.render.codefence_syntax_highlighter = syntax_highlighter;
1867

19-
markdown_to_html(&rb_commonmark, &comrak_options)
68+
Ok(markdown_to_html_with_plugins(
69+
&rb_commonmark,
70+
&comrak_options,
71+
&comrak_plugins,
72+
))
73+
} else {
74+
Ok(markdown_to_html(&rb_commonmark, &comrak_options))
75+
}
2076
}
2177

2278
#[magnus::init]
2379
fn init() -> Result<(), Error> {
2480
let module = define_module("Commonmarker")?;
2581

26-
module.define_module_function("commonmark_to_html", function!(commonmark_to_html, 2))?;
82+
module.define_module_function("commonmark_to_html", function!(commonmark_to_html, -1))?;
2783

2884
Ok(())
2985
}

ext/commonmarker/src/comrak_options.rs ext/commonmarker/src/options.rs

+2-8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use comrak::ComrakOptions;
44

55
use magnus::{class, r_hash::ForEach, Error, RHash, Symbol, Value};
66

7+
use crate::utils::try_convert_string;
8+
79
const PARSE_SMART: &str = "smart";
810
const PARSE_DEFAULT_INFO_STRING: &str = "default_info_string";
911

@@ -126,11 +128,3 @@ pub fn iterate_options_hash(
126128
}
127129
Ok(ForEach::Continue)
128130
}
129-
130-
fn try_convert_string(value: Value) -> Option<String> {
131-
if value.is_kind_of(class::string()) {
132-
Some(value.try_convert::<String>().unwrap())
133-
} else {
134-
None
135-
}
136-
}

ext/commonmarker/src/plugins.rs

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// use comrak::ComrakPlugins;
2+
// use magnus::{class, r_hash::ForEach, RHash, Symbol, Value};
3+
4+
// use crate::plugins::syntax_highlighting::fetch_syntax_highlighter_theme;
5+
6+
pub mod syntax_highlighting;
7+
8+
pub const SYNTAX_HIGHLIGHTER_PLUGIN: &str = "syntax_highlighter";
9+
10+
// pub fn iterate_plugins_hash(
11+
// comrak_plugins: &mut ComrakPlugins,
12+
// mut theme: String,
13+
// key: Symbol,
14+
// value: Value,
15+
// ) -> Result<ForEach, magnus::Error> {
16+
// if key.name().unwrap() == SYNTAX_HIGHLIGHTER_PLUGIN {
17+
// theme = fetch_syntax_highlighter_theme(value)?;
18+
// }
19+
20+
// Ok(ForEach::Continue)
21+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use magnus::{RHash, Symbol, Value};
2+
3+
use crate::EMPTY_STR;
4+
5+
pub const SYNTAX_HIGHLIGHTER_PLUGIN_THEME_KEY: &str = "theme";
6+
pub const SYNTAX_HIGHLIGHTER_PLUGIN_DEFAULT_THEME: &str = "base16-ocean.dark";
7+
8+
pub fn fetch_syntax_highlighter_theme(value: Value) -> Result<String, magnus::Error> {
9+
if value.is_nil() {
10+
// `syntax_highlighter: nil`
11+
return Ok(EMPTY_STR.to_string());
12+
}
13+
14+
let syntax_highlighter_plugin = value.try_convert::<RHash>()?;
15+
let theme_key = Symbol::new(SYNTAX_HIGHLIGHTER_PLUGIN_THEME_KEY);
16+
17+
match syntax_highlighter_plugin.get(theme_key) {
18+
Some(theme) => {
19+
if theme.is_nil() {
20+
// `syntax_highlighter: { theme: nil }`
21+
return Ok(EMPTY_STR.to_string());
22+
}
23+
Ok(theme.try_convert::<String>()?)
24+
}
25+
None => {
26+
// `syntax_highlighter: { }`
27+
Ok(EMPTY_STR.to_string())
28+
}
29+
}
30+
}

ext/commonmarker/src/utils.rs

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use magnus::Value;
2+
3+
pub fn try_convert_string(value: Value) -> Option<String> {
4+
match value.try_convert::<String>() {
5+
Ok(s) => Some(s),
6+
Err(_) => None,
7+
}
8+
}

lib/commonmarker.rb

+8-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative "commonmarker/extension"
44

5+
require "commonmarker/utils"
56
require "commonmarker/config"
67
require "commonmarker/renderer"
78
require "commonmarker/version"
@@ -16,15 +17,19 @@ class << self
1617
# Public: Parses a CommonMark string into an HTML string.
1718
#
1819
# text - A {String} of text
19-
# option - A {Hash} of render, parse, and extension options to transform the text.
20+
# options - A {Hash} of render, parse, and extension options to transform the text.
21+
# plugins - A {Hash} of additional plugins.
2022
#
2123
# Returns a {String} of converted HTML.
22-
def to_html(text, options: Commonmarker::Config::OPTS)
24+
def to_html(text, options: Commonmarker::Config::OPTIONS, plugins: Commonmarker::Config::PLUGINS)
2325
raise TypeError, "text must be a String; got a #{text.class}!" unless text.is_a?(String)
26+
raise TypeError, "text must be UTF-8 encoded; got #{text.encoding}!" unless text.encoding.name == "UTF-8"
2427
raise TypeError, "options must be a Hash; got a #{options.class}!" unless options.is_a?(Hash)
2528

2629
opts = Config.process_options(options)
27-
commonmark_to_html(text.encode("UTF-8"), opts)
30+
plugins = Config.process_plugins(plugins)
31+
32+
commonmark_to_html(text, options: opts, plugins: plugins)
2833
end
2934
end
3035
end

lib/commonmarker/config.rb

+40-17
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module Commonmarker
44
module Config
55
# For details, see
66
# https://github.com/kivikakk/comrak/blob/162ef9354deb2c9b4a4e05be495aa372ba5bb696/src/main.rs#L201
7-
OPTS = {
7+
OPTIONS = {
88
parse: {
99
smart: false,
1010
default_info_string: "",
@@ -31,9 +31,17 @@ module Config
3131
format: [:html].freeze,
3232
}.freeze
3333

34+
PLUGINS = {
35+
syntax_highlighter: {
36+
theme: "base16-ocean.dark",
37+
},
38+
}
39+
3440
class << self
41+
include Commonmarker::Utils
42+
3543
def merged_with_defaults(options)
36-
Commonmarker::Config::OPTS.merge(process_options(options))
44+
Commonmarker::Config::OPTIONS.merge(process_options(options))
3745
end
3846

3947
def process_options(options)
@@ -43,29 +51,44 @@ def process_options(options)
4351
extension: process_extension_options(options[:extension]),
4452
}
4553
end
46-
end
4754

48-
BOOLS = [true, false]
49-
["parse", "render", "extension"].each do |type|
50-
define_singleton_method :"process_#{type}_options" do |options|
51-
Commonmarker::Config::OPTS[type.to_sym].each_with_object({}) do |(key, value), hash|
52-
if options.nil? # option not provided, go for the default
55+
def process_plugins(plugins)
56+
{
57+
syntax_highlighter: process_syntax_highlighter_plugin(plugins&.fetch(:syntax_highlighter, nil)),
58+
}
59+
end
60+
end
61+
62+
[:parse, :render, :extension].each do |type|
63+
define_singleton_method :"process_#{type}_options" do |option|
64+
Commonmarker::Config::OPTIONS[type].each_with_object({}) do |(key, value), hash|
65+
if option.nil? # option not provided, go for the default
5366
hash[key] = value
5467
next
5568
end
5669

5770
# option explicitly not included, remove it
58-
next if options[key].nil?
71+
next if option[key].nil?
5972

60-
value_klass = value.class
61-
if BOOLS.include?(value) && BOOLS.include?(options[key])
62-
hash[key] = options[key]
63-
elsif options[key].is_a?(value_klass)
64-
hash[key] = options[key]
65-
else
66-
expected_type = BOOLS.include?(value) ? "Boolean" : value_klass.to_s
67-
raise TypeError, "#{type}_options[:#{key}] must be a #{expected_type}; got #{options[key].class}"
73+
hash[key] = fetch_kv(option, key, value, type)
74+
end
75+
end
76+
end
77+
78+
[:syntax_highlighter].each do |type|
79+
define_singleton_method :"process_#{type}_plugin" do |plugin|
80+
return nil if plugin.nil? # plugin explicitly nil, remove it
81+
82+
Commonmarker::Config::PLUGINS[type].each_with_object({}) do |(key, value), hash|
83+
if plugin.nil? # option not provided, go for the default
84+
hash[key] = value
85+
next
6886
end
87+
88+
# option explicitly not included, remove it
89+
next if plugin[key].nil?
90+
91+
hash[key] = fetch_kv(plugin, key, value, type)
6992
end
7093
end
7194
end

lib/commonmarker/constants.rb

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
module Commonmarker
4+
module Constants
5+
BOOLS = [true, false].freeze
6+
end
7+
end

lib/commonmarker/extension.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
begin
44
# native precompiled gems package shared libraries in <gem_dir>/lib/commonmarker/<ruby_version>
55
# load the precompiled extension file
6-
ruby_version = /\d+\.\d+/.match(::RUBY_VERSION)
6+
ruby_version = /\d+\.\d+/.match(RUBY_VERSION)
77
require_relative "#{ruby_version}/commonmarker"
88
rescue LoadError
99
# fall back to the extension compiled upon installation.

0 commit comments

Comments
 (0)