jekyll-slides 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE.txt +203 -0
  4. data/README.md +180 -0
  5. data/_includes/slides/controls.html +28 -0
  6. data/_includes/slides/overview.html +1 -0
  7. data/_includes/slides/progress.html +3 -0
  8. data/_layouts/presentation.html +36 -0
  9. data/assets/css/presentation.css +2 -0
  10. data/assets/fonts/OFL-atkinson-hyperlegible-mono.txt +93 -0
  11. data/assets/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  12. data/assets/fonts/README.md +56 -0
  13. data/assets/fonts/atkinson-hyperlegible-mono-italic.woff2 +0 -0
  14. data/assets/fonts/atkinson-hyperlegible-mono.woff2 +0 -0
  15. data/assets/fonts/atkinson-hyperlegible-next-italic.woff2 +0 -0
  16. data/assets/fonts/atkinson-hyperlegible-next.woff2 +0 -0
  17. data/assets/js/presentation.js +370 -0
  18. data/doc/CHANGELOG.md +25 -0
  19. data/doc/Jekyll/Slides/Assets/SiteAssetFile.md +10 -0
  20. data/doc/Jekyll/Slides/Assets.md +12 -0
  21. data/doc/Jekyll/Slides/ComponentRenderer/RenderedDocument.md +15 -0
  22. data/doc/Jekyll/Slides/ComponentRenderer.md +33 -0
  23. data/doc/Jekyll/Slides/JekyllIntegration.md +28 -0
  24. data/doc/Jekyll/Slides/Layout.md +10 -0
  25. data/doc/Jekyll/Slides/Presentation.md +19 -0
  26. data/doc/Jekyll/Slides/PresentationParser.md +17 -0
  27. data/doc/Jekyll/Slides/RangeParser.md +21 -0
  28. data/doc/Jekyll/Slides/Renderer.md +10 -0
  29. data/doc/Jekyll/Slides/RougeRenderer.md +16 -0
  30. data/doc/Jekyll/Slides/SlideMetadata.md +43 -0
  31. data/doc/Jekyll/Slides/SlideRenderer.md +13 -0
  32. data/doc/Jekyll/Slides/Support.md +12 -0
  33. data/doc/Jekyll/Slides.md +50 -0
  34. data/doc/Jekyll.md +5 -0
  35. data/doc/README.md +180 -0
  36. data/doc/index.csv +71 -0
  37. data/lib/jekyll/slides/assets.rb +47 -0
  38. data/lib/jekyll/slides/component_renderer.rb +237 -0
  39. data/lib/jekyll/slides/jekyll_integration.rb +126 -0
  40. data/lib/jekyll/slides/layout.rb +20 -0
  41. data/lib/jekyll/slides/presentation.rb +22 -0
  42. data/lib/jekyll/slides/presentation_parser.rb +102 -0
  43. data/lib/jekyll/slides/range_parser.rb +52 -0
  44. data/lib/jekyll/slides/renderer.rb +21 -0
  45. data/lib/jekyll/slides/rouge_renderer.rb +58 -0
  46. data/lib/jekyll/slides/slide_metadata.rb +85 -0
  47. data/lib/jekyll/slides/slide_renderer.rb +93 -0
  48. data/lib/jekyll/slides/support.rb +25 -0
  49. data/lib/jekyll/slides/version.rb +7 -0
  50. data/lib/jekyll/slides.rb +39 -0
  51. data/lib/jekyll-slides.rb +3 -0
  52. data/llm.txt +50 -0
  53. metadata +175 -0
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+ require "kramdown-parser-gfm"
5
+ require "set"
6
+
7
+ module Jekyll
8
+ module Slides
9
+ class ComponentRenderer
10
+ FRAGMENT_PATTERN = /<!-- jekyll-slides:start:(\d+) -->(.*?)<!-- jekyll-slides:end:\1 -->/m
11
+ RenderedDocument = Struct.new(:components, :fragments, keyword_init: true)
12
+ SIZES = %w[sm md lg].freeze
13
+ # Advisory only: available space depends on the slide layout and component size.
14
+ LONG_CODE_LINE_THRESHOLD = 20
15
+
16
+ def self.render(markdown, warning: nil, options: {})
17
+ new(warning: warning, options: options).render(markdown)
18
+ end
19
+
20
+ def initialize(warning: nil, rouge_renderer: nil, options: {})
21
+ @warning = warning
22
+ @rouge_renderer = rouge_renderer || RougeRenderer.new(warning: warning)
23
+ @options = options
24
+ end
25
+
26
+ def render(markdown)
27
+ render_document(markdown).fragments.map { |_kind, html, _metadata| html }.join
28
+ end
29
+
30
+ def render_fragments(markdown, id_prefix: "")
31
+ render_document(markdown, id_prefix: id_prefix).fragments
32
+ end
33
+
34
+ def render_document(markdown, id_prefix: "")
35
+ document = Kramdown::Document.new(markdown.to_s, kramdown_options(id_prefix))
36
+ descriptors = []
37
+ components = []
38
+ document.root.children = document.root.children.flat_map do |element|
39
+ next element if element.type == :blank
40
+
41
+ index = descriptors.length
42
+ kind, rendered, component = render_element(element)
43
+ components << component if component
44
+ descriptors << [kind, heading_level(element)]
45
+ [marker("start", index), rendered, marker("end", index)]
46
+ end
47
+
48
+ html, warnings = Kramdown::Converter::Html.convert(document.root, document.options)
49
+ warnings.each { |message| Support.warn(@warning, message) }
50
+ RenderedDocument.new(
51
+ components: components,
52
+ fragments: merge_prose_fragments(extract_fragments(html, descriptors))
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def kramdown_options(id_prefix)
59
+ @options.merge(
60
+ "input" => @options.fetch("input", "GFM"),
61
+ "auto_id_prefix" => id_prefix,
62
+ "footnote_prefix" => "#{@options.fetch('footnote_prefix', '')}#{id_prefix}"
63
+ )
64
+ end
65
+
66
+ def render_element(element)
67
+ attributes = component_attributes(element)
68
+ component = component_type(element, attributes)
69
+ return [:prose, element, nil] unless component
70
+
71
+ source_lines = element.value.to_s.lines.map do |line|
72
+ [line.sub(/\r?\n\z/, ""), line[/\r?\n\z/].to_s]
73
+ end
74
+ language = element.options[:lang].to_s
75
+ html = render_component(component, source_lines, language, attributes)
76
+ [:visual, raw_element(html), component]
77
+ end
78
+
79
+ def component_attributes(element)
80
+ ial = element.options[:ial] || {}
81
+ attributes = ial.each_with_object({ classes: [] }) do |(key, value), result|
82
+ if key.to_s == "class"
83
+ result[:classes] = value.to_s.split
84
+ elsif key.to_s == "id"
85
+ result[:id] = value
86
+ else
87
+ result[key.to_s] = value
88
+ end
89
+ end
90
+ attributes[:id] ||= element.attr["id"]
91
+ attributes
92
+ end
93
+
94
+ def component_type(element, attributes)
95
+ return unless element.type == :codeblock
96
+ return :editor if attributes[:classes].include?("editor")
97
+
98
+ :terminal if attributes[:classes].include?("terminal")
99
+ end
100
+
101
+ def heading_level(element)
102
+ element.type == :header ? element.options[:level] : nil
103
+ end
104
+
105
+ def marker(position, index)
106
+ raw_element("<!-- jekyll-slides:#{position}:#{index} -->")
107
+ end
108
+
109
+ def raw_element(html)
110
+ Kramdown::Element.new(:raw, html, {}, category: :block, type: "html")
111
+ end
112
+
113
+ def extract_fragments(html, descriptors)
114
+ matches = html.to_enum(:scan, FRAGMENT_PATTERN).map { Regexp.last_match }
115
+ fragments = matches.map do |match|
116
+ kind, level = descriptors.fetch(match[1].to_i)
117
+ [kind, match[2].strip, { heading_level: level }]
118
+ end
119
+ append_converter_tail(fragments, html[matches.last&.end(0).to_i..].to_s)
120
+ end
121
+
122
+ def append_converter_tail(fragments, tail)
123
+ tail = tail.strip
124
+ return fragments if tail.empty?
125
+
126
+ if fragments.last&.first == :prose
127
+ fragments.last[1] << tail
128
+ else
129
+ fragments << [:prose, tail, { heading_level: nil }]
130
+ end
131
+ fragments
132
+ end
133
+
134
+ def merge_prose_fragments(fragments)
135
+ fragments.each_with_object([]) do |fragment, merged|
136
+ kind, html, metadata = fragment
137
+ previous = merged.last
138
+ if kind == :prose && previous&.first == :prose && metadata[:heading_level].nil?
139
+ previous[1] << html
140
+ else
141
+ merged << fragment
142
+ end
143
+ end
144
+ end
145
+
146
+ def render_component(component, source_lines, language, attrs)
147
+ if source_lines.length > LONG_CODE_LINE_THRESHOLD
148
+ Support.warn(@warning, "#{component.to_s.capitalize} block has #{source_lines.length} lines and may require scrolling; " \
149
+ "consider splitting it across slides, especially for print.")
150
+ end
151
+
152
+ if component == :editor
153
+ render_editor(source_lines, language, attrs)
154
+ elsif component == :terminal
155
+ render_terminal(source_lines, attrs)
156
+ else
157
+ ""
158
+ end
159
+ end
160
+
161
+ def render_editor(lines, language, attrs)
162
+ size = normalize_size(attrs["size"])
163
+ highlight = line_range(attrs["highlight"], max_line: lines.length)
164
+ focus = line_range(attrs["focus"], max_line: lines.length)
165
+ line_numbers = line_numbers?(attrs.fetch("line_numbers", "true"))
166
+ classes = ["code-window", "size-#{size}"] + attrs[:classes].reject { |name| name == "editor" }
167
+ classes << "has-focus" unless focus.empty?
168
+ attributes = html_attributes(class: classes.join(" "), id: attrs[:id])
169
+ header = +'<header class="code-window-header"><span class="code-window-controls" aria-hidden="true"><i></i><i></i><i></i></span>'
170
+ header << %(<span class="code-window-language">#{Support.escape(language)}</span></header>)
171
+ caption = attrs["title"] ? %(<figcaption>#{Support.escape(attrs['title'])}</figcaption>) : ""
172
+ source = lines.map { |line, newline| line + newline }.join
173
+ highlighted = @rouge_renderer.render_lines(source, language)
174
+ highlight = highlight.to_set
175
+ focus = focus.to_set
176
+ code = lines.each_with_index.map do |(_line, _newline), index|
177
+ line_classes = ["line"]
178
+ line_classes << "is-highlighted" if highlight.include?(index + 1)
179
+ line_classes << "is-focused" if focus.include?(index + 1)
180
+ line_attrs = { class: line_classes.join(" ") }
181
+ line_attrs["data-line-number"] = index + 1 if line_numbers
182
+ %(<span#{html_attributes(line_attrs)}>#{highlighted[index]}</span>)
183
+ end.join
184
+ %(<figure#{attributes}>#{caption}#{header}<pre><code>#{code}</code></pre></figure>)
185
+ end
186
+
187
+ def render_terminal(lines, attrs)
188
+ size = normalize_size(attrs["size"])
189
+ classes = ["terminal-window", "size-#{size}"] + attrs[:classes].reject { |name| name == "terminal" }
190
+ figure_attrs = html_attributes(class: classes.join(" "), id: attrs[:id])
191
+ caption = attrs["title"] ? %(<figcaption>#{Support.escape(attrs['title'])}</figcaption>) : ""
192
+ body = lines.map do |line, newline|
193
+ visible = Support.escape(line)
194
+ if (prompt_match = line.match(/\A([$>❯]) /))
195
+ prompt = Support.escape(prompt_match[1])
196
+ rest = Support.escape(line[1..].to_s)
197
+ visible = %(<span class="prompt">#{prompt}</span><span class="cmd">#{rest}</span>)
198
+ end
199
+ %(<span class="tline">#{visible}#{Support.escape(newline)}</span>)
200
+ end.join
201
+ %(<figure#{figure_attrs}>#{caption}<pre><code>#{body}</code></pre></figure>)
202
+ end
203
+
204
+ def line_range(value, max_line: nil)
205
+ RangeParser.parse(value, warning: @warning, max_line: max_line)
206
+ end
207
+
208
+ def line_numbers?(value)
209
+ case value.to_s.downcase
210
+ when "true"
211
+ true
212
+ when "false"
213
+ false
214
+ else
215
+ Support.warn(@warning, "Invalid line_numbers #{value.inspect}; falling back to true")
216
+ true
217
+ end
218
+ end
219
+
220
+ def normalize_size(value)
221
+ return "md" if value.nil? || value.to_s.empty?
222
+ return value.to_s if SIZES.include?(value.to_s)
223
+
224
+ Support.warn(@warning, "Unsupported component size #{value.inspect}; falling back to md")
225
+ "md"
226
+ end
227
+
228
+ def html_attributes(values)
229
+ values.map do |key, value|
230
+ next if value.nil?
231
+
232
+ %( #{key}="#{Support.escape(value)}")
233
+ end.compact.join
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ module Jekyll
6
+ module Slides
7
+ module JekyllIntegration
8
+ DEFAULTS = {
9
+ "theme" => "midnight",
10
+ "aspect_ratio" => "16:9",
11
+ "progress" => true,
12
+ "slide_numbers" => true,
13
+ "overview" => true,
14
+ "fullscreen" => true
15
+ }.freeze
16
+ THEMES = %w[minimal-light minimal-dark midnight ruby].freeze
17
+ BOOLEAN_OPTIONS = %w[progress slide_numbers overview fullscreen].freeze
18
+ OPTION_KEYS = DEFAULTS.keys.freeze
19
+
20
+ class << self
21
+ def install!
22
+ return if @installed
23
+
24
+ Jekyll::Hooks.register(:site, :post_read) do |site|
25
+ process_site(site)
26
+ end
27
+ @installed = true
28
+ end
29
+
30
+ def process_site(site)
31
+ install_resources(site)
32
+ presentation_documents(site).each { |document| process(document) }
33
+ end
34
+
35
+ def process(document)
36
+ return unless presentation?(document)
37
+ return if document.instance_variable_defined?(:@jekyll_slides_processed)
38
+
39
+ site = document.site
40
+ options = normalized_options(site, document.data)
41
+ source = document.content.to_s
42
+ document.data.merge!(normalized_data(options, source))
43
+ document.instance_variable_set(:@renderer, Renderer.new(site, document))
44
+ document.instance_variable_set(:@jekyll_slides_processed, true)
45
+ end
46
+
47
+ private
48
+
49
+ def presentation?(document)
50
+ document.respond_to?(:data) && document.data.is_a?(Hash) && document.data["layout"].to_s == "presentation"
51
+ end
52
+
53
+ def install_resources(site)
54
+ site.layouts[LAYOUT_NAME] ||= Layout.new(site)
55
+ includes_path = File.join(ROOT, "_includes")
56
+ site.includes_load_paths << includes_path unless site.includes_load_paths.include?(includes_path)
57
+ Assets.install(site)
58
+ end
59
+
60
+ def presentation_documents(site)
61
+ collection_documents = site.collections.values.flat_map(&:docs)
62
+ site.pages + collection_documents
63
+ end
64
+
65
+ def normalized_options(site, front_matter)
66
+ global = site.config.is_a?(Hash) ? site.config["slides"] : nil
67
+ if global.nil?
68
+ global = {}
69
+ elsif !global.is_a?(Hash)
70
+ warning("Invalid slides configuration; using defaults")
71
+ global = {}
72
+ end
73
+
74
+ OPTION_KEYS.to_h do |key|
75
+ value = front_matter[key]
76
+ value = global.fetch(key, DEFAULTS[key]) if value.nil?
77
+ [key, normalize_option(key, value)]
78
+ end
79
+ end
80
+
81
+ def normalize_option(key, value)
82
+ case key
83
+ when "theme"
84
+ theme = value.to_s
85
+ return theme if THEMES.include?(theme)
86
+
87
+ warning("Invalid theme #{value.inspect}; falling back to midnight")
88
+ DEFAULTS[key]
89
+ when "aspect_ratio"
90
+ return value if value == DEFAULTS[key]
91
+
92
+ warning("Invalid aspect ratio #{value.inspect}; quote it in YAML as aspect_ratio: \"16:9\"; falling back to 16:9")
93
+ DEFAULTS[key]
94
+ when *BOOLEAN_OPTIONS
95
+ return value if [true, false].include?(value)
96
+
97
+ normalized = value.to_s.strip.downcase
98
+ return true if normalized == "true"
99
+ return false if normalized == "false"
100
+
101
+ warning("Invalid #{key} option #{value.inspect}; falling back to #{DEFAULTS[key]}")
102
+ DEFAULTS[key]
103
+ end
104
+ end
105
+
106
+ def normalized_data(options, source)
107
+ {
108
+ "slides_theme" => options["theme"],
109
+ "slides_aspect_ratio" => options["aspect_ratio"],
110
+ "slides_progress" => options["progress"],
111
+ "slides_slide_numbers" => options["slide_numbers"],
112
+ "slides_overview" => options["overview"],
113
+ "slides_fullscreen" => options["fullscreen"],
114
+ "slides_count" => PresentationParser.new(source).parse.length
115
+ }
116
+ end
117
+
118
+ def warning(message)
119
+ Jekyll.logger.warn("Jekyll Slides:", message)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ Jekyll::Slides::JekyllIntegration.install!
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Slides
5
+ class Layout < Jekyll::Layout
6
+ def initialize(site)
7
+ @site = site
8
+ @base = ROOT
9
+ @base_dir = ROOT
10
+ @name = "presentation.html"
11
+ @path = File.join(ROOT, "_layouts", @name)
12
+ @relative_path = File.join("_layouts", @name)
13
+ self.data = {}
14
+
15
+ process(@name)
16
+ read_yaml(File.dirname(@path), @name)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Slides
5
+ # Renders a Markdown deck into slide sections without the page layout.
6
+ class Presentation
7
+ # @param markdown [String] Markdown without page YAML front matter
8
+ # @param warning [#call, nil] receives advisory rendering warnings
9
+ # @param options [Hash] kramdown options
10
+ def initialize(markdown, warning: nil, options: {})
11
+ @markdown = markdown
12
+ @renderer = SlideRenderer.new(warning: warning, options: options)
13
+ end
14
+
15
+ # @param markdown [String] optional replacement for the initial Markdown
16
+ # @return [String] rendered slide section HTML
17
+ def render(markdown = @markdown)
18
+ @renderer.render(markdown)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+
6
+ module Jekyll
7
+ module Slides
8
+ class PresentationParser
9
+ def self.parse(body)
10
+ new(body).parse
11
+ end
12
+
13
+ def initialize(body = nil)
14
+ @body = body.to_s
15
+ end
16
+
17
+ def parse(body = @body)
18
+ @invalid_front_matter = false
19
+ @invalid_front_matter_closing = nil
20
+ @unterminated_front_matter = false
21
+ source = strip_front_matter(body.to_s)
22
+ slides = []
23
+ current = []
24
+ fence = nil
25
+
26
+ source.each_line.with_index do |line, line_index|
27
+ if fence
28
+ fence = nil if closing_fence?(line, fence)
29
+ current << line
30
+ elsif (opening = fence_line(line))
31
+ fence = opening
32
+ current << line
33
+ elsif slide_separator?(line, line_index)
34
+ slide = normalize_slide(current.join)
35
+ slides << slide if slide
36
+ current = []
37
+ else
38
+ current << line
39
+ end
40
+ end
41
+ slide = normalize_slide(current.join)
42
+ slides << slide if slide
43
+ slides
44
+ end
45
+
46
+ private
47
+
48
+ def slide_separator?(line, line_index)
49
+ return false unless line.chomp == "---"
50
+ return false if @unterminated_front_matter && line_index.zero?
51
+ return false if @invalid_front_matter && [0, @invalid_front_matter_closing].include?(line_index)
52
+
53
+ true
54
+ end
55
+
56
+ def strip_front_matter(source)
57
+ return source unless source.start_with?("---\n", "---\r\n")
58
+
59
+ lines = source.lines
60
+ closing = lines[1..]&.index { |line| line.chomp == "---" }
61
+ unless closing
62
+ @unterminated_front_matter = true
63
+ return source
64
+ end
65
+ closing += 1
66
+
67
+ front_lines = lines[1...closing]
68
+ valid = begin
69
+ parsed = YAML.safe_load(front_lines.join, permitted_classes: [Date, Time, DateTime])
70
+ front_lines.empty? || parsed.is_a?(Hash)
71
+ rescue Psych::Exception
72
+ false
73
+ end
74
+ unless valid
75
+ @invalid_front_matter = true
76
+ @invalid_front_matter_closing = closing
77
+ return source
78
+ end
79
+ lines[(closing + 1)..]&.join.to_s
80
+ end
81
+
82
+ def normalize_slide(text)
83
+ return if text.strip.empty?
84
+
85
+ text = text.sub(/\A(?:[ \t]*\r?\n)+/, "")
86
+ text.sub(/(?:\r?\n[ \t]*)+\z/, "")
87
+ end
88
+
89
+ def fence_line(line)
90
+ match = line.match(/\A( {0,3})(`{3,}|~{3,})([^\r\n]*)\r?\n?\z/)
91
+ return unless match
92
+ return if match[2].start_with?("`") && match[3].include?("`")
93
+
94
+ { indent: match[1].length, char: match[2][0], length: match[2].length }
95
+ end
96
+
97
+ def closing_fence?(line, fence)
98
+ line.match?(/\A {0,3}#{Regexp.escape(fence[:char])}{#{fence[:length]},} *\r?\n?\z/)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Slides
5
+ class RangeParser
6
+ # Prevent author-controlled ranges from forcing unbounded allocation.
7
+ MAX_RANGE_SIZE = 100_000
8
+
9
+ class << self
10
+ def parse(value, warning: nil, max_line: nil)
11
+ new(value, warning: warning, max_line: max_line).parse
12
+ end
13
+ end
14
+
15
+ def initialize(value = nil, warning: nil, max_line: nil)
16
+ @value = value
17
+ @warning = warning
18
+ @max_line = max_line
19
+ end
20
+
21
+ def parse
22
+ return [] if @value.nil? || @value.to_s.strip.empty?
23
+
24
+ spans = @value.to_s.split(",", -1).map do |token|
25
+ token = token.strip
26
+ case token
27
+ when /\A([1-9]\d*)\z/
28
+ number = Regexp.last_match(1).to_i
29
+ raise ArgumentError if @max_line && number > @max_line
30
+
31
+ [number, number]
32
+ when /\A([1-9]\d*)-([1-9]\d*)\z/
33
+ first = Regexp.last_match(1).to_i
34
+ last = Regexp.last_match(2).to_i
35
+ raise ArgumentError if first > last || last - first + 1 > MAX_RANGE_SIZE
36
+ raise ArgumentError if @max_line && last > @max_line
37
+
38
+ [first, last]
39
+ else
40
+ raise ArgumentError
41
+ end
42
+ end
43
+ raise ArgumentError if spans.sum { |first, last| last - first + 1 } > MAX_RANGE_SIZE
44
+
45
+ spans.flat_map { |first, last| (first..last).to_a }.uniq.sort
46
+ rescue ArgumentError
47
+ Support.warn(@warning, "Invalid line range #{@value.inspect}; ignoring range")
48
+ []
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Slides
5
+ class Renderer < Jekyll::Renderer
6
+ def convert(content)
7
+ Presentation.new(
8
+ content,
9
+ warning: method(:warning),
10
+ options: site.config.fetch("kramdown", {})
11
+ ).render
12
+ end
13
+
14
+ private
15
+
16
+ def warning(message)
17
+ Jekyll.logger.warn("Jekyll Slides:", "#{document.relative_path}: #{message}")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Slides
5
+ class RougeRenderer
6
+ def initialize(warning: nil)
7
+ @warning = warning
8
+ end
9
+
10
+ def render(source, language)
11
+ lexer = Rouge::Lexer.find(language.to_s)
12
+ return Support.escape(source.to_s) unless lexer
13
+
14
+ Rouge::Formatters::HTML.new.format(lexer.lex(source.to_s))
15
+ rescue StandardError => e
16
+ Support.warn(@warning, "Unable to highlight #{language.inspect}: #{e.message}")
17
+ Support.escape(source.to_s)
18
+ end
19
+
20
+ def render_lines(source, language)
21
+ source = source.to_s
22
+ distribute(render(source, language), source)
23
+ end
24
+
25
+ private
26
+
27
+ def distribute(html, source)
28
+ lines = []
29
+ current = +""
30
+ open_tags = []
31
+ html.scan(/<[^>]+>|[^<]+/m).each do |token|
32
+ if token.start_with?("<")
33
+ current << token
34
+ if token.match?(/\A<span\b/)
35
+ open_tags << token
36
+ elsif token == "</span>"
37
+ open_tags.pop
38
+ end
39
+ next
40
+ end
41
+
42
+ token.split(/(\r?\n)/, -1).each do |part|
43
+ if part.match?(/\A\r?\n\z/)
44
+ current << ("</span>" * open_tags.length) << part
45
+ lines << current
46
+ current = +open_tags.join
47
+ else
48
+ current << part
49
+ end
50
+ end
51
+ end
52
+ lines << current unless current.empty?
53
+ expected = source.lines.length
54
+ lines.first(expected)
55
+ end
56
+ end
57
+ end
58
+ end