Class: JekyllGFMAdmonitions::GFMAdmonitionConverter

Inherits:
Jekyll::Generator
  • Object
show all
Defined in:
lib/jekyll-gfm-admonitions.rb

Overview

GFMAdmonitionConverter is a Jekyll generator that converts custom admonition blocks in markdown (e.g., ‘> [!IMPORTANT]`) into styled HTML alert boxes with icons.

This generator processes both posts and pages, replacing admonition syntax with HTML markup that includes appropriate iconography and CSS styling.

CSS injection can be disabled via _config.yml:

gfm_admonitions:
  inject_css: false

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.admonition_pagesObject (readonly)

Returns the value of attribute admonition_pages.



33
34
35
# File 'lib/jekyll-gfm-admonitions.rb', line 33

def admonition_pages
  @admonition_pages
end

.inject_cssObject

Returns the value of attribute inject_css.



34
35
36
# File 'lib/jekyll-gfm-admonitions.rb', line 34

def inject_css
  @inject_css
end

Class Method Details

.reset!Object



36
37
38
39
# File 'lib/jekyll-gfm-admonitions.rb', line 36

def reset!
  @admonition_pages = []
  @inject_css = true
end

Instance Method Details

#admonition_html(type, title, text, icon) ⇒ Object



155
156
157
158
159
160
161
162
163
164
165
# File 'lib/jekyll-gfm-admonitions.rb', line 155

def admonition_html(type, title, text, icon)
  body = @markdown.convert(text)
  body = body.gsub(/href="(?!https?:\/\/)([^"]*?)\.md(#[^"]*?)?"/) do
    anchor = ::Regexp.last_match(2) || ''
    "href=\"#{::Regexp.last_match(1)}.html#{anchor}\""
  end
  "<div class='markdown-alert markdown-alert-#{type}'>" \
    "<p class='markdown-alert-title'>#{icon} #{title}</p>" \
    "#{body}" \
  "</div>"
end

#convert_admonitions(doc) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/jekyll-gfm-admonitions.rb', line 135

def convert_admonitions(doc)
  doc.content.gsub!(/^([^\S\n]*)>[^\S\n]*\[!(IMPORTANT|NOTE|WARNING|TIP|CAUTION)\]([^\n]*)\n((?:\1[^\S\n]*>[^\S\n]*[^\n]*(?:\n|$))(?:(?![^\S\n]*>[^\S\n]*\[!)\1[^\S\n]*>[^\S\n]*[^\n]*(?:\n|$))*)/) do
    initial_indent = ::Regexp.last_match(1)
    type = ::Regexp.last_match(2).downcase
    title = ::Regexp.last_match(3).strip.empty? ? type.capitalize : ::Regexp.last_match(3).strip
    # Strip the blockquote prefix from each line. Per CommonMark, a `>`
    # marker consumes at most ONE following space, so we only remove a
    # single space here. Consuming all whitespace would flatten the
    # indentation that distinguishes nested list items (see issue #20).
    text = ::Regexp.last_match(4).gsub(/^#{Regexp.escape(initial_indent)}[^\S\n]*>[^\S\n]?/, '').strip

    icon = Octicons::Octicon.new(ADMONITION_ICONS[type]).to_svg
    html = admonition_html(type, title, text, icon)
    initial_indent.empty? ? html : html.gsub(/^/, initial_indent)
  end

  # Ensure a blank line exists after each admonition block to prevent Markdown parsing issues.
  doc.content.gsub!(/(<\/div>)(?!\n\n)/, "\\1\n\n")
end

#generate(site) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/jekyll-gfm-admonitions.rb', line 44

def generate(site)
  self.class.reset!

  inject_css_setting = site.config.dig('gfm_admonitions', 'inject_css')
  self.class.inject_css = (inject_css_setting != false)

  init_converter(site)
  process_collections(site)
  process_pages(site)
  Jekyll.logger.info 'GFMA:', "Converted admonitions in #{self.class.admonition_pages.length} file(s)."

  if self.class.inject_css
    Jekyll.logger.debug 'GFMA:', 'CSS injection enabled.'
  else
    Jekyll.logger.info 'GFMA:', 'CSS injection disabled (gfm_admonitions.inject_css: false).'
  end
end

#init_converter(site) ⇒ Object



62
63
64
65
66
67
68
# File 'lib/jekyll-gfm-admonitions.rb', line 62

def init_converter(site)
  @markdown = site.converters.find { |c| c.is_a?(Jekyll::Converters::Markdown) }
  return if @markdown

  raise 'Markdown converter not found. Please ensure that you have a markdown' \
          ' converter configured in your Jekyll site.'
end

#process_collections(site) ⇒ Object



70
71
72
73
74
75
76
77
# File 'lib/jekyll-gfm-admonitions.rb', line 70

def process_collections(site)
  site.collections.each do |name, collection|
    collection.docs.each do |doc|
      Jekyll.logger.debug 'GFMA:', "Processing collection '#{name}' document '#{doc.path}' (#{doc.content.length} characters)."
      process_doc_content(doc)
    end
  end
end

#process_doc(doc) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/jekyll-gfm-admonitions.rb', line 98

def process_doc(doc)
  # Return early if content is empty
  return if doc.content.empty?

  # If the content is frozen, we need to duplicate it so that we can modify it
  doc.content = doc.content.dup if doc.content.frozen?

  code_blocks = []
  # Temporarily replace fenced code blocks by a tag, so that we don't process any
  # admonitions inside of code blocks.
  doc.content.gsub!(/(?:^|\n)(?<!>)\s*```.*?```/m) do |match|
    code_blocks << match
    "```{{CODE_BLOCK_#{code_blocks.length - 1}}}```"
  end

  indented_blocks = []
  # Temporarily replace 4-space/tab indented code blocks (CommonMark §4.4).
  # These must be preceded by a blank line or appear at start of content —
  # indented code blocks cannot interrupt a paragraph.
  doc.content.gsub!(/(\A|\n\n)((?:(?:[ ]{4,}|\t)[^\n]*(?:\n|\z))+)/) do
    anchor = ::Regexp.last_match(1)
    block  = ::Regexp.last_match(2)
    indented_blocks << block
    "#{anchor}{{INDENTED_CODE_BLOCK_#{indented_blocks.length - 1}}}"
  end

  convert_admonitions(doc)

  # Restore indented code blocks, then fenced code blocks.
  doc.content.gsub!(/\{\{INDENTED_CODE_BLOCK_(\d+)\}\}/) do
    indented_blocks[::Regexp.last_match(1).to_i]
  end
  doc.content.gsub!(/```\{\{CODE_BLOCK_(\d+)}}```/) do
    code_blocks[::Regexp.last_match(1).to_i]
  end
end

#process_doc_content(doc) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
# File 'lib/jekyll-gfm-admonitions.rb', line 86

def process_doc_content(doc)
  original_content = doc.content.dup
  process_doc(doc)

  return unless doc.content != original_content

  # Store a reference to all the pages we modified, to inject the CSS post render
  # (otherwise GitHub Pages sanitizes the CSS into plaintext).
  # Only track when CSS injection is enabled.
  self.class.admonition_pages << doc if self.class.inject_css
end

#process_pages(site) ⇒ Object



79
80
81
82
83
84
# File 'lib/jekyll-gfm-admonitions.rb', line 79

def process_pages(site)
  site.pages.each do |page|
    Jekyll.logger.debug 'GFMA:', "Processing page '#{page.path}' (#{page.content.length} characters)."
    process_doc_content(page)
  end
end