jekyll-slides 0.1.0 → 0.3.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.
data/doc/README.md CHANGED
@@ -59,6 +59,62 @@ class: keynote
59
59
  -->
60
60
  ```
61
61
 
62
+ ## Deck folders
63
+
64
+ A deck can also be a folder of Markdown files, one file per slide, instead of one file with `---` separators:
65
+
66
+ ```
67
+ talks/my-talk/
68
+ index.md # deck front matter, opts in with `slides: true`
69
+ 01-title.md
70
+ 02-the-idea.md
71
+ 03-code.md
72
+ diagram.png # ordinary static file, untouched
73
+ ```
74
+
75
+ The entry file must be named `index.md` and carry the deck's front matter, `layout: presentation`, and `slides: true`:
76
+
77
+ ```yaml
78
+ # talks/my-talk/index.md
79
+ ---
80
+ layout: presentation
81
+ title: My talk
82
+ slides: true
83
+ ---
84
+ ```
85
+
86
+ `slides: true` discovers every other Markdown file that is a direct child of the folder (not recursive), skipping names starting with `_` or `.`, and skipping anything Jekyll itself withholds: a file dropped by the site's `exclude:` setting, or one whose front matter sets `published: false`. Naming a withheld file in an explicit list warns and skips it too, so a deck never publishes content the site holds back. Files are ordered by a natural sort of the filename: digit runs compare numerically and sort before letters, so `01-title.md`, `02-the-idea.md`, `10-outro.md`, then any file with no numeric prefix, sorted alphabetically. Each file holds one slide, or several separated by `---`, exactly as in a single-file deck. Body content in `index.md`, if any, becomes the deck's leading slides.
87
+
88
+ A slide file can carry its own YAML front matter with `layout`, `background`, and `class`, as an alternative to the `<!-- ... -->` comment:
89
+
90
+ ```markdown
91
+ ---
92
+ layout: statement
93
+ background: spotlight
94
+ ---
95
+ # A bold claim
96
+ ```
97
+
98
+ Front matter in the file applies to every slide it holds; a slide's own comment overrides it key by key.
99
+
100
+ Set `slides:` to a list instead of `true` to turn the entry file into a readable running order, in presentation order:
101
+
102
+ ```yaml
103
+ ---
104
+ layout: presentation
105
+ title: My talk
106
+ slides:
107
+ - 01-title.md
108
+ - 02-the-idea.md
109
+ # - 03-detour.md
110
+ - 04-code.md
111
+ ---
112
+ ```
113
+
114
+ Cutting a slide for a shorter time slot is commenting out one line, not moving a file out of the folder.
115
+
116
+ `slides:` is what opts a folder in. Opting in absorbs the whole folder: every Markdown file it absorbs is removed from the site's pages, collection documents, and static files, so none of them is ever published on its own. This holds even for a file commented out of an explicit list; the list controls order and inclusion in the deck, not what gets absorbed. Without `slides:`, a folder holding an `index.md` presentation behaves exactly as it does today, and sibling files are left alone. This matters if the site applies `layout: presentation` through a broad `defaults:` rule, since that alone never triggers absorption.
117
+
62
118
  ## Editors and terminals
63
119
 
64
120
  Put a kramdown inline attribute list immediately after a fenced block. Attribute values must be quoted; class and ID tokens such as `.editor` and `#order-total` do not need quotes. `.editor` renders a syntax-highlighted editor window; `.terminal` renders a terminal window.
@@ -80,7 +136,7 @@ ruby 3.4.1
80
136
  {: .terminal #ruby-version title="terminal" size="sm"}
81
137
  ````
82
138
 
83
- Editor/terminal attributes are `title`, `id` (or `#id`), `size` (`sm`, `md`, `lg`), `line_numbers` (`true`/`false`), `focus`, and `highlight`. Ranges accept comma-separated lines and ranges such as `2,4-6,9`; invalid values fall back safely with a Jekyll warning. Terminal prompts beginning with `$ `, `> `, or `❯ ` are styled separately. Source remains server-rendered and copyable.
139
+ Editor/terminal attributes are `title`, `id` (or `#id`), `size` (`sm`, `md`, `lg`), `theme`, `line_numbers` (`true`/`false`), `focus`, and `highlight`. Ranges accept comma-separated lines and ranges such as `2,4-6,9`; invalid values fall back safely with a Jekyll warning. Terminal prompts beginning with `$ `, `> `, or `❯ ` are styled separately. Source remains server-rendered and copyable.
84
140
 
85
141
  Long editor and terminal blocks scroll within the slide. Blocks over 20 lines produce an advisory build warning; the amount that fits depends on the layout and font size. Split long examples across slides for print, where scrolling is unavailable.
86
142
 
@@ -90,7 +146,7 @@ Code remains selectable and copyable with normal browser controls; version 0.1 d
90
146
 
91
147
  ## Themes and configuration
92
148
 
93
- Presentation themes are `minimal-light`, `minimal-dark`, `midnight` (the default), and `ruby`. These style the slides without replacing the site's Jekyll theme. Configure defaults under `slides:`:
149
+ Presentation themes are `minimal-light`, `minimal-dark`, `midnight` (the default), `ruby`, and the four Catppuccin flavors `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, and `catppuccin-mocha`. These style the slides without replacing the site's Jekyll theme. Configure defaults under `slides:`:
94
150
 
95
151
  ```yaml
96
152
  slides:
@@ -106,11 +162,31 @@ The same keys can be overridden per presentation in front matter (`theme`, `aspe
106
162
 
107
163
  For a light presentation, set `slides: { theme: minimal-light }` in `_config.yml`, or `theme: minimal-light` in the deck's front matter. The [readability example](https://github.com/lucianghinda/jekyll-slides/blob/main/examples/readability.md) demonstrates the light theme with code, terminal output, tables, and prose.
108
164
 
165
+ ### Terminal themes
166
+
167
+ The Catppuccin flavors are ported from the palette shared by [catppuccin/ghostty](https://github.com/catppuccin/ghostty). Hues are the published values; lightness is adjusted only where a token cannot otherwise clear the 4.5:1 contrast the project checks on every code surface. Latte, the light flavor, needed the most adjustment.
168
+
169
+ Any theme also works on a single editor or terminal block through a `theme` attribute, so one window can carry a different palette from the deck around it:
170
+
171
+ ````markdown
172
+ ```console
173
+ $ ghostty --version
174
+ ghostty 1.0.1
175
+ ```
176
+ {: .terminal title="ghostty" theme="catppuccin-latte"}
177
+ ````
178
+
179
+ The override repaints that window completely: surface, title bar, syntax colors, prompt, and body text. Everything outside it keeps the deck theme. An unknown name warns during the build and falls back to the deck theme. The [terminal themes example](https://github.com/lucianghinda/jekyll-slides/blob/main/examples/terminal-themes.md) shows a deck-wide flavor next to per-window overrides.
180
+
181
+ ### Window chrome
182
+
183
+ Editors and terminals render the same macOS-style title bar: traffic lights on the left, the `title` centered in the bar, and for editors the language on the right. The traffic lights keep the macOS red, amber, and green in every theme, the way a real title bar does; only the window surface follows the theme. Bar height, control size, and spacing scale with the component `size`.
184
+
109
185
  ## Typography and readability
110
186
 
111
187
  The gem bundles Atkinson Hyperlegible Next for text and Atkinson Hyperlegible Mono for code, including variable upright and italic faces. Fonts load from the site's own assets and work offline. The font files retain their SIL Open Font License; source revisions, checksums, and notices are in [assets/fonts](https://github.com/lucianghinda/jekyll-slides/blob/main/assets/fonts/README.md).
112
188
 
113
- On the 1920×1080 slide canvas, body text is 44px, section headings are 72px, and code sizes `sm`, `md`, and `lg` are 32px, 38px, and 42px. These sizes scale with the slide. Filenames and line numbers remain readable, and focused lines use borders and background color while keeping surrounding code fully visible. All four themes check syntax-token contrast against normal, highlighted, and focused code surfaces.
189
+ On the 1920×1080 slide canvas, body text is 44px, section headings are 72px, and code sizes `sm`, `md`, and `lg` are 32px, 38px, and 42px. These sizes scale with the slide. Filenames and line numbers remain readable, and focused lines use borders and background color while keeping surrounding code fully visible. All eight themes check syntax-token contrast against normal, highlighted, and focused code surfaces, both deck-wide and as a single-window override.
114
190
 
115
191
  Prefer medium or large code and short examples for projection. Long blocks can scroll, but an audience cannot reveal hidden lines independently; split examples when presenting or printing. Screen contrast tests do not replace checking the actual projector and viewing distance.
116
192
 
@@ -151,7 +227,7 @@ bundle exec rake docs
151
227
  ```
152
228
 
153
229
  This replaces `doc/` with fresh YARD Markdown, updates the documentation index in
154
- `doc/Jekyll/Slides.md`, and writes `llm.txt` with links relative to the gem root.
230
+ `doc/Jekyll/Slides.md`, and writes `llms.txt` with links relative to the gem root.
155
231
  The generated files are checked in and shipped with the gem. Internal plans,
156
232
  temporary files, and Node dependencies are excluded. YARD and `yard-markdown`
157
233
  are development tools only.
data/doc/index.csv CHANGED
@@ -5,27 +5,44 @@ Jekyll,Module,Jekyll.md
5
5
  Jekyll::Slides,Module,Jekyll/Slides.md
6
6
  Jekyll::Slides.ROOT,Constant,Jekyll/Slides.md#constant-ROOT
7
7
  Jekyll::Slides.LAYOUT_NAME,Constant,Jekyll/Slides.md#constant-LAYOUT_NAME
8
+ Jekyll::Slides.THEMES,Constant,Jekyll/Slides.md#constant-THEMES
9
+ Jekyll::Slides.DEFAULT_THEME,Constant,Jekyll/Slides.md#constant-DEFAULT_THEME
8
10
  Jekyll::Slides.VERSION,Constant,Jekyll/Slides.md#constant-VERSION
9
11
  Jekyll::Slides::Assets,Module,Jekyll/Slides/Assets.md
10
12
  Jekyll::Slides::Assets.asset_files,Method,Jekyll/Slides/Assets.md#method-c-asset_files
11
13
  Jekyll::Slides::Assets.install,Method,Jekyll/Slides/Assets.md#method-c-install
12
14
  Jekyll::Slides::Support,Module,Jekyll/Slides/Support.md
13
15
  Jekyll::Slides::Support.escape,Method,Jekyll/Slides/Support.md#method-c-escape
16
+ Jekyll::Slides::Support.natural_sort_key,Method,Jekyll/Slides/Support.md#method-c-natural_sort_key
14
17
  Jekyll::Slides::Support.warn,Method,Jekyll/Slides/Support.md#method-c-warn
18
+ Jekyll::Slides::FrontMatter,Module,Jekyll/Slides/FrontMatter.md
19
+ Jekyll::Slides::FrontMatter.parse,Method,Jekyll/Slides/FrontMatter.md#method-c-parse
20
+ Jekyll::Slides::FrontMatter.split,Method,Jekyll/Slides/FrontMatter.md#method-c-split
21
+ Jekyll::Slides::DeckAssembler,Module,Jekyll/Slides/DeckAssembler.md
22
+ Jekyll::Slides::DeckAssembler.assemble,Method,Jekyll/Slides/DeckAssembler.md#method-c-assemble
15
23
  Jekyll::Slides::JekyllIntegration,Module,Jekyll/Slides/JekyllIntegration.md
16
24
  Jekyll::Slides::JekyllIntegration.DEFAULTS,Constant,Jekyll/Slides/JekyllIntegration.md#constant-DEFAULTS
17
- Jekyll::Slides::JekyllIntegration.THEMES,Constant,Jekyll/Slides/JekyllIntegration.md#constant-THEMES
18
25
  Jekyll::Slides::JekyllIntegration.BOOLEAN_OPTIONS,Constant,Jekyll/Slides/JekyllIntegration.md#constant-BOOLEAN_OPTIONS
19
26
  Jekyll::Slides::JekyllIntegration.OPTION_KEYS,Constant,Jekyll/Slides/JekyllIntegration.md#constant-OPTION_KEYS
20
27
  Jekyll::Slides::JekyllIntegration.install!,Method,Jekyll/Slides/JekyllIntegration.md#method-c-install-21
21
28
  Jekyll::Slides::JekyllIntegration.process,Method,Jekyll/Slides/JekyllIntegration.md#method-c-process
22
29
  Jekyll::Slides::JekyllIntegration.process_site,Method,Jekyll/Slides/JekyllIntegration.md#method-c-process_site
30
+ Jekyll::Slides::Deck,Class,Jekyll/Slides/Deck.md
31
+ Jekyll::Slides::Deck.OPTION_KEYS,Constant,Jekyll/Slides/Deck.md#constant-OPTION_KEYS
32
+ Jekyll::Slides::Deck.consumed_paths,Method,Jekyll/Slides/Deck.md#method-i-consumed_paths
33
+ Jekyll::Slides::Deck.content,Method,Jekyll/Slides/Deck.md#method-i-content
34
+ Jekyll::Slides::Deck.deck?,Method,Jekyll/Slides/Deck.md#method-i-deck-3F
35
+ Jekyll::Slides::Deck.initialize,Method,Jekyll/Slides/Deck.md#method-i-initialize
36
+ Jekyll::Slides::Deck.slide_paths,Method,Jekyll/Slides/Deck.md#method-i-slide_paths
23
37
  Jekyll::Slides::Assets::SiteAssetFile,Class,Jekyll/Slides/Assets/SiteAssetFile.md
24
38
  Jekyll::Slides::Assets::SiteAssetFile.write,Method,Jekyll/Slides/Assets/SiteAssetFile.md#method-i-write
25
39
  Jekyll::Slides::Layout,Class,Jekyll/Slides/Layout.md
26
40
  Jekyll::Slides::Layout.initialize,Method,Jekyll/Slides/Layout.md#method-i-initialize
27
41
  Jekyll::Slides::Renderer,Class,Jekyll/Slides/Renderer.md
28
42
  Jekyll::Slides::Renderer.convert,Method,Jekyll/Slides/Renderer.md#method-i-convert
43
+ Jekyll::Slides::SlideFile,Class,Jekyll/Slides/SlideFile.md
44
+ Jekyll::Slides::SlideFile.initialize,Method,Jekyll/Slides/SlideFile.md#method-i-initialize
45
+ Jekyll::Slides::SlideFile.slides,Method,Jekyll/Slides/SlideFile.md#method-i-slides
29
46
  Jekyll::Slides::Presentation,Class,Jekyll/Slides/Presentation.md
30
47
  Jekyll::Slides::Presentation.initialize,Method,Jekyll/Slides/Presentation.md#method-i-initialize
31
48
  Jekyll::Slides::Presentation.render,Method,Jekyll/Slides/Presentation.md#method-i-render
@@ -45,6 +62,7 @@ Jekyll::Slides::SlideMetadata.KEYS,Constant,Jekyll/Slides/SlideMetadata.md#const
45
62
  Jekyll::Slides::SlideMetadata.initialize,Method,Jekyll/Slides/SlideMetadata.md#method-i-initialize
46
63
  Jekyll::Slides::SlideMetadata.parse,Method,Jekyll/Slides/SlideMetadata.md#method-i-parse
47
64
  Jekyll::Slides::SlideMetadata.parse,Method,Jekyll/Slides/SlideMetadata.md#method-c-parse
65
+ Jekyll::Slides::SlideMetadata.split_comment,Method,Jekyll/Slides/SlideMetadata.md#method-c-split_comment
48
66
  Jekyll::Slides::SlideMetadata.background,Attribute,Jekyll/Slides/SlideMetadata.md#attribute-i-background
49
67
  Jekyll::Slides::SlideMetadata.content,Attribute,Jekyll/Slides/SlideMetadata.md#attribute-i-content
50
68
  Jekyll::Slides::SlideMetadata.custom_class,Attribute,Jekyll/Slides/SlideMetadata.md#attribute-i-custom_class
@@ -165,9 +165,8 @@ module Jekyll
165
165
  line_numbers = line_numbers?(attrs.fetch("line_numbers", "true"))
166
166
  classes = ["code-window", "size-#{size}"] + attrs[:classes].reject { |name| name == "editor" }
167
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>)
168
+ attributes = window_attributes(classes, attrs)
169
+ header = window_header(language: language)
171
170
  caption = attrs["title"] ? %(<figcaption>#{Support.escape(attrs['title'])}</figcaption>) : ""
172
171
  source = lines.map { |line, newline| line + newline }.join
173
172
  highlighted = @rouge_renderer.render_lines(source, language)
@@ -187,7 +186,7 @@ module Jekyll
187
186
  def render_terminal(lines, attrs)
188
187
  size = normalize_size(attrs["size"])
189
188
  classes = ["terminal-window", "size-#{size}"] + attrs[:classes].reject { |name| name == "terminal" }
190
- figure_attrs = html_attributes(class: classes.join(" "), id: attrs[:id])
189
+ figure_attrs = window_attributes(classes, attrs)
191
190
  caption = attrs["title"] ? %(<figcaption>#{Support.escape(attrs['title'])}</figcaption>) : ""
192
191
  body = lines.map do |line, newline|
193
192
  visible = Support.escape(line)
@@ -198,7 +197,36 @@ module Jekyll
198
197
  end
199
198
  %(<span class="tline">#{visible}#{Support.escape(newline)}</span>)
200
199
  end.join
201
- %(<figure#{figure_attrs}>#{caption}<pre><code>#{body}</code></pre></figure>)
200
+ %(<figure#{figure_attrs}>#{caption}#{window_header}<pre><code>#{body}</code></pre></figure>)
201
+ end
202
+
203
+ # The macOS-style title bar both window types share: traffic lights on
204
+ # the left, and for editors the language on the right. The centered title
205
+ # is the figure's own caption, which stays a direct child of the figure so
206
+ # the markup remains a valid figure/figcaption pair.
207
+ def window_header(language: nil)
208
+ header = +'<header class="code-window-header"><span class="code-window-controls" aria-hidden="true"><i></i><i></i><i></i></span>'
209
+ header << %(<span class="code-window-language">#{Support.escape(language)}</span>) if language
210
+ header << "</header>"
211
+ end
212
+
213
+ def window_attributes(classes, attrs)
214
+ html_attributes(
215
+ "class" => classes.join(" "),
216
+ "id" => attrs[:id],
217
+ "data-code-theme" => component_theme(attrs["theme"])
218
+ )
219
+ end
220
+
221
+ # A component may carry any deck theme, so one window can show a light
222
+ # terminal inside a dark deck. Returns nil to inherit the deck theme.
223
+ def component_theme(value)
224
+ return nil if value.nil? || value.to_s.empty?
225
+ return value.to_s if THEMES.include?(value.to_s)
226
+
227
+ Support.warn(@warning, "Unsupported component theme #{value.inspect}; expected one of #{THEMES.join(', ')}; " \
228
+ "using the deck theme")
229
+ nil
202
230
  end
203
231
 
204
232
  def line_range(value, max_line: nil)
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Slides
5
+ # Represents one folder deck: an entry file plus the slide files a
6
+ # directory holds. Reads slide files from disk and assembles them, with
7
+ # the entry body, into one Markdown string ready for the existing
8
+ # +PresentationParser+ pipeline. Knows nothing about Jekyll page objects.
9
+ class Deck
10
+ # Top-level presentation options an author might mistakenly nest under
11
+ # +slides:+ instead of setting directly in front matter.
12
+ OPTION_KEYS = %w[theme progress slide_numbers overview fullscreen aspect_ratio].freeze
13
+
14
+ # rubocop:disable Metrics/ParameterLists -- this exact keyword set is the deck's public
15
+ # construction contract; the Jekyll integration layer wires it verbatim.
16
+ def initialize(directory:, entry_path:, entry_body:, slides:, markdown_extensions:, warning: nil, read_options: {}, published: nil)
17
+ @directory = directory
18
+ @entry_path = File.expand_path(entry_path)
19
+ @entry_body = entry_body.to_s
20
+ @slides_option = slides
21
+ @markdown_extensions = Array(markdown_extensions).map { |ext| ext.to_s.downcase }
22
+ @warning = warning
23
+ @read_options = read_options
24
+ @published = published
25
+ @deck = resolve_deck?
26
+ end
27
+ # rubocop:enable Metrics/ParameterLists
28
+
29
+ def deck?
30
+ @deck
31
+ end
32
+
33
+ # The ordered slide file paths that make up +content+: the explicit
34
+ # list in list order when +slides:+ names one, otherwise every
35
+ # discovered file in natural order.
36
+ def slide_paths
37
+ @slide_paths ||= deck? ? resolve_slide_paths : []
38
+ end
39
+
40
+ # Every slide file the deck folder absorbs, so none is published on
41
+ # its own: every discovered direct child, plus any explicitly listed
42
+ # path (which may live in a subfolder). Order is not meaningful; this
43
+ # is a set for pruning, not the render order. Identical to
44
+ # +slide_paths+ when +slides:+ is +true+.
45
+ def consumed_paths
46
+ @consumed_paths ||= deck? ? resolve_consumed_paths : []
47
+ end
48
+
49
+ def content
50
+ parts = []
51
+ parts << @entry_body unless blank?(@entry_body)
52
+ parts.concat(slide_files.flat_map(&:slides))
53
+ parts.join("\n\n---\n\n")
54
+ end
55
+
56
+ private
57
+
58
+ def blank?(text)
59
+ text.to_s.strip.empty?
60
+ end
61
+
62
+ def slide_files
63
+ @slide_files ||= slide_paths.map { |path| SlideFile.new(path, warning: @warning, read_options: @read_options) }
64
+ end
65
+
66
+ def resolve_deck?
67
+ case @slides_option
68
+ when true, Array
69
+ true
70
+ when false, nil
71
+ false
72
+ when Hash
73
+ Support.warn(@warning, "Invalid slides value #{@slides_option.inspect}; set the top-level slides options " \
74
+ "(#{OPTION_KEYS.join(', ')}) directly instead of nesting them under slides:; " \
75
+ "treating as a single-file deck")
76
+ false
77
+ else
78
+ Support.warn(@warning, "Invalid slides value #{@slides_option.inspect}; expected true, false, or a list of " \
79
+ "file names; treating as a single-file deck")
80
+ false
81
+ end
82
+ end
83
+
84
+ def resolve_slide_paths
85
+ @slides_option == true ? discover_slide_paths : explicit_slide_paths
86
+ end
87
+
88
+ def resolve_consumed_paths
89
+ paths = discover_slide_paths
90
+ @slides_option.is_a?(Array) ? paths | explicit_slide_paths : paths
91
+ end
92
+
93
+ def discover_slide_paths
94
+ paths = Dir.children(@directory).each_with_object([]) do |basename, found|
95
+ next if basename.start_with?("_", ".")
96
+
97
+ path = File.join(@directory, basename)
98
+ next unless File.file?(path)
99
+ next unless markdown_extension?(basename)
100
+ next if File.expand_path(path) == @entry_path
101
+ next unless published?(path)
102
+
103
+ found << path
104
+ end
105
+ paths.sort_by { |path| Support.natural_sort_key(File.basename(path)) }
106
+ end
107
+
108
+ def markdown_extension?(basename)
109
+ @markdown_extensions.include?(File.extname(basename).delete_prefix(".").downcase)
110
+ end
111
+
112
+ # Memoized because both the rendering set and the pruning set ask for
113
+ # it, and resolve warns about missing or out-of-bounds names.
114
+ def explicit_slide_paths
115
+ @explicit_slide_paths ||= @slides_option.filter_map { |name| resolve(name) }
116
+ end
117
+
118
+ def resolve(name)
119
+ root = File.expand_path(@directory) + File::SEPARATOR
120
+ path = File.expand_path(File.join(@directory, name.to_s))
121
+ unless path.start_with?(root)
122
+ Support.warn(@warning, "Slide file #{name.inspect} escapes the deck directory; ignoring")
123
+ return nil
124
+ end
125
+ if path == @entry_path
126
+ Support.warn(@warning, "Slide file #{name.inspect} is the deck entry itself; ignoring")
127
+ return nil
128
+ end
129
+ unless File.file?(path)
130
+ Support.warn(@warning, "Slide file #{name.inspect} does not exist; ignoring")
131
+ return nil
132
+ end
133
+ unless published?(path)
134
+ Support.warn(@warning, "Slide file #{name.inspect} is excluded or not published; ignoring")
135
+ return nil
136
+ end
137
+ path
138
+ end
139
+
140
+ # Discovery reads the folder straight from disk, which bypasses the
141
+ # filtering Jekyll already applied: files its exclude: config drops and
142
+ # files whose front matter sets published: false are never read. Absorbing
143
+ # one would publish content the author withheld, inside the deck.
144
+ def published?(path)
145
+ return true unless @published
146
+
147
+ @published.call(File.expand_path(path))
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+
6
+ module Jekyll
7
+ module Slides
8
+ # Finds folder-deck entries across a site's pages and output-collection
9
+ # documents, assembles each into one Markdown string via +Deck+, and
10
+ # prunes the slide files it consumed so they never appear in the built
11
+ # site. Runs once per site, inside the +:site, :post_read+ hook, before
12
+ # Liquid rendering and before +JekyllIntegration.process+ computes
13
+ # +slides_count+.
14
+ module DeckAssembler
15
+ class << self
16
+ # Assembles every folder deck the site's pages and output-collection
17
+ # documents opt into. Mutates +site.pages+, each collection's
18
+ # +docs+, and +site.static_files+ in place to remove consumed slide
19
+ # files.
20
+ def assemble(site)
21
+ published = published_paths(site)
22
+ entry_candidates(site).each { |entry| assemble_entry(site, entry, published) }
23
+ end
24
+
25
+ private
26
+
27
+ # Every source path Jekyll actually read. A file its exclude: config
28
+ # drops, or one whose front matter sets published: false, is absent
29
+ # here, and a deck must not absorb it. Snapshotted before any pruning,
30
+ # since pruning removes the very items this is built from.
31
+ def published_paths(site)
32
+ items = site.pages + site.collections.values.flat_map(&:docs) + site.static_files
33
+ items.to_set { |item| absolute_path(site, item) }
34
+ end
35
+
36
+ def entry_candidates(site)
37
+ collection_documents = site.collections.values.flat_map(&:docs)
38
+ site.pages + collection_documents
39
+ end
40
+
41
+ def assemble_entry(site, entry, published)
42
+ return unless presentation_entry?(entry)
43
+
44
+ slides_option = entry.data["slides"]
45
+ return unless slides_option
46
+
47
+ entry_path = absolute_path(site, entry)
48
+ unless index_entry?(entry_path, markdown_extensions(site))
49
+ warn_entry(site, entry_path, "the entry of a deck folder must be named index.<ext>; leaving as a single-file deck")
50
+ return
51
+ end
52
+
53
+ finish_assembly(site, entry, entry_path, build_deck(site, entry, entry_path, published))
54
+ end
55
+
56
+ def finish_assembly(site, entry, entry_path, deck)
57
+ return unless deck.deck?
58
+
59
+ entry.content = deck.content
60
+ # Pruning does not depend on anything being selected. Opting in
61
+ # absorbs the whole folder, so a file left out of an explicit
62
+ # running order is cut from the deck rather than published alone.
63
+ prune(site, deck.consumed_paths)
64
+ register_dependencies(site, entry_path, deck.slide_paths)
65
+ log_deck(site, entry_path, deck.slide_paths) unless deck.slide_paths.empty?
66
+ end
67
+
68
+ def presentation_entry?(entry)
69
+ entry.respond_to?(:data) && entry.data.is_a?(Hash) && entry.data["layout"].to_s == "presentation"
70
+ end
71
+
72
+ def markdown_extensions(site)
73
+ site.config["markdown_ext"].to_s.split(",")
74
+ end
75
+
76
+ def index_entry?(path, extensions)
77
+ basename = File.basename(path)
78
+ extname = File.extname(basename)
79
+ return false if extname.empty?
80
+
81
+ base = basename.delete_suffix(extname)
82
+ ext = extname.delete_prefix(".").downcase
83
+ base.casecmp("index").zero? && extensions.any? { |candidate| candidate.to_s.downcase == ext }
84
+ end
85
+
86
+ def build_deck(site, entry, entry_path, published)
87
+ Deck.new(
88
+ directory: File.dirname(entry_path),
89
+ entry_path: entry_path,
90
+ entry_body: entry.content,
91
+ slides: entry.data["slides"],
92
+ markdown_extensions: markdown_extensions(site),
93
+ warning: ->(message) { warn_entry(site, entry_path, message) },
94
+ read_options: Jekyll::Utils.merged_file_read_opts(site, {}),
95
+ published: ->(path) { published.include?(path) }
96
+ )
97
+ end
98
+
99
+ # Removes every consumed slide file from site.pages, each
100
+ # collection's docs, and site.static_files, matching on absolute
101
+ # source path. A Markdown file with no front matter is read by
102
+ # Jekyll as a StaticFile rather than a Page, so pruning static
103
+ # files is required, not optional.
104
+ def prune(site, paths)
105
+ return if paths.empty?
106
+
107
+ consumed = paths.map { |path| File.expand_path(path) }
108
+ site.pages.reject! { |page| consumed.include?(absolute_path(site, page)) }
109
+ site.collections.each_value do |collection|
110
+ collection.docs.reject! { |doc| consumed.include?(absolute_path(site, doc)) }
111
+ end
112
+ site.static_files.reject! { |file| consumed.include?(absolute_path(site, file)) }
113
+ end
114
+
115
+ def register_dependencies(site, entry_path, slide_paths)
116
+ return unless site.respond_to?(:regenerator) && site.regenerator.respond_to?(:add_dependency)
117
+
118
+ slide_paths.each { |path| site.regenerator.add_dependency(entry_path, path) }
119
+ end
120
+
121
+ def log_deck(site, entry_path, slide_paths)
122
+ basenames = slide_paths.map { |path| File.basename(path) }
123
+ Jekyll.logger.info(
124
+ "Jekyll Slides:",
125
+ "#{relative_path(site, entry_path)}: #{slide_paths.length} slide files (#{basenames.join(', ')})"
126
+ )
127
+ end
128
+
129
+ def warn_entry(site, entry_path, message)
130
+ Jekyll.logger.warn("Jekyll Slides:", "#{relative_path(site, entry_path)}: #{message}")
131
+ end
132
+
133
+ # Jekyll::Page#path is relative to the source; Jekyll::Document#path
134
+ # and Jekyll::StaticFile#path are already absolute. expand_path
135
+ # handles all three, since it ignores the base for a path that is
136
+ # already absolute.
137
+ def absolute_path(site, item)
138
+ File.expand_path(item.path.to_s, site.source)
139
+ end
140
+
141
+ def relative_path(site, path)
142
+ Pathname.new(path).relative_path_from(Pathname.new(site.source)).to_s
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+
6
+ module Jekyll
7
+ module Slides
8
+ # Splits a leading YAML front matter block off a Markdown source string.
9
+ module FrontMatter
10
+ module_function
11
+
12
+ # Returns [values, body]. +values+ is a Hash for a well-formed leading
13
+ # +---+ block; otherwise +values+ is {} and +body+ is +source+ unchanged.
14
+ def split(source)
15
+ source = source.to_s
16
+ return [{}, source] unless source.start_with?("---\n", "---\r\n")
17
+
18
+ lines = source.lines
19
+ closing = lines[1..]&.index { |line| line.chomp == "---" }
20
+ return [{}, source] unless closing
21
+
22
+ closing += 1
23
+ values = parse(lines[1...closing].join)
24
+ return [{}, source] unless values
25
+
26
+ [values, lines[(closing + 1)..]&.join.to_s]
27
+ end
28
+
29
+ def parse(text)
30
+ return {} if text.strip.empty?
31
+
32
+ parsed = YAML.safe_load(text, permitted_classes: [Date, Time, DateTime])
33
+ parsed.is_a?(Hash) ? parsed : nil
34
+ rescue Psych::Exception
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -6,14 +6,13 @@ module Jekyll
6
6
  module Slides
7
7
  module JekyllIntegration
8
8
  DEFAULTS = {
9
- "theme" => "midnight",
9
+ "theme" => DEFAULT_THEME,
10
10
  "aspect_ratio" => "16:9",
11
11
  "progress" => true,
12
12
  "slide_numbers" => true,
13
13
  "overview" => true,
14
14
  "fullscreen" => true
15
15
  }.freeze
16
- THEMES = %w[minimal-light minimal-dark midnight ruby].freeze
17
16
  BOOLEAN_OPTIONS = %w[progress slide_numbers overview fullscreen].freeze
18
17
  OPTION_KEYS = DEFAULTS.keys.freeze
19
18
 
@@ -29,6 +28,7 @@ module Jekyll
29
28
 
30
29
  def process_site(site)
31
30
  install_resources(site)
31
+ DeckAssembler.assemble(site)
32
32
  presentation_documents(site).each { |document| process(document) }
33
33
  end
34
34
 
@@ -84,7 +84,7 @@ module Jekyll
84
84
  theme = value.to_s
85
85
  return theme if THEMES.include?(theme)
86
86
 
87
- warning("Invalid theme #{value.inspect}; falling back to midnight")
87
+ warning("Invalid theme #{value.inspect}; expected one of #{THEMES.join(', ')}; falling back to #{DEFAULT_THEME}")
88
88
  DEFAULTS[key]
89
89
  when "aspect_ratio"
90
90
  return value if value == DEFAULTS[key]
@@ -4,6 +4,20 @@ module Jekyll
4
4
  module Slides
5
5
  class Renderer < Jekyll::Renderer
6
6
  def convert(content)
7
+ # Liquid has already run by the time this fires (Jekyll::Renderer#run
8
+ # converts before place_in_layouts), so this is the true slide
9
+ # count, including slides a {% for %} loop generated.
10
+ #
11
+ # document.data alone is not enough: a Document reaches the layout
12
+ # through a live DocumentDrop that reads document.data on every
13
+ # access, so writing it here is enough. A Page reaches the layout
14
+ # through a plain Hash snapshot that Jekyll::Renderer#assign_pages!
15
+ # already built and stashed in payload["page"] before convert runs,
16
+ # so a Page's layout would keep seeing the old, pre-Liquid count
17
+ # unless payload["page"] itself is updated too.
18
+ count = PresentationParser.new(content).parse.length
19
+ document.data["slides_count"] = count
20
+ payload["page"]["slides_count"] = count
7
21
  Presentation.new(
8
22
  content,
9
23
  warning: method(:warning),