You've already forked comprehensive-rust
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:
committed by
GitHub
parent
ea204774b6
commit
6d19292f16
451
mdbook-course/src/course.rs
Normal file
451
mdbook-course/src/course.rs
Normal 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)) =
|
||||
(¤t_course_name, ¤t_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
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
|
||||
149
mdbook-course/src/markdown.rs
Normal file
149
mdbook-course/src/markdown.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
61
mdbook-course/src/replacements.rs
Normal file
61
mdbook-course/src/replacements.rs
Normal 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: ®ex::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();
|
||||
}
|
||||
38
mdbook-course/src/timing_info.rs
Normal file
38
mdbook-course/src/timing_info.rs
Normal 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}"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user