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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +203 -0
- data/README.md +180 -0
- data/_includes/slides/controls.html +28 -0
- data/_includes/slides/overview.html +1 -0
- data/_includes/slides/progress.html +3 -0
- data/_layouts/presentation.html +36 -0
- data/assets/css/presentation.css +2 -0
- data/assets/fonts/OFL-atkinson-hyperlegible-mono.txt +93 -0
- data/assets/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- data/assets/fonts/README.md +56 -0
- data/assets/fonts/atkinson-hyperlegible-mono-italic.woff2 +0 -0
- data/assets/fonts/atkinson-hyperlegible-mono.woff2 +0 -0
- data/assets/fonts/atkinson-hyperlegible-next-italic.woff2 +0 -0
- data/assets/fonts/atkinson-hyperlegible-next.woff2 +0 -0
- data/assets/js/presentation.js +370 -0
- data/doc/CHANGELOG.md +25 -0
- data/doc/Jekyll/Slides/Assets/SiteAssetFile.md +10 -0
- data/doc/Jekyll/Slides/Assets.md +12 -0
- data/doc/Jekyll/Slides/ComponentRenderer/RenderedDocument.md +15 -0
- data/doc/Jekyll/Slides/ComponentRenderer.md +33 -0
- data/doc/Jekyll/Slides/JekyllIntegration.md +28 -0
- data/doc/Jekyll/Slides/Layout.md +10 -0
- data/doc/Jekyll/Slides/Presentation.md +19 -0
- data/doc/Jekyll/Slides/PresentationParser.md +17 -0
- data/doc/Jekyll/Slides/RangeParser.md +21 -0
- data/doc/Jekyll/Slides/Renderer.md +10 -0
- data/doc/Jekyll/Slides/RougeRenderer.md +16 -0
- data/doc/Jekyll/Slides/SlideMetadata.md +43 -0
- data/doc/Jekyll/Slides/SlideRenderer.md +13 -0
- data/doc/Jekyll/Slides/Support.md +12 -0
- data/doc/Jekyll/Slides.md +50 -0
- data/doc/Jekyll.md +5 -0
- data/doc/README.md +180 -0
- data/doc/index.csv +71 -0
- data/lib/jekyll/slides/assets.rb +47 -0
- data/lib/jekyll/slides/component_renderer.rb +237 -0
- data/lib/jekyll/slides/jekyll_integration.rb +126 -0
- data/lib/jekyll/slides/layout.rb +20 -0
- data/lib/jekyll/slides/presentation.rb +22 -0
- data/lib/jekyll/slides/presentation_parser.rb +102 -0
- data/lib/jekyll/slides/range_parser.rb +52 -0
- data/lib/jekyll/slides/renderer.rb +21 -0
- data/lib/jekyll/slides/rouge_renderer.rb +58 -0
- data/lib/jekyll/slides/slide_metadata.rb +85 -0
- data/lib/jekyll/slides/slide_renderer.rb +93 -0
- data/lib/jekyll/slides/support.rb +25 -0
- data/lib/jekyll/slides/version.rb +7 -0
- data/lib/jekyll/slides.rb +39 -0
- data/lib/jekyll-slides.rb +3 -0
- data/llm.txt +50 -0
- 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
|