presently 0.20.1 → 0.22.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44f63b8a685dbff7a9d834076b6f3df8e68cb512376a97a4953dd37ebad18b3d
4
- data.tar.gz: ec2b552b9f5b72952bfed56f2aecdc55743ed18128f67cc391e52d9ec0064748
3
+ metadata.gz: bceb99c19c4563f60e51fe3e17cce836c7176ebd9b695919af5737ccc87fd4ca
4
+ data.tar.gz: 102b2bbab9019d17c5aadb85c3f40044c1e94bdb2e587482c246d0c184f5fa36
5
5
  SHA512:
6
- metadata.gz: 5a1e4f78672691afb74bb33b7a1f464dfec183d1b4e691dd3a159da961d3d1a5d7950745614880bc6cef8d4a2b9c0e10f66164e079abd357f5ee3a5aefa73735
7
- data.tar.gz: aef5cd14d684f9dfa245bcac914d587d4889bd1a07661513e1e1cc87697c6e18ea11fd7e9ca151664af30c6bc234140ea173426da30cebfd57aeb918cba24bac
6
+ metadata.gz: 8edf0ac3857670ce3afe65522bdf9d560b2f909fb68a85a43161200047f6a8fe588be5bef3ee685ca007a927d47999b5afc7c0db65ae43896c78b018ec347ffc
7
+ data.tar.gz: b8cca4183e4494e9fdd2923e429bc3bdca75310f127eba5474f410214c90f2cd5a0b2d89bb03df1e887673d30b81ebb45320b3af5853b73f137e6fbe6c2554f7
checksums.yaml.gz.sig CHANGED
Binary file
@@ -82,7 +82,7 @@ def rehearse(slides_root: "slides", speaker: nil, from: nil, to: nil, resume: fa
82
82
  offset = prior_records.length
83
83
  slides.each_with_index do |slide, index|
84
84
  relative_path = slide.path
85
- headline = extract_headline(slide)
85
+ headline = slide.title
86
86
  absolute = offset + index + 1
87
87
  total = offset + slides.length
88
88
 
@@ -199,14 +199,6 @@ private
199
199
 
200
200
  UPDATE_THRESHOLD = 1.0
201
201
 
202
- def extract_headline(slide)
203
- body = slide.content["body"]
204
- return slide.title unless body && !body.empty?
205
-
206
- first_line = body.to_commonmark.strip.lines.first.to_s.strip
207
- first_line.empty? ? slide.title : first_line
208
- end
209
-
210
202
  def print_report(records, log_path)
211
203
  planned_total = records.sum{|record| record[:planned]}
212
204
  actual_total = records.sum{|record| record[:actual]}
@@ -192,7 +192,7 @@ Use semantic classes and CSS custom properties so that meaning remains separate
192
192
  .diagram-node {
193
193
  --node-color: var(--accent);
194
194
  border: 0.08em solid var(--node-color);
195
- background: color-mix(in srgb, var(--node-color) 10%, var(--slide-bg));
195
+ background: color-mix(in srgb, var(--node-color) 10%, var(--slide-background));
196
196
  }
197
197
 
198
198
  .diagram-node[data-tone="storage"] {
@@ -60,11 +60,7 @@ template: title
60
60
  duration: 30
61
61
  ---
62
62
 
63
- # Title
64
-
65
- Welcome to My Talk
66
-
67
- # Subtitle
63
+ # Welcome to My Talk
68
64
 
69
65
  A presentation built with Presently
70
66
 
@@ -76,7 +72,7 @@ These are presenter notes — only visible in the presenter view.
76
72
  Each slide has three parts:
77
73
 
78
74
  1. **YAML frontmatter** between `---` markers at the top, specifying the template, duration, and other metadata.
79
- 2. **Content** with Markdown headings that become named sections for the template.
75
+ 2. **Content** with an optional H1 title and any template placeholders written as H2 sections.
80
76
  3. **Presenter notes** after a `---` separator in the body (optional).
81
77
 
82
78
  ### Styling Slides
@@ -196,7 +192,7 @@ Templates define the visual layout of each slide. Select a template using the `t
196
192
 
197
193
  ### Default
198
194
 
199
- A general-purpose content slide. All content without a heading goes into the `body` section.
195
+ A general-purpose content slide. An H1 becomes the slide title and the remaining document becomes its body.
200
196
 
201
197
  ``` markdown
202
198
  ---
@@ -204,6 +200,8 @@ template: default
204
200
  duration: 60
205
201
  ---
206
202
 
203
+ # Key points
204
+
207
205
  - First point
208
206
  - Second point
209
207
  - Third point
@@ -211,7 +209,7 @@ duration: 60
211
209
 
212
210
  ### Title
213
211
 
214
- A large title with a subtitle, centered on the slide.
212
+ A large title with a short body, centered on the slide.
215
213
 
216
214
  ``` markdown
217
215
  ---
@@ -219,18 +217,14 @@ template: title
219
217
  duration: 30
220
218
  ---
221
219
 
222
- # Title
223
-
224
- My Presentation Title
225
-
226
- # Subtitle
220
+ # My Presentation Title
227
221
 
228
222
  A subtitle or tagline
229
223
  ```
230
224
 
231
225
  ### Section
232
226
 
233
- A section divider slide with a large heading and accent background.
227
+ A section divider slide with a large heading, optional supporting body, and accent background.
234
228
 
235
229
  ``` markdown
236
230
  ---
@@ -238,9 +232,9 @@ template: section
238
232
  duration: 15
239
233
  ---
240
234
 
241
- # Heading
235
+ # Part Two
242
236
 
243
- Part Two
237
+ Architecture and design
244
238
  ```
245
239
 
246
240
  ### Two Column
@@ -253,14 +247,18 @@ template: two_column
253
247
  duration: 90
254
248
  ---
255
249
 
256
- # Left
250
+ # Client and server responsibilities
251
+
252
+ The application is split across two cooperating environments.
253
+
254
+ ## Left
257
255
 
258
256
  **Server Side**
259
257
 
260
258
  - Ruby + Lively
261
259
  - WebSocket connections
262
260
 
263
- # Right
261
+ ## Right
264
262
 
265
263
  **Client Side**
266
264
 
@@ -277,9 +275,10 @@ A syntax-highlighted code slide with optional focus regions for code walkthrough
277
275
  template: code
278
276
  duration: 60
279
277
  focus: 2-8
280
- title: Constructor
281
278
  ---
282
279
 
280
+ # Constructor
281
+
283
282
  ```ruby
284
283
  class Presentation
285
284
  def initialize
@@ -298,7 +297,7 @@ Create animated walkthroughs by using multiple slides with the same code but dif
298
297
 
299
298
  ### Statement
300
299
 
301
- A prominent statement or quote, centered on the slide. Supports an optional `# Translation` section.
300
+ A prominent statement or quote, centered on the slide. Supports an optional `## Translation` placeholder.
302
301
 
303
302
  ``` markdown
304
303
  ---
@@ -308,14 +307,14 @@ duration: 30
308
307
 
309
308
  The best way to predict the future is to create it.
310
309
 
311
- # Translation
310
+ ## Translation
312
311
 
313
312
  未来を予測する最善の方法は、それを創ることである。
314
313
  ```
315
314
 
316
315
  ### Translations
317
316
 
318
- All templates support an optional `# Translation` section. When present, the translation is displayed below the main content in a lighter style. This works with `title`, `section`, `statement`, and `image` templates.
317
+ Templates can extract an optional `## Translation` section and position it independently from the main document. Every standard template displays it separately in a lighter style.
319
318
 
320
319
  ### Image
321
320
 
@@ -329,7 +328,7 @@ duration: 30
329
328
 
330
329
  ![Architecture diagram](/images/architecture.png)
331
330
 
332
- # Caption
331
+ ## Caption
333
332
 
334
333
  System architecture overview
335
334
  ```
@@ -344,11 +343,7 @@ template: diagram
344
343
  duration: 60
345
344
  ---
346
345
 
347
- # Title
348
-
349
- Request lifecycle
350
-
351
- # Body
346
+ # Request lifecycle
352
347
 
353
348
  <div style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 2em; width: 80%;">
354
349
  <div>Browser</div>
@@ -420,7 +415,20 @@ service "presently" do
420
415
  end
421
416
  ```
422
417
 
423
- Templates receive a {ruby Presently::TemplateScope} with access to `self.slide` (the {ruby Presently::Slide} instance) and `self.section(name)` for retrieving named content sections.
418
+ Templates receive a {ruby Presently::TemplateScope}. `self.slide_header` renders the semantic H1 title and optional section metadata, while `self.document` renders the remaining slide body. `self.extract(name)` removes an H2 placeholder with that exact heading text from the body and returns its rendered content. Extract placeholders before rendering the remaining document:
419
+
420
+ ``` xrb
421
+ <?r translation = self.extract("Translation") ?>
422
+ #{self.slide_header}
423
+ <div class="slide-body">
424
+ #{self.document}
425
+ </div>
426
+ <?r if translation ?>
427
+ <div class="slide-translation">#{translation}</div>
428
+ <?r end ?>
429
+ ```
430
+
431
+ Extraction stops at the next heading of the same or a higher level, so lower-level headings remain inside the extracted fragment. Placeholder names are case-sensitive and must match the heading text exactly. Only placeholders requested by the template are removed; other headings remain ordinary document content.
424
432
 
425
433
  ## Customizing the Application
426
434
 
@@ -72,10 +72,11 @@ module Presently
72
72
 
73
73
  # Create a Presently page with the presentation-specific stylesheets.
74
74
  # @parameter view [Live::View] The root view for the page.
75
+ # @parameter interface [Symbol | Nil] The interface-specific stylesheet to load.
75
76
  # @returns [Page] The presentation page.
76
- def make_page(view)
77
+ def make_page(view, interface: nil)
77
78
  stylesheets = controller.presentation.stylesheets.map(&:url)
78
- Page.new(title: title, body: view, stylesheets: stylesheets)
79
+ Page.new(title: title, body: view, interface: interface, stylesheets: stylesheets)
79
80
  end
80
81
 
81
82
  # Add Presently's application routes.
@@ -83,12 +84,12 @@ module Presently
83
84
  def configure_routes(router)
84
85
  router.get("/") do
85
86
  body = resolver.root(HomeView)
86
- Page.new(title: title, body: body).call
87
+ Page.new(title: title, body: body, interface: :home).call
87
88
  end
88
89
 
89
- router.get("/display"){make_page(resolver.root(DisplayView)).call}
90
- router.get("/presenter"){make_page(resolver.root(PresenterView)).call}
91
- router.get("/record"){make_page(resolver.root(RecordingView)).call}
90
+ router.get("/display"){make_page(resolver.root(DisplayView), interface: :display).call}
91
+ router.get("/presenter"){make_page(resolver.root(PresenterView), interface: :presenter).call}
92
+ router.get("/record"){make_page(resolver.root(RecordingView), interface: :recorder).call}
92
93
 
93
94
  router.route("/recordings", methods: ["GET", "HEAD", "PUT"]) do |request|
94
95
  handle_recording(request, request_parameters(request))
@@ -4,6 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
 
6
6
  <link rel="stylesheet" href="/_static/index.css" type="text/css" />
7
+ <link rel="stylesheet" href="/_static/slides.css" type="text/css" />
8
+ <link rel="stylesheet" href="/_static/export.css" type="text/css" />
7
9
  <link rel="stylesheet" href="/_static/custom.css" type="text/css" />
8
10
  <link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" />
9
11
  <?r self.stylesheets.each do |stylesheet| ?>
@@ -13,8 +13,17 @@ module Presently
13
13
  class Page < Lively::Page
14
14
  ICON = "/_static/icon.png"
15
15
  STYLESHEETS = [
16
- {href: "/_static/site.css", media: "screen"}.freeze,
17
16
  {href: "/_static/index.css", media: "screen"}.freeze,
17
+ ].freeze
18
+ SLIDES_STYLESHEET = {href: "/_static/slides.css", media: "screen"}.freeze
19
+ INTERFACE_STYLESHEETS = {
20
+ home: [{href: "/_static/home.css", media: "screen"}.freeze].freeze,
21
+ display: [SLIDES_STYLESHEET, {href: "/_static/display.css", media: "screen"}.freeze].freeze,
22
+ presenter: [SLIDES_STYLESHEET, {href: "/_static/presenter.css", media: "screen"}.freeze].freeze,
23
+ recorder: [SLIDES_STYLESHEET, {href: "/_static/recorder.css", media: "screen"}.freeze].freeze,
24
+ }.freeze
25
+ DEFAULT_INTERFACE_STYLESHEETS = INTERFACE_STYLESHEETS.values.flatten.uniq.freeze
26
+ TRAILING_STYLESHEETS = [
18
27
  {href: "/_static/custom.css", media: "screen"}.freeze,
19
28
  {href: "/_components/@socketry/syntax/themes/base/syntax.css", media: "screen"}.freeze,
20
29
  ].freeze
@@ -31,13 +40,16 @@ module Presently
31
40
  # Initialize a new page.
32
41
  # @parameter title [String] The page title.
33
42
  # @parameter body [Live::View | Nil] The Live view to embed in the page.
43
+ # @parameter interface [Symbol | Nil] The interface-specific stylesheets to load, or all interface stylesheets by default for compatibility.
34
44
  # @parameter stylesheets [Array(String | Hash)] Presentation-specific stylesheets.
35
- def initialize(title: "Presently", body: nil, stylesheets: [])
45
+ def initialize(title: "Presently", body: nil, interface: nil, stylesheets: [])
46
+ interface_stylesheets = interface ? INTERFACE_STYLESHEETS.fetch(interface) : DEFAULT_INTERFACE_STYLESHEETS
47
+
36
48
  super(
37
49
  title: title,
38
50
  body: body,
39
51
  icon: ICON,
40
- stylesheets: STYLESHEETS + stylesheets,
52
+ stylesheets: STYLESHEETS + interface_stylesheets + TRAILING_STYLESHEETS + stylesheets,
41
53
  imports: IMPORTS,
42
54
  modules: MODULES,
43
55
  )
@@ -7,8 +7,9 @@
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
8
8
 
9
9
  <link rel="icon" type="image/png" href="/_static/icon.png" />
10
- <link rel="stylesheet" href="/_static/site.css" type="text/css" media="screen" />
11
10
  <link rel="stylesheet" href="/_static/index.css" type="text/css" media="screen" />
11
+ <link rel="stylesheet" href="/_static/slides.css" type="text/css" media="screen" />
12
+ <link rel="stylesheet" href="/_static/playback.css" type="text/css" media="screen" />
12
13
  <link rel="stylesheet" href="/_static/custom.css" type="text/css" media="screen" />
13
14
  <link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" media="screen" />
14
15
  <?r self.stylesheets.each do |stylesheet| ?>
@@ -14,13 +14,13 @@ require_relative "stylesheet"
14
14
  module Presently
15
15
  # A single slide parsed from a Markdown file.
16
16
  #
17
- # Each slide has YAML front_matter for metadata (template, duration, focus), content sections
18
- # split by Markdown headings, and optional presenter notes separated by `---`.
17
+ # Each slide has YAML front_matter for metadata (template, duration, focus), a Markdown
18
+ # document, and optional presenter notes separated by `---`.
19
19
  class Slide
20
20
  # A fragment of a Markly AST document.
21
21
  #
22
22
  # Wraps a `Markly::Node` of type `:document` and provides rendering helpers.
23
- # Used for both content sections and presenter notes so callers can choose
23
+ # Used for both slide documents and presenter notes so callers can choose
24
24
  # their output format without the parser pre-committing to one.
25
25
  class Fragment
26
26
  # Markly extensions enabled for all slide Markdown rendering.
@@ -35,6 +35,13 @@ module Presently
35
35
  # @attribute [Markly::Node] The underlying AST document node.
36
36
  attr :node
37
37
 
38
+ # Duplicate this fragment and its underlying AST.
39
+ # @returns [Fragment] An independently mutable copy.
40
+ def initialize_copy(other)
41
+ super
42
+ @node = other.node.dup
43
+ end
44
+
38
45
  # Whether the fragment has no content.
39
46
  # @returns [Boolean]
40
47
  def empty?
@@ -47,6 +54,51 @@ module Presently
47
54
  Markly::Renderer::HTML.new(flags: Markly::UNSAFE, extensions: EXTENSIONS).render(@node)
48
55
  end
49
56
 
57
+ # Extract the content belonging to a named H2 placeholder.
58
+ #
59
+ # The matching heading itself is removed. Following nodes are extracted up
60
+ # to the next heading of the same or a higher level. Lower-level headings
61
+ # remain part of the extracted content.
62
+ # @parameter name [String] The heading name to extract.
63
+ # @returns [Fragment | Nil] The extracted content, or `nil` when not found.
64
+ def extract(name)
65
+ heading = find_heading(name)
66
+ return unless heading
67
+
68
+ level = heading.header_level
69
+ fragment = Markly::Node.new(:document)
70
+ node = heading.next
71
+ heading.delete
72
+
73
+ while node
74
+ next_node = node.next
75
+ break if node.type == :header && node.header_level <= level
76
+
77
+ fragment.append_child(node)
78
+ node = next_node
79
+ end
80
+
81
+ Fragment.new(fragment)
82
+ end
83
+
84
+ # Extract the first heading at the given level, retaining the heading node.
85
+ # @parameter level [Integer] The Markdown heading level.
86
+ # @returns [Fragment | Nil] A fragment containing the extracted heading.
87
+ def extract_heading(level = 1)
88
+ heading = nil
89
+ @node.each do |node|
90
+ if node.type == :header && node.header_level == level
91
+ heading = node
92
+ break
93
+ end
94
+ end
95
+ return unless heading
96
+
97
+ fragment = Markly::Node.new(:document)
98
+ fragment.append_child(heading)
99
+ Fragment.new(fragment)
100
+ end
101
+
50
102
  # Render the fragment back to CommonMark Markdown.
51
103
  # @returns [String] The CommonMark source.
52
104
  def to_commonmark
@@ -54,6 +106,27 @@ module Presently
54
106
  end
55
107
 
56
108
  alias to_s to_commonmark
109
+
110
+ # Return the plain text of the first heading at the given level.
111
+ # @parameter level [Integer] The Markdown heading level.
112
+ # @returns [String | Nil] The heading text, or `nil` when not found.
113
+ def heading_text(level = 1)
114
+ @node.each do |node|
115
+ if node.type == :header && node.header_level == level
116
+ return node.dup.extract_children.to_plaintext
117
+ end
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def find_heading(name)
124
+ @node.each do |node|
125
+ return node if node.type == :header && node.header_level == 2 && node.dup.extract_children.to_plaintext == name
126
+ end
127
+
128
+ nil
129
+ end
57
130
  end
58
131
 
59
132
  # Parses a Markdown slide file into structured data for {Slide}.
@@ -61,8 +134,17 @@ module Presently
61
134
  # Handles YAML front_matter extraction, presenter note separation, and
62
135
  # Markdown AST construction via Markly.
63
136
  module Parser
137
+ FLAGS = Markly::UNSAFE | Markly::FRONT_MATTER | Markly::INLINE_CODE_INFO | Markly::HTML_BLOCK_BLANK_LINES
138
+
64
139
  module_function
65
140
 
141
+ # Parse slide Markdown with the standard flags and extensions.
142
+ # @parameter source [String] The Markdown source.
143
+ # @returns [Markly::Node] The parsed document.
144
+ def parse(source)
145
+ Markly.parse(source, flags: FLAGS, extensions: Fragment::EXTENSIONS)
146
+ end
147
+
66
148
  # Parse the file and return a {Slide}.
67
149
  # @parameter presentation [Presentation] The presentation which owns the slide.
68
150
  # @parameter path [String] The slide path relative to the presentation root.
@@ -72,7 +154,7 @@ module Presently
72
154
  raw = File.read(source_path)
73
155
 
74
156
  # Parse once, with native front matter support.
75
- document = Markly.parse(raw, flags: Markly::UNSAFE | Markly::FRONT_MATTER | Markly::INLINE_CODE_INFO | Markly::HTML_BLOCK_BLANK_LINES, extensions: Fragment::EXTENSIONS)
157
+ document = parse(raw)
76
158
 
77
159
  expand_includes!(document, File.dirname(source_path), presentation.root)
78
160
  rewrite_image_urls!(document, source_path, presentation.root)
@@ -109,14 +191,12 @@ module Presently
109
191
  script_node.delete
110
192
  end
111
193
 
112
- content = parse_sections(document)
113
194
  notes = Fragment.new(notes_node)
114
195
  else
115
- content = parse_sections(document)
116
196
  notes = nil
117
197
  end
118
198
 
119
- Slide.new(presentation, path, front_matter: front_matter, content: content, notes: notes, scripts: scripts)
199
+ Slide.new(presentation, path, front_matter: front_matter, document: Fragment.new(document), notes: notes, scripts: scripts)
120
200
  end
121
201
 
122
202
  # Extract reusable setup scripts from the expanded slide document.
@@ -169,7 +249,7 @@ module Presently
169
249
  to_replace.each do |paragraph, relative_path|
170
250
  included_path = File.expand_path(relative_path, base_dir)
171
251
  included_raw = File.read(included_path)
172
- included_document = Markly.parse(included_raw, flags: Markly::UNSAFE | Markly::FRONT_MATTER | Markly::INLINE_CODE_INFO | Markly::HTML_BLOCK_BLANK_LINES, extensions: Fragment::EXTENSIONS)
252
+ included_document = parse(included_raw)
173
253
 
174
254
  # Strip front matter from included file if present.
175
255
  front_matter_node = included_document.first_child
@@ -211,31 +291,6 @@ module Presently
211
291
  end
212
292
  end
213
293
 
214
- # Parse a Markly document into content sections based on top-level headings.
215
- #
216
- # Each heading becomes a named key; content before the first heading is
217
- # collected under `"body"`. Each value is a {Fragment} wrapping a document node.
218
- # @parameter document [Markly::Node] The document to parse.
219
- # @returns [Hash(String, Fragment)] Sections keyed by heading name.
220
- def parse_sections(document)
221
- sections = {}
222
- current_key = "body"
223
- current_node = Markly::Node.new(:document)
224
-
225
- document.each do |node|
226
- if node.type == :header
227
- sections[current_key] = Fragment.new(current_node) unless current_node.first_child.nil?
228
- current_key = node.to_plaintext.strip.downcase.gsub(/\s+/, "_")
229
- current_node = Markly::Node.new(:document)
230
- else
231
- current_node.append_child(node.dup)
232
- end
233
- end
234
-
235
- sections[current_key] = Fragment.new(current_node) unless current_node.first_child.nil?
236
-
237
- sections
238
- end
239
294
  end
240
295
 
241
296
  # Load and parse a slide from a Markdown file.
@@ -250,18 +305,16 @@ module Presently
250
305
  # @parameter presentation [Presentation] The presentation which owns the slide.
251
306
  # @parameter path [String] The slide path relative to the presentation root.
252
307
  # @parameter front_matter [Hash | Nil] The parsed YAML front_matter.
253
- # @parameter content [Hash(String, Fragment)] Content sections keyed by heading name.
308
+ # @parameter document [Fragment | Nil] The complete slide document.
254
309
  # @parameter notes [Fragment | Nil] The presenter notes as a Markly AST fragment.
255
310
  # @parameter scripts [Array(String)] JavaScript sources to execute after the slide renders.
256
- # @parameter script [String | Nil] A single JavaScript source retained for compatibility.
257
- def initialize(presentation, path, front_matter: nil, content: {}, notes: nil, scripts: [], script: nil)
311
+ def initialize(presentation, path, front_matter: nil, document: nil, notes: nil, scripts: [])
258
312
  @presentation = presentation
259
313
  @path = path
260
314
  @front_matter = front_matter
261
- @content = content
315
+ @document = document || Fragment.new(Markly::Node.new(:document))
262
316
  @notes = notes
263
317
  @scripts = scripts.dup
264
- @scripts << script if script
265
318
  end
266
319
 
267
320
  # @attribute [Presentation] The presentation which owns the slide.
@@ -279,8 +332,8 @@ module Presently
279
332
  # @attribute [Hash | Nil] The parsed YAML front_matter.
280
333
  attr :front_matter
281
334
 
282
- # @attribute [Hash(String, Fragment)] The content sections keyed by heading name.
283
- attr :content
335
+ # @attribute [Fragment] The complete slide document.
336
+ attr :document
284
337
 
285
338
  # @attribute [Fragment | Nil] The presenter notes as a Markly AST fragment.
286
339
  attr :notes
@@ -288,14 +341,6 @@ module Presently
288
341
  # @attribute [Array(String)] JavaScript sources to execute after the slide renders on the display.
289
342
  attr :scripts
290
343
 
291
- # All JavaScript sources combined into a single string.
292
- # @returns [String | Nil] The combined JavaScript, or `nil` when the slide has no scripts.
293
- def script
294
- return if @scripts.empty?
295
-
296
- @scripts.join("\n\n")
297
- end
298
-
299
344
  # The template to use for rendering this slide.
300
345
  # @returns [String] The template name from front_matter, or `"default"`.
301
346
  def template
@@ -309,9 +354,15 @@ module Presently
309
354
  end
310
355
 
311
356
  # The title of this slide.
312
- # @returns [String] The title from front_matter, or the filename without extension.
357
+ # @returns [String] The H1 text, front matter title, or filename without extension.
313
358
  def title
314
- @front_matter&.fetch("title", File.basename(@path, ".md")) || File.basename(@path, ".md")
359
+ @document.heading_text(1) || @front_matter&.fetch("title", nil) || File.basename(@path, ".md")
360
+ end
361
+
362
+ # The section name for this slide.
363
+ # @returns [String | Nil] The section from front matter, or `nil` when it was not specified.
364
+ def section
365
+ @front_matter&.fetch("section", nil)
315
366
  end
316
367
 
317
368
  # Whether this slide should be skipped in the presentation.
@@ -64,36 +64,75 @@ module Presently
64
64
 
65
65
  # Provides the scope for XRB template rendering.
66
66
  #
67
- # Templates access slide content via `self.section(name)` and slide metadata via `self.slide`.
67
+ # Templates selectively extract placeholders with `self.extract(name)` and render the remainder
68
+ # with `self.document`.
68
69
  class TemplateScope
69
70
  # Initialize a new template scope for the given slide.
70
71
  # @parameter slide [Slide] The slide being rendered.
71
72
  def initialize(slide)
72
73
  @slide = slide
74
+ @document = slide.document.dup
75
+ @heading = @document.extract_heading(1)
76
+ @extracted = {}
73
77
  end
74
78
 
75
79
  # @attribute [Slide] The slide being rendered.
76
80
  attr :slide
77
81
 
78
- # The content sections of the slide.
79
- # @returns [Hash(String, String)] Sections keyed by heading name.
80
- def content
81
- @slide.content
82
+ # Render the slide body after title and placeholder extraction.
83
+ # @returns [XRB::MarkupString] The remaining document as HTML.
84
+ def document
85
+ markup(@document)
82
86
  end
83
87
 
84
- # Whether the named content section exists and has content.
85
- # @parameter name [String] The section name (derived from the Markdown heading).
86
- # @returns [Boolean]
87
- def section?(name)
88
- fragment = @slide.content[name]
89
- fragment && !fragment.empty?
88
+ # Extract and render a named H2 placeholder from this render's document.
89
+ #
90
+ # Extraction is cached so a template can reference a placeholder more than
91
+ # once without mutating the document repeatedly.
92
+ # @parameter name [String] The heading name to extract.
93
+ # @returns [XRB::MarkupString | Nil] The extracted HTML, or `nil` when absent.
94
+ def extract(name)
95
+ fragment = extract_fragment(name)
96
+ return unless fragment && !fragment.empty?
97
+
98
+ markup(fragment)
99
+ end
100
+
101
+ # Render the slide header using semantic markup.
102
+ # @returns [XRB::MarkupString] The rendered header, or an empty string when no metadata is present.
103
+ def slide_header
104
+ section = @slide.section
105
+
106
+ return XRB::MarkupString.raw("") unless present?(section) || @heading
107
+
108
+ builder = XRB::Builder.new
109
+ builder.tag(:header, class: "slide-header") do
110
+ if present?(section)
111
+ builder.tag(:div, class: "slide-section-heading") do
112
+ builder.text(section.to_s)
113
+ end
114
+ end
115
+
116
+ builder.raw(@heading.to_html) if @heading
117
+ end
118
+
119
+ XRB::MarkupString.raw(builder.to_s)
120
+ end
121
+
122
+ private
123
+
124
+ def extract_fragment(name)
125
+ return @extracted[name] if @extracted.key?(name)
126
+
127
+ @extracted[name] = @document.extract(name)
128
+ end
129
+
130
+ def markup(fragment)
131
+ XRB::MarkupString.raw(fragment&.to_html || "")
90
132
  end
91
133
 
92
- # Get a named content section as raw HTML markup.
93
- # @parameter name [String] The section name (derived from the Markdown heading).
94
- # @returns [XRB::MarkupString] The rendered HTML content, safe for embedding.
95
- def section(name)
96
- XRB::MarkupString.raw(@slide.content[name]&.to_html || "")
134
+ def present?(value)
135
+ value && !value.to_s.empty?
97
136
  end
98
137
  end
99
138
  end