support speaker notes and timing comments on the title slide

Pandoc puts content written before the first heading into an otherwise
empty phantom slide right after the title slide. When such a slide
consists of nothing but ::: notes blocks, timing comments, and
whitespace, it is now merged into the title slide: the title gets the
data-timing attribute and the notes, the phantom slide disappears, and
slide_timing() reports the title time as "(title)".

Change-Id: I984e924c467c4e15905b97c128c380887132f09d
diff --git a/NEWS.md b/NEWS.md
index 5190f7c..731195a 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
 # revealjs.ids (development version)
 
+- Speaker notes (`::: notes`) and timing comments placed before the first heading are now attached to the title slide instead of ending up on a separate empty phantom slide after it, so the title itself can have a time budget and a welcome note (shown in the speaker view and the mobile notes overlay, and included in `slide_timing()` and the pacing timer).
+
 - Disable reveal.js' automatic scroll view on narrow screens (`scrollActivationWidth: 0`): when a phone activated scroll view during load, it restructured the slides DOM and the slides menu degenerated to italic "Slide 1..N" fallback entries. Phones now get the regular swipeable slide view, where menu, speaker notes overlay, and pacing timer work as usual. Re-enable with `reveal_options: list(scrollActivationWidth = 600)` or force scroll view with `view: "scroll"`.
 
 - `ids` theme: on touch devices, a new button next to the fullscreen toggle overlays the current slide with its speaker notes (plus the slide title and planned time, if annotated with a timing comment) -- for practicing talks on mobile browsers, where reveal.js' separate speaker view window is awkward. The overlay follows slide changes and only appears when the deck contains notes.
diff --git a/R/revealjs_presentation.R b/R/revealjs_presentation.R
index f641284..75d6c23 100644
--- a/R/revealjs_presentation.R
+++ b/R/revealjs_presentation.R
@@ -31,7 +31,8 @@
 #' Content of speaker notes
 #' :::
 #' ```
-#' to create notes only viewable in presentation mode.
+#' to create notes only viewable in presentation mode. Notes placed before
+#' the first heading are attached to the title slide.
 #' 
 #' On mobile browsers (touch devices), a button next to the fullscreen
 #' toggle overlays the current slide with its speaker notes (plus the slide
@@ -55,7 +56,8 @@
 #' 
 #' This means Marc Kupietz will need 30 seconds for that slide. For talks
 #' presented by a single speaker, the speaker code can be omitted:
-#' `<!-- 00:30 -->`.
+#' `<!-- 00:30 -->`. Comments placed before the first heading are applied to
+#' the title slide.
 #' 
 #' Typically you start from a known total time budget and adjust the
 #' individual slides to it. You can set this budget yourself with the
@@ -408,6 +410,7 @@
   # timer of the notes plugin) and reporting per-speaker totals
   post_processor <- function(metadata, input_file, output_file, clean, verbose) {
     lines <- readLines(output_file, warn = FALSE, encoding = "UTF-8")
+    lines <- merge_title_slide_notes(lines)
     result <- apply_timing_attributes(lines)
     comments <- attr(result, "comments")
     if (nrow(comments) > 0) {
diff --git a/R/slide_timing.R b/R/slide_timing.R
index 560716d..7fbc976 100644
--- a/R/slide_timing.R
+++ b/R/slide_timing.R
@@ -138,6 +138,52 @@
   ifelse(is.na(speaker), "(unnamed)", speaker)
 }
 
+# Timing comments and ::: notes blocks written before the first heading do
+# not land on the title slide: pandoc puts them into an otherwise empty
+# phantom slide right after it. Merge such a phantom slide -- consisting of
+# nothing but notes, timing comments, and whitespace -- into the title
+# slide, so that notes and timing can be given for the title as well.
+merge_title_slide_notes <- function(lines) {
+  title_open <- grep('<section class="title-frame"', lines)
+  if (length(title_open) == 0) {
+    return(lines)
+  }
+  title_open <- title_open[1]
+  title_close <- which(lines == "</section>")
+  title_close <- title_close[title_close > title_open][1]
+
+  phantom_open <- grep("^<section", lines)
+  phantom_open <- phantom_open[phantom_open > title_close][1]
+  if (is.na(phantom_open)) {
+    return(lines)
+  }
+  phantom_close <- which(lines == "</section>")
+  phantom_close <- phantom_close[phantom_close > phantom_open][1]
+
+  inner <- lines[(phantom_open + 1):(phantom_close - 1)]
+
+  # the phantom slide is only merged if it has no visible content beyond
+  # notes, timing comments, and whitespace
+  aside <- grep('<aside class="notes">', inner)
+  aside_end <- grep("</aside>", inner)
+  in_aside <- if (length(aside) > 0) {
+    unlist(Map(":", aside, aside_end[aside_end > aside][1]))
+  }
+  content <- seq_along(inner)
+  content <- content[!inner %in% ""]
+  content <- setdiff(content, c(grep("<!--", inner), in_aside))
+  if (length(content) > 0) {
+    return(lines)
+  }
+
+  moved <- inner[nzchar(trimws(inner))]
+  append(
+    lines[-(phantom_open:phantom_close)],
+    moved,
+    after = title_close - 1
+  )
+}
+
 # Convert timing comments into data-timing attributes on the enclosing
 # <section> elements of a rendered reveal.js HTML document. Returns the
 # modified lines invisibly along with the extracted comments as attributes.
diff --git a/README.Rmd b/README.Rmd
index 11a7f6b..b8e1d9d 100644
--- a/README.Rmd
+++ b/README.Rmd
@@ -127,7 +127,7 @@
 <!-- JD 00:30 -->
 ```
 
-This means John Doe will need 30 seconds for that slide. For talks presented by a single speaker, the speaker code can be omitted:
+This means John Doe will need 30 seconds for that slide. For talks presented by a single speaker, the speaker code can be omitted: Comments placed before the first heading are applied to the title slide, so you can give the title itself a time budget and a welcome note:
 
 ``` markdown
 ## My slide title
diff --git a/README.md b/README.md
index 930c084..ca5994c 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,9 @@
 
 This means John Doe will need 30 seconds for that slide. For talks
 presented by a single speaker, the speaker code can be omitted:
+Comments placed before the first heading are applied to the title
+slide, so you can give the title itself a time budget and a welcome
+note:
 
 ``` markdown
 ## My slide title
diff --git a/man/revealjs_presentation.Rd b/man/revealjs_presentation.Rd
index 827370e..3912575 100644
--- a/man/revealjs_presentation.Rd
+++ b/man/revealjs_presentation.Rd
@@ -175,7 +175,14 @@
 :::
 }\if{html}{\out{</div>}}
 
-to create notes only viewable in presentation mode.
+to create notes only viewable in presentation mode. Notes placed before
+the first heading are attached to the title slide.
+
+On mobile browsers (touch devices), a button next to the fullscreen
+toggle overlays the current slide with its speaker notes (plus the slide
+title and, if present, the planned time from a timing comment), which is
+useful for practicing a talk on a phone or tablet. It follows along as
+you change slides and only appears if the deck contains notes at all.
 }
 
 }
@@ -195,7 +202,8 @@
 
 This means Marc Kupietz will need 30 seconds for that slide. For talks
 presented by a single speaker, the speaker code can be omitted:
-\verb{<!-- 00:30 -->}.
+\verb{<!-- 00:30 -->}. Comments placed before the first heading are applied to
+the title slide.
 
 Typically you start from a known total time budget and adjust the
 individual slides to it. You can set this budget yourself with the
diff --git a/tests/testthat/test-slide_timing.R b/tests/testthat/test-slide_timing.R
index edcce26..48bc3d1 100644
--- a/tests/testthat/test-slide_timing.R
+++ b/tests/testthat/test-slide_timing.R
@@ -144,6 +144,74 @@
   expect_true(any(grepl("totalTime:\\s*120", html)))
 })
 
+test_that("title slide notes and timing are merged into the title section", {
+  lines <- c(
+    '<section class="title-frame" id="title-slide">',
+    "<h1 class=\"title\">Title</h1>",
+    "</section>",
+    "<section class=\"slide level2\">",
+    "<!-- JD 00:30 -->",
+    "<aside class=\"notes\">",
+    "<p>Welcome</p>",
+    "</aside>",
+    "</section>",
+    "<section id=\"slide-a\" class=\"slide level2\">",
+    "<!-- MK 01:00 -->",
+    "</section>"
+  )
+  merged <- merge_title_slide_notes(lines)
+  # phantom slide (6 lines) removed, its 4 content lines moved into the title
+  expect_length(merged, length(lines) - 2)
+  expect_identical(merged[1], lines[1])
+  expect_match(paste(merged[1:5], collapse = "\n"), "Welcome")
+  expect_match(paste(merged[1:5], collapse = "\n"), "JD 00:30")
+  # only the real Slide A section remains
+  expect_equal(sum(grepl('class="slide level2"', merged)), 1)
+
+  # a phantom slide with real content is left alone
+  with_content <- lines
+  with_content[6] <- "<p>Visible intro content</p>"
+  expect_identical(merge_title_slide_notes(with_content), with_content)
+
+  # no title slide: nothing to merge
+  no_title <- lines[-1]
+  expect_identical(merge_title_slide_notes(no_title), no_title)
+})
+
+test_that("rendered title slides carry notes and data-timing", {
+  skip_if_not_pandoc()
+  skip_if_not_installed("xml2")
+  rmd <- local_temp_rmd_file(
+    "---",
+    "title: Timing test",
+    "output: revealjs.ids::revealjs_presentation",
+    "---",
+    "",
+    "::: notes",
+    "Welcome note",
+    ":::",
+    "",
+    "<!-- 00:30 -->",
+    "",
+    "## Slide A",
+    "",
+    "Content A"
+  )
+  html <- .render_and_read(rmd)
+  title <- xml2::xml_find_first(html, "//section[@id='title-slide']")
+  expect_equal(xml2::xml_attr(title, "data-timing"), "30")
+  expect_equal(
+    xml2::xml_text(xml2::xml_find_first(title, "./aside[@class='notes']"), trim = TRUE),
+    "Welcome note"
+  )
+  # no phantom slide between title and first content slide
+  sections <- xml2::xml_find_all(html, "//section[@class]")
+  expect_equal(
+    xml2::xml_attr(sections[1:2], "id"),
+    c("title-slide", "slide-a")
+  )
+})
+
 test_that("a user-supplied totalTime is respected", {
   skip_if_not_pandoc()
   rmd <- local_temp_rmd_file(