Skip to content

Instantly share code, notes, and snippets.

@czak
Created June 22, 2016 18:03
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save czak/94f8b98e3f6789b38a4f7414ca703380 to your computer and use it in GitHub Desktop.
Save czak/94f8b98e3f6789b38a4f7414ca703380 to your computer and use it in GitHub Desktop.
Extended jekyll {% highlight %} tag with <figcaption>
module Czak
module Tags
class HighlightCaptionBlock < Liquid::Block
include Liquid::StandardFilters
# The regular expression syntax checker. Start with the language specifier.
# Follow that by zero or more space separated options that take one of three
# forms: name, name=value, or name="<quoted list>"
#
# <quoted list> is a space-separated list of numbers
SYNTAX = %r!^([a-zA-Z0-9.+#-]+)((\s+\w+(=([\w\.\/-]+|"([0-9]+\s)*[0-9]+"))?)*)$!
def initialize(tag_name, markup, tokens)
super
if markup.strip =~ SYNTAX
@lang = Regexp.last_match(1).downcase
@highlight_options = parse_options(Regexp.last_match(2))
else
raise SyntaxError, <<-eos
Syntax Error in tag 'highlight' while parsing the following markup:
#{markup}
Valid syntax: highlight <lang> [linenos]
eos
end
end
def render(context)
prefix = context["highlighter_prefix"] || ""
suffix = context["highlighter_suffix"] || ""
code = super.to_s.gsub(%r!\A(\n|\r)+|(\n|\r)+\z!, "")
is_safe = !!context.registers[:site].safe
output =
case context.registers[:site].highlighter
when "pygments"
render_pygments(code, is_safe)
when "rouge"
render_rouge(code)
else
render_codehighlighter(code)
end
rendered_output = add_code_tag(output)
prefix + rendered_output + suffix
end
def sanitized_opts(opts, is_safe)
if is_safe
Hash[[
[:startinline, opts.fetch(:startinline, nil)],
[:hl_lines, opts.fetch(:hl_lines, nil)],
[:linenos, opts.fetch(:linenos, nil)],
[:encoding, opts.fetch(:encoding, "utf-8")],
[:cssclass, opts.fetch(:cssclass, nil)],
[:caption, opts.fetch(:caption, nil)]
].reject { |f| f.last.nil? }]
else
opts
end
end
private
def parse_options(input)
options = {}
unless input.empty?
# Split along 3 possible forms -- key="<quoted list>", key=value, or key
input.scan(%r!(?:\w+="[^"]*"|\w+=[\w\.\/-]+|\w+)!) do |opt|
key, value = opt.split("=")
# If a quoted list, convert to array
if value && value.include?("\"")
value.delete!('"')
value = value.split
end
options[key.to_sym] = value || true
end
end
if options.key?(:linenos) && options[:linenos] == true
options[:linenos] = "inline"
end
options
end
def render_pygments(code, is_safe)
Jekyll::External.require_with_graceful_fail("pygments")
highlighted_code = Pygments.highlight(
code,
:lexer => @lang,
:options => sanitized_opts(@highlight_options, is_safe)
)
if highlighted_code.nil?
Jekyll.logger.error <<eos
There was an error highlighting your code:
#{code}
While attempting to convert the above code, Pygments.rb returned an unacceptable value.
This is usually a timeout problem solved by running `jekyll build` again.
eos
raise ArgumentError, "Pygments.rb returned an unacceptable value "\
"when attempting to highlight some code."
end
highlighted_code.sub('<div class="highlight"><pre>', "").sub("</pre></div>", "")
end
def render_rouge(code)
Jekyll::External.require_with_graceful_fail("rouge")
formatter = Rouge::Formatters::HTML.new(
:line_numbers => @highlight_options[:linenos],
:wrap => false
)
lexer = Rouge::Lexer.find_fancy(@lang, code) || Rouge::Lexers::PlainText
formatter.format(lexer.lex(code))
end
def render_codehighlighter(code)
h(code).strip
end
def add_code_tag(code)
code_attributes = [
"class=\"language-#{@lang.to_s.tr("+", "-")}\"",
"data-lang=\"#{@lang}\""
].join(" ")
res = "<figure class=\"highlight\">"
res += "<figcaption>#{@highlight_options[:caption]}</figcaption>" if @highlight_options[:caption]
res += "<pre><code #{code_attributes}>#{code.chomp}</code></pre></figure>"
res
end
end
end
end
Liquid::Template.register_tag("highlight2", Czak::Tags::HighlightCaptionBlock)
@czak
Copy link
Author

czak commented Aug 16, 2016

The plugin registers a highlight2 tag with Liquid.

To use the plugin on your site, place highlight_caption_block.rb inside _plugins folder and use the following:

{% highlight2 java caption=src/main/java/Hello.java %}
class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}
{% endhighlight2 %}

This will result in the following markup:

<figure class="highlight">
  <figcaption>src/main/java/Hello.java</figcaption>
  <pre>
    <code class="language-java" data-lang="java">
      <!-- highlighted code omitted for clarity -->
    </code>
  </pre>
</figure>

@mohkale
Copy link

mohkale commented Nov 6, 2019

Wow, I got annoyed by this very same thing today. I looked into tbjers/jekyll-highlight which claims to be a better highlighter, and for all intense and purposes it is (because it offers this feature), but I couldn't get over how it forces the caption to include a File: prefix (couldn't see an option to disable it). I considered making a pull request to allow the user to dictate what (or rather if they event want a) prefix for the caption, but it hasn't been updated in 3 years and I feared just waiting forever to get the feature to my blog. Then I found this and for all intense and purposes, this is exactly what I was looking for.

Though, if I'm allowed to be a little critical, I don't like the idea of copying and pasting a class and then changing the code to get some desired features. Now if there are any upstream changes to the original file, ours will be left behind 😢 😢 😢. So I tried to replicate what you did, but had my class inherit from the default highlight tag. This'll maximise code reuse and maintain consistency with upstream updates.

# src: https://gist.github.com/czak/94f8b98e3f6789b38a4f7414ca703380
# see also: https://github.com/jekyll/jekyll/blob/master/lib/jekyll/tags/highlight.rb

module Mohkale
    class HighlightCaptionBlock < Jekyll::Tags::HighlightBlock
        @@markup_parse_regexp = %r!^([a-zA-Z0-9.+#-]+)((\s+\w+(=([\w\.\/-]+|"([0-9]+\s)*[0-9]+"))?)*)$!.freeze

        # also uses a different regex to the parent class, so
        # the method body needs to be defined basically twice :(
        def initialize(tag_name, markup, tokens)
            begin
                super(tag_name, markup, tokens)
            rescue SyntaxError
                if markup.strip =~ @@markup_parse_regexp
                    @lang = Regexp.last_match(1).downcase
                    @highlight_options = parse_options(Regexp.last_match(2))
                else
                    raise SyntaxError, <<~MSG
Syntax Error in tag 'highlight' while parsing the following markup:
    #{markup}
Valid syntax: highlight <lang> [caption] [linenos] [option=OPTION]
WARN: values for options must be a series of characters ending in
      a space. You cannot delimit them with speech marks.
MSG
                end
            end
        end

        def render(context)
            @context = context
            super
        end

        def add_code_tag(code)
            tag = super # without a figure caption

            if caption = @highlight_options[:caption]
                # find the end of the beginning figure tag
                end_of_fig = /class="highlight"[^>]*>/
                if match = end_of_fig.match(tag)
                    end_of_tag = match.end(0) # end of zeroeth group, meaning of entire match string
                    tag = tag.slice(0, end_of_tag) + get_caption_tag(caption) + tag.slice(end_of_tag, tag.length)
                else
                    # can't find end of figure, so can't append figure caption to tag
                    raise "unable to find end of figure tag in code\n#{tag}"
                end
            end

            tag
        end

        private

        # Split along 3 possible forms -- key="<quoted list>", key=value, or key
        @@options_parse_regexp = %r!(?:\w+="[^"]*"|\w+=[\w\.\/-]+|\w+)!

        # uses a different regexp, so has to be redefined here :(
        def parse_options(input)
            options = {}
            unless input.empty?
                input.scan(@@options_parse_regexp) do |opt|
                    key, value = opt.split("=")
                    # If a quoted list, convert to array
                    if value && value.include?("\"")
                        value.delete!('"')
                        value = value.split
                    end
                    options[key.to_sym] = value || true
                end
            end

            if options.key?(:linenos) && options[:linenos] == true
                options[:linenos] = "inline"
            end

            options
        end

        def get_caption_tag(caption)
            tag = "<figcaption>"
            if caption_prefix
                tag += "<span class=\"prefix\">#{caption_prefix} </span>"
            end
            tag += caption
            tag += "</figcaption>"
            tag
        end

        def _site_config()
            @context.registers[:site].config
        end

        def _highlight_config()
            _site_config["highlight"]
        end

        def caption_prefix
            _highlight_config && _highlight_config["caption_prefix"]
        end
    end
end

Liquid::Template.register_tag("highlight", Mohkale::HighlightCaptionBlock)

seeing as I'm extending the existing highlight tag, I've taken liberty to just redirect all highlight tags through my class instead of the default 😄. I've also tried to replicate the prefix feature from tbjers/jekyll-highlight so if you want a prefix for figure captions, simply add the following to your _config.yml file.

highlight:
  caption_prefix: "File:"

Update: oops, forgot to allow file names with full stops like you did. embarassing 😓 my bad. Added it now :)

@mohkale
Copy link

mohkale commented Nov 6, 2019

I got annoyed by the fact that the highlight tag doesn't follow my kramdown settings for line numbers so I added a fallback to that when unspecified. I also got annoyed that I couldn't declare prefixes for figure captions in the tag itself, so I added that. Then I got annoyed that you can't send values with multiple spaces to the tag, noticed that you can for some reason do so with commer delimeted numbers in speech marks, also noticed that doing so is meaningless because @highlight_options is only used for line numbers, literally nothing else; so I changed it so that you can delimit multi space strings using speech marks (doesn't support speech marks within strings sadly).

Anyways, here's what I've got now:

# src: https://gist.github.com/czak/94f8b98e3f6789b38a4f7414ca703380
# see also: https://github.com/jekyll/jekyll/blob/master/lib/jekyll/tags/highlight.rb

module Mohkale
    class HighlightCaptionBlock < Jekyll::Tags::HighlightBlock
        @@markup_parse_regexp = %r!^([\w\d.+#-]+)((\s+\w+(=([\w\./\\]+|"[^"]+"))?)*)$!.freeze

        # also uses a different regex to the parent class, so
        # the method body needs to be defined basically twice :(
        def initialize(tag_name, markup, tokens)
            begin
                super(tag_name, markup, tokens)
            rescue SyntaxError
                if markup.strip =~ @@markup_parse_regexp
                    @lang = Regexp.last_match(1).downcase
                    @highlight_options = parse_options(Regexp.last_match(2))
                else
                    raise SyntaxError, <<~MSG
Syntax Error in tag 'highlight' while parsing the following markup:
    #{markup}
Valid syntax: highlight <lang> [caption] [linenos] [option=OPTION]
WARN: values for options must be a series of characters ending in
      a space. You cannot delimit them with speech marks.
MSG
                end
            end
        end

        def render(context)
            @context = context

            # make highlight tag conform to kramdown
            linenos = @highlight_options[:linenos]
            @highlight_options[:linenos] =
                if linenos.nil?
                    _kramdown_linenos
                elsif linenos == "no"
                    false
                else
                    linenos
                end

            super
        end

        def add_code_tag(code)
            tag = super # without a figure caption

            if caption = @highlight_options[:caption]
                # find the end of the beginning figure tag
                end_of_fig = /class="highlight"[^>]*>/
                if match = end_of_fig.match(tag)
                    end_of_tag = match.end(0) # end of zeroeth group, meaning of entire match string
                    tag = tag.slice(0, end_of_tag) + get_caption_tag(caption) + tag.slice(end_of_tag, tag.length)
                else
                    # can't find end of figure, so can't append figure caption to tag
                    raise "unable to find end of figure tag in code\n#{tag}"
                end
            end

            tag
        end

        private

        # Split along 3 possible forms -- key="<quoted list>", key=value, or key
        @@options_parse_regexp = %r!(?:\w+="[^"]*"|\w+=[\w\.\/-]+|\w+)!

        # uses a different regexp, so has to be redefined here :(
        def parse_options(input)
            options = {}
            return options if input.empty?

            # Split along 3 possible forms -- key="<quoted list>", key=value, or key
            input.scan(@@options_parse_regexp) do |opt|
                key, value = opt.split("=")
                # If a quoted value, remove
                if value&.include?('"')
                    value.delete!('"')
                end
                options[key.to_sym] = value || true
            end

            options[:linenos] = "inline" if options[:linenos] == true
            options
        end

        def get_caption_tag(caption)
            tag = "<figcaption>"
            if caption_prefix
                tag += "<span class=\"prefix\">#{caption_prefix}</span>"
            end
            tag += caption
            tag += "</figcaption>"
            tag
        end

        def _site_config()
            @context.registers[:site].config
        end

        def _highlight_config()
            _site_config["highlight"]
        end

        def caption_prefix
            @highlight_options[:caption_prefix] || (_highlight_config && _highlight_config["caption_prefix"])
        end

        # there's probably a better way todo this, but what ever
        def _kramdown_linenos
            begin
                _site_config["kramdown"][:syntax_highlighter_opts][:block][:line_numbers]
            rescue NoMethodError
                nil # key not found
            end
        end
    end
end

Liquid::Template.register_tag("highlight", Mohkale::HighlightCaptionBlock)

@inetbiz
Copy link

inetbiz commented Oct 11, 2021

@mohkale Are there other means to add caption other than to a separate file?

@mohkale
Copy link

mohkale commented Oct 11, 2021

@inetbiz

I'm not sure what you're asking. Either way it's been nearly two years since I wrote this and well over a year since I stopped using jekyll so I'm not sure I'll be of much help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment