big refactor

master
Ondřej Hruška 6 years ago
parent afeac2377f
commit 8fb340395f
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 1
      Cargo.lock
  2. 2
      Cargo.toml
  3. 11
      src/hash_dict.rs
  4. 470
      src/main.rs
  5. 6
      web/assets/style.css
  6. BIN
      web/data/2019-02-13/2019-02-13 21.56.35.jpg
  7. 1
      web/data/2019-02-13/note.txt
  8. BIN
      web/data/2019-02-18/bread.jpg
  9. 3
      web/data/2019-02-18/note.txt
  10. 2
      web/templates/detail.html

1
Cargo.lock generated

@ -82,6 +82,7 @@ dependencies = [
"base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", "base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)",
"blake2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "blake2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
"chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
"failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
"image 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "image 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)",
"image-utils 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "image-utils 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
"markdown 0.2.0 (git+https://github.com/johannhof/markdown.rs)", "markdown 0.2.0 (git+https://github.com/johannhof/markdown.rs)",

@ -13,3 +13,5 @@ image-utils = "0.2.0"
image = "0.21.0" image = "0.21.0"
blake2 = "0.8.0" blake2 = "0.8.0"
base64 = "0.10.1" base64 = "0.10.1"
failure = "0.1.5"

@ -1,8 +1,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::fs::File;
use std::io;
use std::io::Read; use std::io::Read;
use std::io::Write; use std::io::Write;
use std::io;
use std::path::PathBuf; use std::path::PathBuf;
// file-stored hash map used to prevent needless image regenerating // file-stored hash map used to prevent needless image regenerating
@ -14,7 +14,7 @@ pub struct HashDict {
} }
impl HashDict { impl HashDict {
pub fn load(path: PathBuf) -> Result<HashDict, io::Error> { pub fn load(path: PathBuf) -> Result<HashDict, io::Error> {
let mut hd = HashDict { let mut hd = HashDict {
hashes: HashMap::new(), hashes: HashMap::new(),
path, path,
@ -33,18 +33,19 @@ impl HashDict {
for l in lines { for l in lines {
let halves: Vec<&str> = l.split("\t").collect(); let halves: Vec<&str> = l.split("\t").collect();
if halves.len() == 2 { if halves.len() == 2 {
hd.hashes.insert(halves[0].to_string(), halves[1].to_string()); hd.hashes
.insert(halves[0].to_string(), halves[1].to_string());
} }
} }
Ok(hd) Ok(hd)
} }
pub fn get(&self, key : &str) -> Option<&String> { pub fn get(&self, key: &str) -> Option<&String> {
self.hashes.get(key) self.hashes.get(key)
} }
pub fn put(&mut self, key : String, value: String) { pub fn put(&mut self, key: String, value: String) {
self.hashes.insert(key, value); self.hashes.insert(key, value);
self.any_change = true; self.any_change = true;
} }

@ -1,26 +1,38 @@
use blake2::{Blake2b, Digest};
use chrono;
use chrono::offset::TimeZone;
use chrono::Date;
use chrono::NaiveDate;
use chrono::Utc;
use failure::Fallible;
use image_utils;
use markdown;
use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET};
use rss::{Channel, ChannelBuilder, Guid, ItemBuilder};
use std::env; use std::env;
use std::io;
use std::fs; use std::fs;
use std::fs::DirEntry; use std::fs::DirEntry;
use std::fs::File; use std::fs::File;
use std::fs::OpenOptions;
use std::io;
use std::io::prelude::*; use std::io::prelude::*;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono;
use chrono::NaiveDate;
use markdown;
use std::fs::OpenOptions;
use rss::{Channel, ChannelBuilder, Item, ItemBuilder, Guid};
use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET};
use image_utils;
use chrono::offset::TimeZone;
use chrono::Date;
use chrono::Utc;
use blake2::{Digest,Blake2b};
use base64; use base64;
use std::collections::HashMap;
mod hash_dict; mod hash_dict;
#[derive(Debug)]
struct BreadRendered {
thumb: String,
detail: String,
rss_item: Option<rss::Item>,
title: String,
url: String,
detail_fname: String,
}
#[derive(Debug)] #[derive(Debug)]
struct Bread { struct Bread {
path: PathBuf, path: PathBuf,
@ -29,243 +41,363 @@ struct Bread {
note: String, note: String,
rss_note: String, rss_note: String,
images: Vec<PathBuf>, images: Vec<PathBuf>,
rendered: BreadRendered,
} }
impl Bread { //impl From<std::option::NoneError> for failure::Error {
fn thumb_photo(&self) -> (&str, &str) { // fn from(x: std::option::NoneError) -> Self {
let mut first_img : &PathBuf = self.images.get(0).unwrap(); // failure::err_msg("Expected something, found nothing.")
// }
for im in &self.images { //}
if im.file_name().unwrap().to_str().unwrap().contains("cover") {
first_img = im;
break;
}
}
let img_path = first_img.to_str().unwrap();
let img_alt = first_img.file_name().unwrap().to_str().unwrap();
(img_path, img_alt)
}
fn parse(base_dir : &PathBuf, bread_dir : &DirEntry) -> Result<Bread, std::io::Error> {
let bpath = bread_dir.path();
let mut note = String::new();
let mut rss_note = String::new();
let mut note_path = bpath.join("note.txt");
let mut rss_note_path = bpath.join("rss.txt");
// try a md one as a fallback
if !note_path.exists() {
note_path = bpath.join("note.md");
}
if !rss_note_path.exists() {
rss_note_path = bpath.join("rss.md");
}
if note_path.exists() {
let mut note_file = File::open(note_path)?;
note_file.read_to_string(&mut note)?;
note = markdown::to_html(&note);
}
if rss_note_path.exists() { #[derive(Debug)]
let mut note_file = File::open(rss_note_path)?; struct BreadLink {
note_file.read_to_string(&mut rss_note)?; label: String,
rss_note = markdown::to_html(&rss_note); url: String,
}
let mut bread_files: Vec<DirEntry> = fs::read_dir(&bpath)?.map(|e| e.unwrap()).collect();
bread_files.sort_by(|x, y| x.file_name().cmp(&y.file_name()));
let images = bread_files.iter().filter(|&f| {
let fname = f.file_name();
let name = fname.to_str().unwrap();
return
name.ends_with(".png") ||
name.ends_with(".jpg") ||
name.ends_with(".jpeg") ||
name.ends_with(".gif");
}).map(|x| x.path().strip_prefix(base_dir).unwrap().to_path_buf()).collect();
return Ok(Bread {
date: NaiveDate::parse_from_str(bpath.file_name().unwrap().to_str().unwrap(), "%Y-%m-%d").unwrap(),
rel_path: bpath.strip_prefix(base_dir).unwrap().to_path_buf(),
path: bpath,
note,
rss_note,
images
});
}
} }
fn main() { impl Bread {
let cwd = env::current_dir().unwrap(); fn compile(&mut self, config: &mut GalleryConfig, prev : Option<BreadLink>, next : Option<BreadLink>) -> Fallible<()> {
let web_path = Path::new(&cwd).join("web"); let date = self.date.format("%Y/%m/%d").to_string();
let data_path = web_path.join("data"); let date_slug = self.date.format("%Y-%m-%d").to_string();
let tpl_path = web_path.join("templates");
let thumbs_path = web_path.join("thumbs");
let mut bread_dirs: Vec<DirEntry> = fs::read_dir(&data_path).unwrap().map(|e| e.unwrap()).collect();
bread_dirs.sort_by(|x, y| x.file_name().cmp(&y.file_name()));
let mut breads : Vec<Bread> = Vec::new();
for bread_dir in bread_dirs {
if let Ok(b) = Bread::parse(&web_path, &bread_dir) {
breads.push(b);
}
}
let mut main_tpl = String::new();
let mut thumb_tpl = String::new();
let mut detail_tpl = String::new();
let mut head_tpl = String::new();
File::open(tpl_path.join("index.html")).unwrap().read_to_string(&mut main_tpl).unwrap();
File::open(tpl_path.join("_thumb.html")).unwrap().read_to_string(&mut thumb_tpl).unwrap();
File::open(tpl_path.join("_head.html")).unwrap().read_to_string(&mut head_tpl).unwrap();
File::open(tpl_path.join("detail.html")).unwrap().read_to_string(&mut detail_tpl).unwrap();
let mut thumbs = Vec::<String>::new();
let mut channel : Channel = ChannelBuilder::default()
.title("Piggo's Bread Gallery")
.link("https://www.ondrovo.com/bread")
.description("Sourdough feed")
.build()
.unwrap();
let mut channel_items = Vec::<Item>::new();
let mut hashes = hash_dict::HashDict::load(cwd.join(".hashes.txt")).unwrap();
// TODO separate thumbs by year and generate per-year pages
// TODO limit RSS to last N breads
for bread in &breads {
let date = bread.date.format("%Y/%m/%d").to_string();
let date_slug = bread.date.format("%Y-%m-%d").to_string();
let detail_file = date_slug.clone() + ".html"; let detail_file = date_slug.clone() + ".html";
println!("+ {}", date_slug); println!("+ {}", date_slug);
let (img_path, img_alt) = bread.thumb_photo(); self.rendered.title = date.clone();
let note = if bread.note.is_empty() { "<i>There's no note about this bread.</i>" } else { &bread.note }; self.rendered.detail_fname = detail_file.clone();
let rss_note = &bread.rss_note;
// figure out the thumbnail pic
let (img_path, img_alt) = {
let mut first_img: &PathBuf = self.images.get(0).expect(&format!("No images for bread {}", date_slug));
for im in &self.images {
if im.file_name().unwrap().to_str().unwrap().contains("cover") {
first_img = im;
break;
}
}
(
first_img.to_str().unwrap().to_owned(),
first_img.file_name().unwrap().to_str().unwrap().to_owned(),
)
};
let note = if self.note.is_empty() {
"<i>There's no note about this bread.</i>"
} else {
&self.note
};
let thumb_fname = date_slug.clone() + "." + Path::new(&img_path).extension().unwrap().to_str().unwrap(); let thumb_fname = date_slug.clone() + "." + Path::new(&img_path).extension().unwrap().to_str().unwrap();
let thumb_path = thumbs_path.join(&thumb_fname); let thumb_path = config.thumbs_path.join(&thumb_fname);
let thumb_relpath = thumb_path.strip_prefix(&web_path).unwrap(); let thumb_relpath = thumb_path.strip_prefix(&config.web_path)?;
let image_path_encoded = utf8_percent_encode(thumb_relpath.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string(); let image_path_encoded =
utf8_percent_encode(thumb_relpath.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string();
// TODO keep the original path in bread so we dont have to reconstruct it here let image_real_path = config.web_path.join(img_path);
let image_real_path = web_path.join(img_path);
// Create the thumb // Create the thumb
{ {
let mut img_file = fs::File::open(&image_real_path).unwrap(); let mut img_file = fs::File::open(&image_real_path)?;
let mut hasher = Blake2b::new(); let mut hasher = Blake2b::new();
io::copy(&mut img_file, &mut hasher).unwrap(); io::copy(&mut img_file, &mut hasher)?;
let hash = base64::encode(&hasher.result()); let hash = base64::encode(&hasher.result());
let hash_key = thumb_path.to_str().unwrap(); let hash_key = thumb_path.to_str().unwrap();
let old_hash = hashes.get(hash_key); let old_hash = config.image_hashes.get(hash_key);
if old_hash.is_none() || !old_hash.unwrap().eq(&hash) { if old_hash.is_none() || !old_hash.unwrap().eq(&hash) {
println!("building thumb..."); println!("building thumb...");
let im = image::open(&image_real_path).unwrap(); let im = image::open(&image_real_path)?;
let im = im.thumbnail(500, 500); let im = im.thumbnail(500, 500);
im.save(&thumb_path).unwrap(); im.save(&thumb_path)?;
hashes.put(hash_key.to_string(), hash); config.image_hashes.put(hash_key.to_string(), hash);
} }
} }
// Prepare the thumb card for the gallery page // Prepare the thumb card for the gallery page
{ {
let thumb = thumb_tpl self.rendered.thumb = config
.template("_thumb.html")?
.replace("{detail_url}", &detail_file) .replace("{detail_url}", &detail_file)
.replace("{img_src}", &image_path_encoded) .replace("{img_src}", &image_path_encoded)
.replace("{img_alt}", &img_alt) .replace("{img_alt}", &img_alt)
.replace("{title}", &date); .replace("{title}", &date);
thumbs.push(thumb);
} }
// Add to RSS // Add to RSS
{ {
let image_url : String = channel.link().to_string() + "/" + &image_path_encoded; let image_url: String = config.base_url.to_owned() + "/" + &image_path_encoded;
let link : String = channel.link().to_string() + "/" + &detail_file; let link: String = config.base_url.to_owned() + "/" + &detail_file;
let mut guid = Guid::default(); let mut guid = Guid::default();
guid.set_value(link.clone()); guid.set_value(link.clone());
guid.set_permalink(true); guid.set_permalink(true);
let date_formatted : Date<Utc> = chrono::Utc.from_local_date(&bread.date).unwrap(); let date_formatted: Date<Utc> = chrono::Utc.from_local_date(&self.date).unwrap();
let dt = date_formatted.and_hms(12,0,0); let dt = date_formatted.and_hms(12, 0, 0);
let mut descr = String::new(); let mut descr = String::new();
if !rss_note.is_empty() { if !self.rss_note.is_empty() {
descr.push_str(&(rss_note.to_string() + "<hr>")); descr.push_str(&self.rss_note);
descr.push_str("<hr>");
} }
descr.push_str(note); descr.push_str(note);
descr.push_str(&format!("<img src=\"{}\" alt=\"{}\"><p><i>Open the link for full-res photos ({} total)</i>", image_url, img_alt, bread.images.len())); descr.push_str(&format!(
"<img src=\"{}\" alt=\"{}\"><p><i>Open the link for full-res photos ({} total)</i>",
image_url,
img_alt,
self.images.len()
));
self.rendered.url = link.clone();
channel_items.push(ItemBuilder::default() let item: rss::Item = ItemBuilder::default()
.title(date.clone()) .title(date.clone())
.link(link.clone()) .link(link)
.description(descr) .description(descr)
.guid(guid) .guid(guid)
.pub_date(dt.to_rfc2822()) .pub_date(dt.to_rfc2822())
.build().unwrap()); .build().unwrap();
self.rendered.rss_item = Some(item);
} }
let head_tpl = config.template("_head.html")?;
// Generate the detail page // Generate the detail page
{ {
let win_title = format!("Bread from {}", date); let win_title = format!("Bread from {}", date);
let detail = detail_tpl let detail = config
.template("detail.html")?
.replace("{head}", &head_tpl.replace("{title}", &win_title)) .replace("{head}", &head_tpl.replace("{title}", &win_title))
.replace("{title}", &win_title) .replace("{title}", &win_title)
.replace("{date}", &date_slug) .replace("{date}", &date_slug)
.replace("{url}", &format!("https://www.ondrovo.com/bread/{}", detail_file)) .replace("{url}", &format!("{}/{}", config.base_url, detail_file))
.replace("{thumb_url}", &format!("https://www.ondrovo.com/bread/thumbs/{}", thumb_fname)) .replace(
"{thumb_url}",
&format!("{}/thumbs/{}", config.base_url, thumb_fname),
)
.replace("{heading}", &date) .replace("{heading}", &date)
.replace("{prev}", &(match prev {
Some(b) => format!("<a class=\"prev\" href=\"{}\" title=\"{}\">&lt;</a>", b.url, b.label),
None => "".to_string()
}))
.replace("{next}", &(match next {
Some(b) => format!("<a class=\"next\" href=\"{}\" title=\"{}\">&gt;</a>", b.url, b.label),
None => "".to_string()
}))
.replace("{note}", note.trim()); .replace("{note}", note.trim());
let mut pics = String::new(); let mut pics = String::new();
for img in &bread.images { for img in &self.images {
pics.push_str(&format!(" <a href=\"{src}\"><img src=\"{src}\"></a>\n", src=&utf8_percent_encode(img.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string())) pics.push_str(&format!(
" <a href=\"{src}\"><img src=\"{src}\"></a>\n",
src = &utf8_percent_encode(img.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string()
))
} }
let detail = detail.replace("{images}", &pics.trim()); let detail = detail.replace("{images}", &pics.trim());
let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(web_path.join(detail_file)).unwrap(); let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(config.web_path.join(detail_file)).unwrap();
f.write(detail.as_bytes()).unwrap(); f.write(detail.as_bytes()).unwrap();
self.rendered.detail = detail;
} }
Ok(())
} }
hashes.save(); fn to_link(&self) -> BreadLink {
BreadLink {
label: self.date.format("%Y/%m/%d").to_string(),
url: self.date.format("%Y-%m-%d.html").to_string(),
}
}
{ fn parse(base_dir: &PathBuf, bread_dir: &DirEntry) -> Result<Bread, std::io::Error> {
// make thumbs go from the newest to the oldest let bpath = bread_dir.path();
thumbs.reverse(); let mut note = String::new();
let mut rss_note = String::new();
println!("Building the gallery page"); let mut note_path = bpath.join("note.txt");
let main = main_tpl.replace("{breads}", &thumbs.join("").trim()) let mut rss_note_path = bpath.join("rss.txt");
.replace("{head}", &head_tpl.replace("{title}", "Piggo's breads").trim());
let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(web_path.join("index.html")).unwrap(); // try a md one as a fallback
f.write(main.as_bytes()).unwrap(); if !note_path.exists() {
note_path = bpath.join("note.md");
}
if !rss_note_path.exists() {
rss_note_path = bpath.join("rss.md");
}
if note_path.exists() {
let mut note_file = File::open(note_path)?;
note_file.read_to_string(&mut note)?;
note = markdown::to_html(&note);
}
if rss_note_path.exists() {
let mut note_file = File::open(rss_note_path)?;
note_file.read_to_string(&mut rss_note)?;
rss_note = markdown::to_html(&rss_note);
}
let mut bread_files: Vec<DirEntry> = fs::read_dir(&bpath)?.map(|e| e.unwrap()).collect();
bread_files.sort_by(|x, y| x.file_name().cmp(&y.file_name()));
let images = bread_files
.iter()
.filter(|&f| {
let fname = f.file_name();
let name = fname.to_str().unwrap();
return name.ends_with(".png")
|| name.ends_with(".jpg")
|| name.ends_with(".jpeg")
|| name.ends_with(".gif");
})
.map(|x| x.path().strip_prefix(base_dir).unwrap().to_path_buf())
.collect();
return Ok(Bread {
date: NaiveDate::parse_from_str(
bpath.file_name().unwrap().to_str().unwrap(),
"%Y-%m-%d",
)
.unwrap(),
rel_path: bpath.strip_prefix(base_dir).unwrap().to_path_buf(),
path: bpath,
note,
rss_note,
images,
rendered: BreadRendered {
thumb: "".to_string(),
detail: "".to_string(),
rss_item: None,
title: "".to_string(),
url: "".to_string(),
detail_fname: "".to_string()
},
});
}
}
struct GalleryConfig {
web_path: PathBuf,
data_path: PathBuf,
tpl_path: PathBuf,
thumbs_path: PathBuf,
base_url: String,
templates: HashMap<String, String>,
image_hashes: hash_dict::HashDict,
}
impl GalleryConfig {
fn template(&mut self, name: &str) -> Fallible<String> {
if let Some(text) = self.templates.get(name) {
return Ok(text.clone());
}
let mut tpl = String::new();
File::open(self.tpl_path.join(name))?.read_to_string(&mut tpl)?;
self.templates.insert(name.to_string(), tpl.clone());
Ok(tpl)
}
}
fn main() -> Fallible<()> {
let mut config = {
let cwd = env::current_dir().unwrap();
let web_path = cwd.join("web");
let data_path = web_path.join("data");
let tpl_path = web_path.join("templates");
let thumbs_path = web_path.join("thumbs");
GalleryConfig {
web_path,
data_path,
tpl_path,
thumbs_path,
base_url: "https://www.ondrovo.com/bread".into(),
templates: HashMap::new(),
image_hashes: hash_dict::HashDict::load(cwd.join(".hashes.txt"))?,
}
};
let mut bread_dirs: Vec<DirEntry> = fs::read_dir(&config.data_path)?.filter_map(|e| e.ok()).collect();
bread_dirs.sort_by(|x, y| x.file_name().cmp(&y.file_name()));
let mut breads: Vec<Bread> = bread_dirs
.iter()
.filter_map(|p| Bread::parse(&config.web_path, &p).ok())
.collect();
for i in 0..breads.len() {
let preceding = if i <= 0 { None } else {
match breads.get(i - 1) {
Some(b) => Some(b.to_link()),
None => None
}
};
let following = match breads.get(i + 1) {
Some(b) => Some(b.to_link()),
None => None
};
let cur = breads.get_mut(i).unwrap();
cur.compile(&mut config, preceding, following)?;
} }
let mut channel: Channel = ChannelBuilder::default()
.title("Piggo's Bread Gallery")
.link("https://www.ondrovo.com/bread")
.description("Sourdough feed")
.build()
.unwrap();
config.image_hashes.save();
let mut start = breads.len() as i32 - 10;
if start < 0 {
start = 0;
}
// rss
{ {
let mut channel_items = vec![];
for b in &breads[start as usize..] {
channel_items.push(b.rendered.rss_item.clone().unwrap());
}
println!("Generating feed..."); println!("Generating feed...");
let f = OpenOptions::new().write(true).truncate(true).create(true).open(web_path.join("feed.xml")).unwrap(); let f = OpenOptions::new().write(true).truncate(true).create(true).open(config.web_path.join("feed.xml")).unwrap();
channel.set_items(channel_items); channel.set_items(channel_items);
channel.pretty_write_to(f, b' ', 2).unwrap(); channel.pretty_write_to(f, b' ', 2).unwrap();
} }
// main page
{
// make thumbs go from the newest to the oldest
breads.reverse();
let mut thumbs_buf = String::new();
for b in &breads {
thumbs_buf.push_str(&b.rendered.thumb);
}
println!("Building the gallery page");
let main = config.template("index.html")?.replace("{breads}", &thumbs_buf.trim())
.replace("{head}", config.template("_head.html")?.replace("{title}", "Piggo's breads").trim());
let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(config.web_path.join("index.html")).unwrap();
f.write(main.as_bytes()).unwrap();
}
Ok(())
} }

@ -94,3 +94,9 @@ a {
#crumb a:hover { #crumb a:hover {
color: #ccc; color: #ccc;
} }
.prev, .next {
font-size: 90%;
padding: 0 1em;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@ -0,0 +1 @@
a little flat but A+ taste and cronch

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@ -0,0 +1,3 @@
The previous bread came out a bit tough. I run out of bread flour then, so it was made from fine flour. May be why.
This one is almost perfect, but it's still turning tough quickly. I'm investigating ways to keep it fresh longer... If you have a tip, please let me know!

@ -25,7 +25,7 @@
<meta itemprop="image" content="{thumb_url}"> <meta itemprop="image" content="{thumb_url}">
</head> </head>
<body> <body>
<h1>{heading}</h1> <h1>{prev}<a href="index.html">{heading}</a>{next}</h1>
<section class="BreadDetail"> <section class="BreadDetail">
<div class="note"> <div class="note">

Loading…
Cancel
Save