1
0
mirror of https://github.com/google/comprehensive-rust.git synced 2025-11-27 16:28:48 +02:00

Comprehensive Rust v2 (#1073)

I've taken some work by @fw-immunant and others on the new organization
of the course and condensed it into a form amenable to a text editor and
some computational analysis. You can see the inputs in `course.py` but
the interesting bits are the output: `outline.md` and `slides.md`.

The idea is to break the course into more, smaller segments with
exercises at the ends and breaks in between. So `outline.md` lists the
segments, their duration, and sums those durations up per-day. It shows
we're about an hour too long right now! There are more details of the
segments in `slides.md`, or you can see mostly the same stuff in
`course.py`.

This now contains all of the content from the v1 course, ensuring both
that we've covered everything and that we'll have somewhere to redirect
every page.

Fixes #1082.
Fixes #1465.

---------

Co-authored-by: Nicole LeGare <dlegare.1001@gmail.com>
Co-authored-by: Martin Geisler <mgeisler@google.com>
This commit is contained in:
Dustin J. Mitchell
2023-11-29 10:39:24 -05:00
committed by GitHub
parent ea204774b6
commit 6d19292f16
309 changed files with 6807 additions and 4281 deletions

451
mdbook-course/src/course.rs Normal file
View File

@@ -0,0 +1,451 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Representation of Comprehensive Rust as a hierarchy of types.
//!
//! ```ignore
//! Courses -- a collection of courses
//! Course -- the level of content at which students enroll (fundamentals, android, etc.)
//! Session -- a block of instructional time (typically morning or afternoon)
//! Segment -- a collection of slides with a related theme
//! Slide -- a single topic (may be represented by multiple mdBook chapters)
//! ```
//!
//! This structure is parsed from the format of the book using a combination of the order in which
//! chapters are listed in `SUMMARY.md` and annotations in the frontmatter of each chapter.
//!
//! A book contains a sequence of BookItems, each of which can contain sub-items. A top-level item
//! can potentially introduce a new course, session, segment, and slide all in the same item. If
//! the item has a `course` property in its frontmatter, then it introduces a new course. If it has
//! a `session` property, then it introduces a new session. A top-level item always corresponds
//! 1-to-1 with a segment (as long as it is a chapter), and that item becomes the first slide in
//! that segment. Any other sub-items of the top-level item are treated as further slides in the
//! same segment.
use crate::frontmatter::{split_frontmatter, Frontmatter};
use crate::markdown::{duration, relative_link};
use mdbook::book::{Book, BookItem, Chapter};
use std::fmt::Write;
use std::path::{Path, PathBuf};
/// Duration, in minutes, of breaks between segments in the course.
const BREAK_DURATION: u64 = 10;
/// Courses is simply a collection of Courses.
///
/// Non-instructional material (such as the introduction) has `course: none` and
/// is not included in this data structure.
#[derive(Default, Debug)]
pub struct Courses {
pub courses: Vec<Course>,
}
/// A Course is the level of content at which students enroll.
///
/// Courses are identified by the `course` property in a session's frontmatter. All
/// sessions with the same value for `course` are grouped into a Course.
#[derive(Default, Debug)]
pub struct Course {
pub name: String,
pub sessions: Vec<Session>,
}
/// A Session is a block of instructional time, containing segments. Typically a full day of
/// instruction contains two sessions: morning and afternoon.
///
/// A session is identified by the `session` property in the session's frontmatter. There can be
/// only one session with a given name in a course.
#[derive(Default, Debug)]
pub struct Session {
pub name: String,
pub segments: Vec<Segment>,
}
/// A Segment is a collection of slides with a related theme.
///
/// A segment is identified as a top-level chapter within a session.
#[derive(Default, Debug)]
pub struct Segment {
pub name: String,
pub slides: Vec<Slide>,
}
/// A Slide presents a single topic. It may contain multiple mdBook chapters.
///
/// A slide is identified as an sub-chapter of a segment. Any sub-items of
/// that chapter are also included in the slide.
#[derive(Default, Debug)]
pub struct Slide {
pub name: String,
/// Minutes this slide should take to teach.
pub minutes: u64,
/// Source paths (`.md` files) in this slide.
pub source_paths: Vec<PathBuf>,
}
impl Courses {
/// Extract the course structure from the book. As a side-effect, the frontmatter is stripped
/// from each slide.
pub fn extract_structure(mut book: Book) -> anyhow::Result<(Self, Book)> {
let mut courses = Courses::default();
let mut current_course_name = None;
let mut current_session_name = None;
for item in &mut book.sections {
// We only want to process chapters, omitting part titles and separators.
let BookItem::Chapter(chapter) = item else {
continue;
};
let (frontmatter, content) = split_frontmatter(chapter)?;
chapter.content = content;
// If 'course' is given, use that course (if not 'none') and reset the session.
if let Some(course_name) = &frontmatter.course {
current_session_name = None;
if course_name == "none" {
current_course_name = None;
} else {
current_course_name = Some(course_name.clone());
}
}
// If 'session' is given, use that session.
if let Some(session_name) = &frontmatter.session {
current_session_name = Some(session_name.clone());
}
if current_course_name.is_some() && current_session_name.is_none() {
anyhow::bail!(
"{:?}: 'session' must appear in frontmatter when 'course' appears",
chapter.path
);
}
// If we have a course and session, then add this chapter to it as a segment.
if let (Some(course_name), Some(session_name)) =
(&current_course_name, &current_session_name)
{
let course = courses.course_mut(course_name);
let session = course.session_mut(session_name);
session.add_segment(frontmatter, chapter)?;
}
}
Ok((courses, book))
}
/// Get a reference to a course, adding a new one if none by this name exists.
fn course_mut(&mut self, name: impl AsRef<str>) -> &mut Course {
let name = name.as_ref();
if let Some(found_idx) =
self.courses.iter().position(|course| &course.name == name)
{
return &mut self.courses[found_idx];
}
let course = Course::new(name);
self.courses.push(course);
self.courses.last_mut().unwrap()
}
/// Find a course by name.
pub fn find_course(&self, name: impl AsRef<str>) -> Option<&Course> {
let name = name.as_ref();
self.courses.iter().find(|c| c.name == name)
}
/// Find the slide generated from the given Chapter within these courses, returning the "path"
/// to that slide.
pub fn find_slide(
&self,
chapter: &Chapter,
) -> Option<(&Course, &Session, &Segment, &Slide)> {
let Some(ref source_path) = chapter.source_path else {
return None;
};
for course in self {
for session in course {
for segment in session {
for slide in segment {
if slide.source_paths.contains(source_path) {
return Some((course, session, segment, slide));
}
}
}
}
}
return None;
}
}
impl<'a> IntoIterator for &'a Courses {
type Item = &'a Course;
type IntoIter = std::slice::Iter<'a, Course>;
fn into_iter(self) -> Self::IntoIter {
(&self.courses).into_iter()
}
}
impl Course {
fn new(name: impl Into<String>) -> Self {
Course {
name: name.into(),
..Default::default()
}
}
/// Get a reference to a session, adding a new one if none by this name exists.
fn session_mut(&mut self, name: impl AsRef<str>) -> &mut Session {
let name = name.as_ref();
if let Some(found_idx) = self
.sessions
.iter()
.position(|session| &session.name == name)
{
return &mut self.sessions[found_idx];
}
let session = Session::new(name);
self.sessions.push(session);
self.sessions.last_mut().unwrap()
}
/// Return the total duration of this course, as the sum of all segment durations.
///
/// This includes breaks between segments, but does not count time between between
/// sessions.
pub fn minutes(&self) -> u64 {
self.into_iter().map(|s| s.minutes()).sum()
}
/// Generate a Markdown schedule for this course, for placement at the given path.
pub fn schedule(&self, at_source_path: impl AsRef<Path>) -> String {
let mut outline = String::from("Course schedule:\n");
for session in self {
writeln!(
&mut outline,
" * {} ({}, including breaks)",
session.name,
duration(session.minutes())
)
.unwrap();
for segment in session {
if segment.minutes() == 0 {
continue;
}
writeln!(
&mut outline,
" * [{}]({}) ({})",
segment.name,
relative_link(&at_source_path, &segment.slides[0].source_paths[0]),
duration(segment.minutes())
)
.unwrap();
}
}
outline
}
}
impl<'a> IntoIterator for &'a Course {
type Item = &'a Session;
type IntoIter = std::slice::Iter<'a, Session>;
fn into_iter(self) -> Self::IntoIter {
(&self.sessions).into_iter()
}
}
impl Session {
fn new(name: impl Into<String>) -> Self {
Session {
name: name.into(),
..Default::default()
}
}
/// Add a new segment to the session, representing sub-items as slides.
fn add_segment(
&mut self,
frontmatter: Frontmatter,
chapter: &mut Chapter,
) -> anyhow::Result<()> {
let mut segment = Segment::new(&chapter.name);
segment.add_slide(frontmatter, chapter, false)?;
for sub_chapter in &mut chapter.sub_items {
let BookItem::Chapter(sub_chapter) = sub_chapter else {
continue;
};
let (frontmatter, content) = split_frontmatter(sub_chapter)?;
sub_chapter.content = content;
segment.add_slide(frontmatter, sub_chapter, true)?;
}
self.segments.push(segment);
Ok(())
}
/// Generate a Markdown outline for this session, for placement at the given path.
pub fn outline(&self, at_source_path: impl AsRef<Path>) -> String {
let mut outline = String::from("In this session:\n");
for segment in self {
// Skip short segments (welcomes, wrap-up, etc.)
if segment.minutes() == 0 {
continue;
}
writeln!(
&mut outline,
" * [{}]({}) ({})",
segment.name,
relative_link(&at_source_path, &segment.slides[0].source_paths[0]),
duration(segment.minutes())
)
.unwrap();
}
writeln!(&mut outline,"\nIncluding {BREAK_DURATION} minute breaks, this session should take about {}", duration(self.minutes())).unwrap();
outline
}
/// Return the total duration of this session.
pub fn minutes(&self) -> u64 {
let instructional_time: u64 = self.into_iter().map(|s| s.minutes()).sum();
let breaks = (self.into_iter().filter(|s| s.minutes() > 0).count() - 1) as u64
* BREAK_DURATION;
instructional_time + breaks
}
}
impl<'a> IntoIterator for &'a Session {
type Item = &'a Segment;
type IntoIter = std::slice::Iter<'a, Segment>;
fn into_iter(self) -> Self::IntoIter {
(&self.segments).into_iter()
}
}
impl Segment {
fn new(name: impl Into<String>) -> Self {
Segment {
name: name.into(),
..Default::default()
}
}
/// Create a slide from a chapter. If `recurse` is true, sub-items of this chapter are
/// included in this slide as well.
fn add_slide(
&mut self,
frontmatter: Frontmatter,
chapter: &mut Chapter,
recurse: bool,
) -> anyhow::Result<()> {
let mut slide = Slide::new(frontmatter, chapter);
if recurse {
slide.add_sub_chapters(chapter)?;
}
self.slides.push(slide);
Ok(())
}
/// Return the total duration of this segment (the sum of the durations of the enclosed
/// slides).
pub fn minutes(&self) -> u64 {
self.into_iter().map(|s| s.minutes()).sum()
}
pub fn outline(&self, at_source_path: impl AsRef<Path>) -> String {
let mut outline = String::from("In this segment:\n");
for slide in self {
if slide.minutes() == 0 {
continue;
}
writeln!(
&mut outline,
" * [{}]({}) ({})",
slide.name,
relative_link(&at_source_path, &slide.source_paths[0]),
duration(slide.minutes())
)
.unwrap();
}
writeln!(
&mut outline,
"\nThis segment should take about {}",
duration(self.minutes())
)
.unwrap();
outline
}
}
impl<'a> IntoIterator for &'a Segment {
type Item = &'a Slide;
type IntoIter = std::slice::Iter<'a, Slide>;
fn into_iter(self) -> Self::IntoIter {
(&self.slides).into_iter()
}
}
impl Slide {
fn new(frontmatter: Frontmatter, chapter: &Chapter) -> Self {
let mut slide = Self {
name: chapter.name.clone(),
..Default::default()
};
slide.add_frontmatter(&frontmatter);
slide.push_source_path(&chapter.source_path);
slide
}
fn add_frontmatter(&mut self, frontmatter: &Frontmatter) {
self.minutes += frontmatter.minutes.unwrap_or(0);
}
fn push_source_path(&mut self, source_path: &Option<PathBuf>) {
if let Some(source_path) = &source_path {
self.source_paths.push(source_path.clone());
}
}
/// Add sub-chapters of this chapter to this slide (recursively).
fn add_sub_chapters(&mut self, chapter: &mut Chapter) -> anyhow::Result<()> {
for sub_slide in &mut chapter.sub_items {
let BookItem::Chapter(sub_slide) = sub_slide else {
continue;
};
let (frontmatter, content) = split_frontmatter(sub_slide)?;
sub_slide.content = content;
if frontmatter.course.is_some() || frontmatter.session.is_some() {
anyhow::bail!(
"{:?}: sub-slides may not have 'course' or 'session' set",
sub_slide.path
);
}
self.add_frontmatter(&frontmatter);
self.push_source_path(&sub_slide.source_path);
self.add_sub_chapters(sub_slide)?;
}
Ok(())
}
/// Return the total duration of this slide.
pub fn minutes(&self) -> u64 {
self.minutes
}
}

View File

@@ -12,30 +12,28 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use anyhow::Context;
use matter::matter;
use mdbook::book::{Book, BookItem};
use mdbook::preprocess::PreprocessorContext;
use mdbook::book::Chapter;
use serde::Deserialize;
pub fn remove_frontmatter(
ctx: &PreprocessorContext,
book: &mut Book,
) -> anyhow::Result<()> {
let is_html = ctx.renderer == "html";
book.for_each_mut(|chapter| {
let BookItem::Chapter(chapter) = chapter else {
return;
};
if let Some((frontmatter, content)) = matter(&chapter.content) {
if is_html {
// For the moment, include the frontmatter in the slide in a floating <pre>, for review
// purposes.
let pre = format!(r#"<pre class="frontmatter">{frontmatter}</pre>"#);
chapter.content = format!("{pre}\n\n{content}");
} else {
// For non-HTML renderers, just strip the frontmatter.
chapter.content = content;
}
}
});
Ok(())
#[derive(Deserialize, Debug, Default)]
pub struct Frontmatter {
pub minutes: Option<u64>,
pub course: Option<String>,
pub session: Option<String>,
}
/// Split a chapter's contents into frontmatter and the remaining contents.
pub fn split_frontmatter(chapter: &Chapter) -> anyhow::Result<(Frontmatter, String)> {
if let Some((frontmatter, content)) = matter(&chapter.content) {
let frontmatter: Frontmatter =
serde_yaml::from_str(&frontmatter).with_context(|| {
format!("error parsing frontmatter in {:?}", chapter.source_path)
})?;
Ok((frontmatter, content))
} else {
Ok((Frontmatter::default(), chapter.content.clone()))
}
}

View File

@@ -1,15 +0,0 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
pub mod frontmatter;

View File

@@ -12,9 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
mod course;
mod frontmatter;
mod markdown;
mod replacements;
mod timing_info;
use crate::course::Courses;
use crate::markdown::duration;
use clap::{Arg, Command};
use mdbook::book::BookItem;
use mdbook::preprocess::CmdPreprocessor;
use mdbook_course::frontmatter::remove_frontmatter;
use std::io::{stdin, stdout};
use std::process;
@@ -37,9 +45,65 @@ fn main() {
}
fn preprocess() -> anyhow::Result<()> {
let (ctx, mut book) = CmdPreprocessor::parse_input(stdin())?;
let (_, book) = CmdPreprocessor::parse_input(stdin())?;
remove_frontmatter(&ctx, &mut book)?;
let (courses, mut book) = Courses::extract_structure(book)?;
book.for_each_mut(|chapter| {
if let BookItem::Chapter(chapter) = chapter {
if let Some((course, session, segment, slide)) = courses.find_slide(chapter) {
timing_info::insert_timing_info(slide, chapter);
replacements::replace(
&courses,
Some(course),
Some(session),
Some(segment),
chapter,
);
} else {
// Outside of a course, just perform replacements.
replacements::replace(&courses, None, None, None, chapter);
}
}
});
let timediff = |actual, target| {
if actual > target {
format!(
"{}: {} OVER TARGET {}",
duration(actual),
duration(actual - target),
duration(target)
)
} else if actual < target {
format!(
"{}: {} shorter than target {}",
duration(actual),
duration(target - actual),
duration(target)
)
} else {
format!("{}: right on time", duration(actual))
}
};
// Print a summary of times for the "Fundamentals" course.
let fundamentals = courses.find_course("Fundamentals").unwrap();
eprintln!(
"Fundamentals: {}",
timediff(fundamentals.minutes(), 8 * 3 * 60)
);
eprintln!("Sessions:");
for session in fundamentals {
eprintln!(
" {}: {}",
session.name,
timediff(session.minutes(), 3 * 60)
);
for segment in session {
eprintln!(" {}: {}", segment.name, duration(segment.minutes()));
}
}
serde_json::to_writer(stdout(), &book)?;
Ok(())

View File

@@ -0,0 +1,149 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::path::Path;
/// Given a source_path for the markdown file being rendered and a source_path for the target,
/// generate a relative link.
pub fn relative_link(
doc_path: impl AsRef<Path>,
target_path: impl AsRef<Path>,
) -> String {
let doc_path = doc_path.as_ref();
let target_path = target_path.as_ref();
let mut dotdot = -1;
for parent in doc_path.ancestors() {
if target_path.starts_with(parent) {
break;
}
dotdot += 1;
}
if dotdot > 0 {
format!("{}{}", "../".repeat(dotdot as usize), target_path.display())
} else {
format!("./{}", target_path.display())
}
}
/// Represent the given duration in a human-readable way.
///
/// This will round times longer than 5 minutes to the next 5-minute interval.
pub fn duration(mut minutes: u64) -> String {
if minutes > 5 {
minutes += 4;
minutes -= minutes % 5;
}
let (hours, minutes) = (minutes / 60, minutes % 60);
match (hours, minutes) {
(0, 1) => "1 minute".into(),
(0, m) => format!("{m} minutes"),
(1, 0) => "1 hour".into(),
(1, m) => format!("1 hour and {m} minutes"),
(h, 0) => format!("{h} hours"),
(h, m) => format!("{h} hours and {m} minutes"),
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn relative_link_same_dir() {
assert_eq!(
relative_link(Path::new("welcome.md"), Path::new("hello-world.md")),
"./hello-world.md".to_string()
);
}
#[test]
fn relative_link_subdir() {
assert_eq!(
relative_link(Path::new("hello-world.md"), Path::new("hello-world/foo.md")),
"./hello-world/foo.md".to_string()
);
}
#[test]
fn relative_link_parent_dir() {
assert_eq!(
relative_link(Path::new("references/foo.md"), Path::new("hello-world.md")),
"../hello-world.md".to_string()
);
}
#[test]
fn relative_link_deep_parent_dir() {
assert_eq!(
relative_link(
Path::new("references/foo/bar.md"),
Path::new("hello-world.md")
),
"../../hello-world.md".to_string()
);
}
#[test]
fn relative_link_peer_dir() {
assert_eq!(
relative_link(
Path::new("references/foo.md"),
Path::new("hello-world/foo.md")
),
"../hello-world/foo.md".to_string()
);
}
#[test]
fn duration_no_time() {
assert_eq!(duration(0), "0 minutes")
}
#[test]
fn duration_single_minute() {
assert_eq!(duration(1), "1 minute")
}
#[test]
fn duration_two_minutes() {
assert_eq!(duration(2), "2 minutes")
}
#[test]
fn duration_seven_minutes() {
assert_eq!(duration(7), "10 minutes")
}
#[test]
fn duration_hour() {
assert_eq!(duration(60), "1 hour")
}
#[test]
fn duration_hour_mins() {
assert_eq!(duration(61), "1 hour and 5 minutes")
}
#[test]
fn duration_hours() {
assert_eq!(duration(120), "2 hours")
}
#[test]
fn duration_hours_mins() {
assert_eq!(duration(130), "2 hours and 10 minutes")
}
}

View File

@@ -0,0 +1,61 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::course::{Course, Courses, Segment, Session};
use mdbook::book::Chapter;
use regex::Regex;
lazy_static::lazy_static! {
static ref DIRECTIVE: Regex = Regex::new(r#"\{\{%([^}]*)}}"#).unwrap();
}
/// Replace supported directives with the relevant content.
///
/// See the mdbook-course README for details.
#[allow(unused_variables)]
pub fn replace(
courses: &Courses,
course: Option<&Course>,
session: Option<&Session>,
segment: Option<&Segment>,
chapter: &mut Chapter,
) {
let Some(source_path) = &chapter.source_path else {
return;
};
chapter.content = DIRECTIVE
.replace(&chapter.content, |captures: &regex::Captures| {
let directive_str = captures[1].trim();
let directive: Vec<_> = directive_str.split_whitespace().collect();
match directive.as_slice() {
["session", "outline"] if session.is_some() => {
session.unwrap().outline(source_path)
}
["segment", "outline"] if segment.is_some() => {
segment.unwrap().outline(source_path)
}
["course", "outline"] if course.is_some() => {
course.unwrap().schedule(source_path)
}
["course", "outline", course_name] => {
let Some(course) = courses.find_course(course_name) else {
return captures[0].to_string();
};
course.schedule(source_path)
}
_ => directive_str.to_owned(),
}
})
.to_string();
}

View File

@@ -0,0 +1,38 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::course::Slide;
use mdbook::book::Chapter;
/// Insert timing information for this slide into the speaker notes.
pub fn insert_timing_info(slide: &Slide, chapter: &mut Chapter) {
if slide.minutes > 0 && chapter.content.contains("<details>") {
// Include the minutes in the speaker notes.
let minutes = slide.minutes;
let plural = if slide.minutes == 1 {
"minute"
} else {
"minutes"
};
let mut subslides = "";
if slide.source_paths.len() > 1 {
subslides = "and its sub-slides ";
}
let timing_message =
format!("This slide {subslides}should take about {minutes} {plural}. ");
chapter.content = chapter
.content
.replace("<details>", &format!("<details>\n{timing_message}"));
}
}