Merge pull request #1153 from azerupi/draft-chapters

Bring back draft chapters
This commit is contained in:
Eric Huss 2020-05-10 09:18:02 -07:00 committed by GitHub
commit 578e4da5b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 252 additions and 173 deletions

View File

@ -10,6 +10,7 @@
- [clean](cli/clean.md)
- [Format](format/README.md)
- [SUMMARY.md](format/summary.md)
- [Draft chapter]()
- [Configuration](format/config.md)
- [Theme](format/theme/README.md)
- [index.hbs](format/theme/index-hbs.md)

View File

@ -7,7 +7,7 @@ are. Without this file, there is no book.
Even though `SUMMARY.md` is a markdown file, the formatting is very strict to
allow for easy parsing. Let's see how you should format your `SUMMARY.md` file.
#### Allowed elements
#### Structure
1. ***Title*** It's common practice to begin with a title, generally <code
class="language-markdown"># Summary</code>. But it is not mandatory, the
@ -36,3 +36,19 @@ allow for easy parsing. Let's see how you should format your `SUMMARY.md` file.
All other elements are unsupported and will be ignored at best or result in an
error.
#### Other elements
- ***Separators*** In between chapters you can add a separator. In the HTML renderer
this will result in a line being rendered in the table of contents. A separator is
a line containing exclusively dashes and at least three of them: `---`.
- ***Draft chapters*** Draft chapters are chapters without a file and thus content.
The purpose of a draft chapter is to signal future chapters still to be written.
Or when still laying out the structure of the book to avoid creating the files
while you are still changing the structure of the book a lot.
Draft chapters will be rendered in the HTML renderer as disabled links in the table
of contents, as you can see for the next chapter in the table of contents on the left.
Draft chapters are written like normal chapters but without writing the path to the file
```markdown
- [Draft chapter]()
```

View File

@ -39,17 +39,19 @@ fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> {
let next = items.pop().expect("already checked");
if let SummaryItem::Link(ref link) = *next {
let filename = src_dir.join(&link.location);
if !filename.exists() {
if let Some(parent) = filename.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
if let Some(ref location) = link.location {
let filename = src_dir.join(location);
if !filename.exists() {
if let Some(parent) = filename.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
}
debug!("Creating missing file {}", filename.display());
debug!("Creating missing file {}", filename.display());
let mut f = File::create(&filename)?;
writeln!(f, "# {}", link.name)?;
let mut f = File::create(&filename)?;
writeln!(f, "# {}", link.name)?;
}
}
items.extend(&link.nested_items);
@ -152,7 +154,7 @@ pub struct Chapter {
/// Nested items.
pub sub_items: Vec<BookItem>,
/// The chapter's location, relative to the `SUMMARY.md` file.
pub path: PathBuf,
pub path: Option<PathBuf>,
/// An ordered list of the names of each chapter above this one, in the hierarchy.
pub parent_names: Vec<String>,
}
@ -168,11 +170,31 @@ impl Chapter {
Chapter {
name: name.to_string(),
content,
path: path.into(),
path: Some(path.into()),
parent_names,
..Default::default()
}
}
/// Create a new draft chapter that is not attached to a source markdown file and has
/// thus no content.
pub fn new_draft(name: &str, parent_names: Vec<String>) -> Self {
Chapter {
name: name.to_string(),
content: String::new(),
path: None,
parent_names,
..Default::default()
}
}
/// Check if the chapter is a draft chapter, meaning it has no path to a source markdown file
pub fn is_draft_chapter(&self) -> bool {
match self.path {
Some(_) => false,
None => true,
}
}
}
/// Use the provided `Summary` to load a `Book` from disk.
@ -202,7 +224,7 @@ pub(crate) fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P)
})
}
fn load_summary_item<P: AsRef<Path>>(
fn load_summary_item<P: AsRef<Path> + Clone>(
item: &SummaryItem,
src_dir: P,
parent_names: Vec<String>,
@ -220,28 +242,35 @@ fn load_chapter<P: AsRef<Path>>(
src_dir: P,
parent_names: Vec<String>,
) -> Result<Chapter> {
debug!("Loading {} ({})", link.name, link.location.display());
let src_dir = src_dir.as_ref();
let location = if link.location.is_absolute() {
link.location.clone()
let mut ch = if let Some(ref link_location) = link.location {
debug!("Loading {} ({})", link.name, link_location.display());
let location = if link_location.is_absolute() {
link_location.clone()
} else {
src_dir.join(link_location)
};
let mut f = File::open(&location)
.chain_err(|| format!("Chapter file not found, {}", link_location.display()))?;
let mut content = String::new();
f.read_to_string(&mut content)
.chain_err(|| format!("Unable to read \"{}\" ({})", link.name, location.display()))?;
let stripped = location
.strip_prefix(&src_dir)
.expect("Chapters are always inside a book");
Chapter::new(&link.name, content, stripped, parent_names.clone())
} else {
src_dir.join(&link.location)
Chapter::new_draft(&link.name, parent_names.clone())
};
let mut f = File::open(&location)
.chain_err(|| format!("Chapter file not found, {}", link.location.display()))?;
let mut content = String::new();
f.read_to_string(&mut content)
.chain_err(|| format!("Unable to read \"{}\" ({})", link.name, location.display()))?;
let stripped = location
.strip_prefix(&src_dir)
.expect("Chapters are always inside a book");
let mut sub_item_parents = parent_names.clone();
let mut ch = Chapter::new(&link.name, content, stripped, parent_names);
ch.number = link.number.clone();
sub_item_parents.push(link.name.clone());
@ -376,7 +405,7 @@ And here is some \
name: String::from("Nested Chapter 1"),
content: String::from("Hello World!"),
number: Some(SectionNumber(vec![1, 2])),
path: PathBuf::from("second.md"),
path: Some(PathBuf::from("second.md")),
parent_names: vec![String::from("Chapter 1")],
sub_items: Vec::new(),
};
@ -384,7 +413,7 @@ And here is some \
name: String::from("Chapter 1"),
content: String::from(DUMMY_SRC),
number: None,
path: PathBuf::from("chapter_1.md"),
path: Some(PathBuf::from("chapter_1.md")),
parent_names: Vec::new(),
sub_items: vec![
BookItem::Chapter(nested.clone()),
@ -408,7 +437,7 @@ And here is some \
sections: vec![BookItem::Chapter(Chapter {
name: String::from("Chapter 1"),
content: String::from(DUMMY_SRC),
path: PathBuf::from("chapter_1.md"),
path: Some(PathBuf::from("chapter_1.md")),
..Default::default()
})],
..Default::default()
@ -448,7 +477,7 @@ And here is some \
name: String::from("Chapter 1"),
content: String::from(DUMMY_SRC),
number: None,
path: PathBuf::from("Chapter_1/index.md"),
path: Some(PathBuf::from("Chapter_1/index.md")),
parent_names: Vec::new(),
sub_items: vec![
BookItem::Chapter(Chapter::new(
@ -500,7 +529,7 @@ And here is some \
name: String::from("Chapter 1"),
content: String::from(DUMMY_SRC),
number: None,
path: PathBuf::from("Chapter_1/index.md"),
path: Some(PathBuf::from("Chapter_1/index.md")),
parent_names: Vec::new(),
sub_items: vec![
BookItem::Chapter(Chapter::new(
@ -537,7 +566,7 @@ And here is some \
let summary = Summary {
numbered_chapters: vec![SummaryItem::Link(Link {
name: String::from("Empty"),
location: PathBuf::from(""),
location: Some(PathBuf::from("")),
..Default::default()
})],
..Default::default()
@ -556,7 +585,7 @@ And here is some \
let summary = Summary {
numbered_chapters: vec![SummaryItem::Link(Link {
name: String::from("nested"),
location: dir,
location: Some(dir),
..Default::default()
})],
..Default::default()

View File

@ -251,37 +251,40 @@ impl MDBook {
for item in book.iter() {
if let BookItem::Chapter(ref ch) = *item {
if !ch.path.as_os_str().is_empty() {
let path = self.source_dir().join(&ch.path);
info!("Testing file: {:?}", path);
let chapter_path = match ch.path {
Some(ref path) if !path.as_os_str().is_empty() => path,
_ => continue,
};
// write preprocessed file to tempdir
let path = temp_dir.path().join(&ch.path);
let mut tmpf = utils::fs::create_file(&path)?;
tmpf.write_all(ch.content.as_bytes())?;
let path = self.source_dir().join(&chapter_path);
info!("Testing file: {:?}", path);
let mut cmd = Command::new("rustdoc");
cmd.arg(&path).arg("--test").args(&library_args);
// write preprocessed file to tempdir
let path = temp_dir.path().join(&chapter_path);
let mut tmpf = utils::fs::create_file(&path)?;
tmpf.write_all(ch.content.as_bytes())?;
if let Some(edition) = self.config.rust.edition {
match edition {
RustEdition::E2015 => {
cmd.args(&["--edition", "2015"]);
}
RustEdition::E2018 => {
cmd.args(&["--edition", "2018"]);
}
let mut cmd = Command::new("rustdoc");
cmd.arg(&path).arg("--test").args(&library_args);
if let Some(edition) = self.config.rust.edition {
match edition {
RustEdition::E2015 => {
cmd.args(&["--edition", "2015"]);
}
RustEdition::E2018 => {
cmd.args(&["--edition", "2018"]);
}
}
}
let output = cmd.output()?;
let output = cmd.output()?;
if !output.status.success() {
bail!(ErrorKind::Subprocess(
"Rustdoc returned an error".to_string(),
output
));
}
if !output.status.success() {
bail!(ErrorKind::Subprocess(
"Rustdoc returned an error".to_string(),
output
));
}
}
}

View File

@ -71,7 +71,7 @@ pub struct Link {
pub name: String,
/// The location of the chapter's source file, taking the book's `src`
/// directory as the root.
pub location: PathBuf,
pub location: Option<PathBuf>,
/// The section number, if this chapter is in the numbered section.
pub number: Option<SectionNumber>,
/// Any nested items this chapter may contain.
@ -83,7 +83,7 @@ impl Link {
pub fn new<S: Into<String>, P: AsRef<Path>>(name: S, location: P) -> Link {
Link {
name: name.into(),
location: location.as_ref().to_path_buf(),
location: Some(location.as_ref().to_path_buf()),
number: None,
nested_items: Vec::new(),
}
@ -94,7 +94,7 @@ impl Default for Link {
fn default() -> Self {
Link {
name: String::new(),
location: PathBuf::new(),
location: Some(PathBuf::new()),
number: None,
nested_items: Vec::new(),
}
@ -260,7 +260,7 @@ impl<'a> SummaryParser<'a> {
}
}
Some(Event::Start(Tag::Link(_type, href, _title))) => {
let link = self.parse_link(href.to_string())?;
let link = self.parse_link(href.to_string());
items.push(SummaryItem::Link(link));
}
Some(Event::Rule) => items.push(SummaryItem::Separator),
@ -272,19 +272,21 @@ impl<'a> SummaryParser<'a> {
Ok(items)
}
fn parse_link(&mut self, href: String) -> Result<Link> {
fn parse_link(&mut self, href: String) -> Link {
let link_content = collect_events!(self.stream, end Tag::Link(..));
let name = stringify_events(link_content);
if href.is_empty() {
Err(self.parse_error("You can't have an empty link."))
let path = if href.is_empty() {
None
} else {
Ok(Link {
name,
location: PathBuf::from(href),
number: None,
nested_items: Vec::new(),
})
Some(PathBuf::from(href))
};
Link {
name,
location: path,
number: None,
nested_items: Vec::new(),
}
}
@ -410,7 +412,7 @@ impl<'a> SummaryParser<'a> {
match self.next_event() {
Some(Event::Start(Tag::Paragraph)) => continue,
Some(Event::Start(Tag::Link(_type, href, _title))) => {
let mut link = self.parse_link(href.to_string())?;
let mut link = self.parse_link(href.to_string());
let mut number = parent.clone();
number.0.push(num_existing_items as u32 + 1);
@ -418,7 +420,10 @@ impl<'a> SummaryParser<'a> {
"Found chapter: {} {} ({})",
number,
link.name,
link.location.display()
link.location
.as_ref()
.map(|p| p.to_str().unwrap_or(""))
.unwrap_or("[draft]")
);
link.number = Some(number);
@ -589,12 +594,12 @@ mod tests {
let should_be = vec![
SummaryItem::Link(Link {
name: String::from("First"),
location: PathBuf::from("./first.md"),
location: Some(PathBuf::from("./first.md")),
..Default::default()
}),
SummaryItem::Link(Link {
name: String::from("Second"),
location: PathBuf::from("./second.md"),
location: Some(PathBuf::from("./second.md")),
..Default::default()
}),
];
@ -633,7 +638,7 @@ mod tests {
let src = "[First](./first.md)";
let should_be = Link {
name: String::from("First"),
location: PathBuf::from("./first.md"),
location: Some(PathBuf::from("./first.md")),
..Default::default()
};
@ -645,7 +650,7 @@ mod tests {
other => panic!("Unreachable, {:?}", other),
};
let got = parser.parse_link(href).unwrap();
let got = parser.parse_link(href);
assert_eq!(got, should_be);
}
@ -654,7 +659,7 @@ mod tests {
let src = "- [First](./first.md)\n";
let link = Link {
name: String::from("First"),
location: PathBuf::from("./first.md"),
location: Some(PathBuf::from("./first.md")),
number: Some(SectionNumber(vec![1])),
..Default::default()
};
@ -675,18 +680,18 @@ mod tests {
let should_be = vec![
SummaryItem::Link(Link {
name: String::from("First"),
location: PathBuf::from("./first.md"),
location: Some(PathBuf::from("./first.md")),
number: Some(SectionNumber(vec![1])),
nested_items: vec![SummaryItem::Link(Link {
name: String::from("Nested"),
location: PathBuf::from("./nested.md"),
location: Some(PathBuf::from("./nested.md")),
number: Some(SectionNumber(vec![1, 1])),
nested_items: Vec::new(),
})],
}),
SummaryItem::Link(Link {
name: String::from("Second"),
location: PathBuf::from("./second.md"),
location: Some(PathBuf::from("./second.md")),
number: Some(SectionNumber(vec![2])),
nested_items: Vec::new(),
}),
@ -707,13 +712,13 @@ mod tests {
let should_be = vec![
SummaryItem::Link(Link {
name: String::from("First"),
location: PathBuf::from("./first.md"),
location: Some(PathBuf::from("./first.md")),
number: Some(SectionNumber(vec![1])),
nested_items: Vec::new(),
}),
SummaryItem::Link(Link {
name: String::from("Second"),
location: PathBuf::from("./second.md"),
location: Some(PathBuf::from("./second.md")),
number: Some(SectionNumber(vec![2])),
nested_items: Vec::new(),
}),
@ -737,13 +742,13 @@ mod tests {
let should_be = vec![
SummaryItem::Link(Link {
name: String::from("First"),
location: PathBuf::from("./first.md"),
location: Some(PathBuf::from("./first.md")),
number: Some(SectionNumber(vec![1])),
nested_items: Vec::new(),
}),
SummaryItem::Link(Link {
name: String::from("Second"),
location: PathBuf::from("./second.md"),
location: Some(PathBuf::from("./second.md")),
number: Some(SectionNumber(vec![2])),
nested_items: Vec::new(),
}),
@ -758,13 +763,21 @@ mod tests {
}
#[test]
fn an_empty_link_location_is_an_error() {
fn an_empty_link_location_is_a_draft_chapter() {
let src = "- [Empty]()\n";
let mut parser = SummaryParser::new(src);
parser.stream.next();
let got = parser.parse_numbered();
assert!(got.is_err());
let should_be = vec![SummaryItem::Link(Link {
name: String::from("Empty"),
location: None,
number: Some(SectionNumber(vec![1])),
nested_items: Vec::new(),
})];
assert!(got.is_ok());
assert_eq!(got.unwrap(), should_be);
}
/// Regression test for https://github.com/rust-lang/mdBook/issues/779
@ -776,21 +789,21 @@ mod tests {
let should_be = vec![
SummaryItem::Link(Link {
name: String::from("First"),
location: PathBuf::from("./first.md"),
location: Some(PathBuf::from("./first.md")),
number: Some(SectionNumber(vec![1])),
nested_items: Vec::new(),
}),
SummaryItem::Separator,
SummaryItem::Link(Link {
name: String::from("Second"),
location: PathBuf::from("./second.md"),
location: Some(PathBuf::from("./second.md")),
number: Some(SectionNumber(vec![2])),
nested_items: Vec::new(),
}),
SummaryItem::Separator,
SummaryItem::Link(Link {
name: String::from("Third"),
location: PathBuf::from("./third.md"),
location: Some(PathBuf::from("./third.md")),
number: Some(SectionNumber(vec![3])),
nested_items: Vec::new(),
}),

View File

@ -29,13 +29,15 @@ impl Preprocessor for IndexPreprocessor {
let source_dir = ctx.root.join(&ctx.config.book.src);
book.for_each_mut(|section: &mut BookItem| {
if let BookItem::Chapter(ref mut ch) = *section {
if is_readme_file(&ch.path) {
let index_md = source_dir.join(ch.path.with_file_name("index.md"));
if index_md.exists() {
warn_readme_name_conflict(&ch.path, &index_md);
}
if let Some(ref mut path) = ch.path {
if is_readme_file(&path) {
let mut index_md = source_dir.join(path.with_file_name("index.md"));
if index_md.exists() {
warn_readme_name_conflict(&path, &&mut index_md);
}
ch.path.set_file_name("index.md");
path.set_file_name("index.md");
}
}
}
});

View File

@ -45,14 +45,15 @@ impl Preprocessor for LinkPreprocessor {
book.for_each_mut(|section: &mut BookItem| {
if let BookItem::Chapter(ref mut ch) = *section {
let base = ch
.path
.parent()
.map(|dir| src_dir.join(dir))
.expect("All book items have a parent");
if let Some(ref chapter_path) = ch.path {
let base = chapter_path
.parent()
.map(|dir| src_dir.join(dir))
.expect("All book items have a parent");
let content = replace_all(&ch.content, base, &ch.path, 0);
ch.content = content;
let content = replace_all(&ch.content, base, chapter_path, 0);
ch.content = content;
}
}
});

View File

@ -30,77 +30,80 @@ impl HtmlHandlebars {
print_content: &mut String,
) -> Result<()> {
// FIXME: This should be made DRY-er and rely less on mutable state
if let BookItem::Chapter(ref ch) = *item {
let content = ch.content.clone();
let content = utils::render_markdown(&content, ctx.html_config.curly_quotes);
let fixed_content = utils::render_markdown_with_path(
&ch.content,
ctx.html_config.curly_quotes,
Some(&ch.path),
);
print_content.push_str(&fixed_content);
let (ch, path) = match item {
BookItem::Chapter(ch) if !ch.is_draft_chapter() => (ch, ch.path.as_ref().unwrap()),
_ => return Ok(()),
};
// Update the context with data for this file
let path = ch
.path
.to_str()
.chain_err(|| "Could not convert path to str")?;
let filepath = Path::new(&ch.path).with_extension("html");
let content = ch.content.clone();
let content = utils::render_markdown(&content, ctx.html_config.curly_quotes);
// "print.html" is used for the print page.
if ch.path == Path::new("print.md") {
bail!(ErrorKind::ReservedFilenameError(ch.path.clone()));
};
let fixed_content = utils::render_markdown_with_path(
&ch.content,
ctx.html_config.curly_quotes,
Some(&path),
);
print_content.push_str(&fixed_content);
// Non-lexical lifetimes needed :'(
let title: String;
{
let book_title = ctx
.data
.get("book_title")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
// Update the context with data for this file
let ctx_path = path
.to_str()
.chain_err(|| "Could not convert path to str")?;
let filepath = Path::new(&ctx_path).with_extension("html");
title = match book_title {
"" => ch.name.clone(),
_ => ch.name.clone() + " - " + book_title,
}
// "print.html" is used for the print page.
if path == Path::new("print.md") {
bail!(ErrorKind::ReservedFilenameError(path.clone()));
};
// Non-lexical lifetimes needed :'(
let title: String;
{
let book_title = ctx
.data
.get("book_title")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
title = match book_title {
"" => ch.name.clone(),
_ => ch.name.clone() + " - " + book_title,
}
}
ctx.data.insert("path".to_owned(), json!(path));
ctx.data.insert("content".to_owned(), json!(content));
ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
ctx.data.insert("title".to_owned(), json!(title));
ctx.data.insert(
"path_to_root".to_owned(),
json!(utils::fs::path_to_root(&ch.path)),
);
if let Some(ref section) = ch.number {
ctx.data
.insert("section".to_owned(), json!(section.to_string()));
}
ctx.data.insert("path".to_owned(), json!(path));
ctx.data.insert("content".to_owned(), json!(content));
ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
ctx.data.insert("title".to_owned(), json!(title));
ctx.data.insert(
"path_to_root".to_owned(),
json!(utils::fs::path_to_root(&path)),
);
if let Some(ref section) = ch.number {
ctx.data
.insert("section".to_owned(), json!(section.to_string()));
}
// Render the handlebars template with the data
debug!("Render template");
let rendered = ctx.handlebars.render("index", &ctx.data)?;
// Render the handlebars template with the data
debug!("Render template");
let rendered = ctx.handlebars.render("index", &ctx.data)?;
let rendered = self.post_process(rendered, &ctx.html_config.playpen, ctx.edition);
let rendered = self.post_process(rendered, &ctx.html_config.playpen, ctx.edition);
// Write to file
debug!("Creating {}", filepath.display());
utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?;
// Write to file
debug!("Creating {}", filepath.display());
utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?;
if ctx.is_index {
ctx.data.insert("path".to_owned(), json!("index.md"));
ctx.data.insert("path_to_root".to_owned(), json!(""));
ctx.data.insert("is_index".to_owned(), json!("true"));
let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
let rendered_index =
self.post_process(rendered_index, &ctx.html_config.playpen, ctx.edition);
debug!("Creating index.html from {}", path);
utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?;
}
if ctx.is_index {
ctx.data.insert("path".to_owned(), json!("index.md"));
ctx.data.insert("path_to_root".to_owned(), json!(""));
ctx.data.insert("is_index".to_owned(), json!("true"));
let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
let rendered_index =
self.post_process(rendered_index, &ctx.html_config.playpen, ctx.edition);
debug!("Creating index.html from {}", ctx_path);
utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?;
}
Ok(())
@ -526,11 +529,12 @@ fn make_data(
);
chapter.insert("name".to_owned(), json!(ch.name));
let path = ch
.path
.to_str()
.chain_err(|| "Could not convert path to str")?;
chapter.insert("path".to_owned(), json!(path));
if let Some(ref path) = ch.path {
let p = path
.to_str()
.chain_err(|| "Could not convert path to str")?;
chapter.insert("path".to_owned(), json!(p));
}
}
BookItem::Separator => {
chapter.insert("spacer".to_owned(), json!("_spacer_"));

View File

@ -71,11 +71,15 @@ fn render_item(
item: &BookItem,
) -> Result<()> {
let chapter = match *item {
BookItem::Chapter(ref ch) => ch,
BookItem::Chapter(ref ch) if !ch.is_draft_chapter() => ch,
_ => return Ok(()),
};
let filepath = Path::new(&chapter.path).with_extension("html");
let chapter_path = chapter
.path
.as_ref()
.expect("Checked that path exists above");
let filepath = Path::new(&chapter_path).with_extension("html");
let filepath = filepath
.to_str()
.chain_err(|| "Could not convert HTML path to str")?;

View File

@ -34,7 +34,13 @@ impl Renderer for MarkdownRenderer {
trace!("markdown render");
for item in book.iter() {
if let BookItem::Chapter(ref ch) = *item {
utils::fs::write_file(&ctx.destination, &ch.path, ch.content.as_bytes())?;
if !ch.is_draft_chapter() {
utils::fs::write_file(
&ctx.destination,
&ch.path.as_ref().expect("Checked path exists before"),
ch.content.as_bytes(),
)?;
}
}
}