Merge pull request #65 from Keats/improvements

Improvements
This commit is contained in:
Vincent Prouillet 2017-05-15 12:56:13 +09:00 committed by GitHub
commit 9a7244b35f
47 changed files with 1806 additions and 1277 deletions

View file

@ -4,11 +4,14 @@
- Fix XML templates overriding and reloading
- `title` and `description` are now optional in the front matter
- Add GenericConfig, Vim syntax
- Add GenericConfig, Vim, Jinja2 syntax
- Add `_index.md` for homepage as well and make that into a normal section
- Allow sorting by `none`, `date` and `order` for sections
- Add pagination
- Add a `get_page` global function to tera
- Revamp index page, no more `pages` variables
- Fix livereload stopping randomly
- Smarter re-rendering in `serve` command
## 0.0.4 (2017-04-23)

10
Cargo.lock generated
View file

@ -68,7 +68,7 @@ name = "backtrace-sys"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"gcc 0.3.45 (registry+https://github.com/rust-lang/crates.io-index)",
"gcc 0.3.46 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.22 (registry+https://github.com/rust-lang/crates.io-index)",
]
@ -173,7 +173,7 @@ name = "cmake"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"gcc 0.3.45 (registry+https://github.com/rust-lang/crates.io-index)",
"gcc 0.3.46 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@ -257,7 +257,7 @@ dependencies = [
[[package]]
name = "gcc"
version = "0.3.45"
version = "0.3.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
@ -408,7 +408,7 @@ name = "miniz-sys"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"gcc 0.3.45 (registry+https://github.com/rust-lang/crates.io-index)",
"gcc 0.3.46 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.22 (registry+https://github.com/rust-lang/crates.io-index)",
]
@ -1096,7 +1096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
"checksum fnv 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)" = "6cc484842f1e2884faf56f529f960cc12ad8c71ce96cc7abba0a067c98fee344"
"checksum fsevent 0.2.16 (registry+https://github.com/rust-lang/crates.io-index)" = "dfe593ebcfc76884138b25426999890b10da8e6a46d01b499d7c54c604672c38"
"checksum fsevent-sys 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "1a772d36c338d07a032d5375a36f15f9a7043bf0cb8ce7cee658e037c6032874"
"checksum gcc 0.3.45 (registry+https://github.com/rust-lang/crates.io-index)" = "40899336fb50db0c78710f53e87afc54d8c7266fb76262fecc78ca1a7f09deae"
"checksum gcc 0.3.46 (registry+https://github.com/rust-lang/crates.io-index)" = "181e3cebba1d663bd92eb90e2da787e10597e027eb00de8d742b260a7850948f"
"checksum getopts 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)" = "d9047cfbd08a437050b363d35ef160452c5fe8ea5187ae0a624708c91581d685"
"checksum glob 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)" = "8be18de09a56b60ed0edf84bc9df007e30040691af7acd1c41874faac5895bfb"
"checksum httparse 1.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "77f756bed9ee3a83ce98774f4155b42a31b787029013f3a7d83eca714e500e21"

View file

@ -3,11 +3,12 @@ use std::env;
use gutenberg::errors::Result;
use gutenberg::Site;
use console;
pub fn build(config_file: &str) -> Result<()> {
let mut site = Site::new(env::current_dir().unwrap(), config_file)?;
site.load()?;
super::notify_site_size(&site);
super::warn_about_ignored_pages(&site);
console::notify_site_size(&site);
console::warn_about_ignored_pages(&site);
site.build()
}

View file

@ -4,7 +4,7 @@
_ref = require('./protocol'), Parser = _ref.Parser, PROTOCOL_6 = _ref.PROTOCOL_6, PROTOCOL_7 = _ref.PROTOCOL_7;
Version = '2.2.2';
Version = '2.2.1';
exports.Connector = Connector = (function() {
function Connector(options, WebSocket, Timer, handlers) {
@ -12,7 +12,7 @@
this.WebSocket = WebSocket;
this.Timer = Timer;
this.handlers = handlers;
this._uri = "ws" + (this.options.https ? "s" : "") + "://" + this.options.host + ":" + this.options.port + "/livereload";
this._uri = "ws://" + this.options.host + ":" + this.options.port + "/livereload";
this._nextDelay = this.options.mindelay;
this._connectionDesired = false;
this.protocol = 0;
@ -278,8 +278,7 @@
},{}],4:[function(require,module,exports){
(function() {
var Connector, LiveReload, Options, Reloader, Timer,
__hasProp = {}.hasOwnProperty;
var Connector, LiveReload, Options, Reloader, Timer;
Connector = require('./connector').Connector;
@ -291,15 +290,11 @@
exports.LiveReload = LiveReload = (function() {
function LiveReload(window) {
var k, v, _ref;
this.window = window;
this.listeners = {};
this.plugins = [];
this.pluginIdentifiers = {};
this.console = this.window.console && this.window.console.log && this.window.console.error ? this.window.location.href.match(/LR-verbose/) ? this.window.console : {
log: function() {},
error: this.window.console.error.bind(this.window.console)
} : {
this.console = this.window.location.href.match(/LR-verbose/) && this.window.console && this.window.console.log && this.window.console.error ? this.window.console : {
log: function() {},
error: function() {}
};
@ -307,20 +302,9 @@
this.console.error("LiveReload disabled because the browser does not seem to support web sockets");
return;
}
if ('LiveReloadOptions' in window) {
this.options = new Options();
_ref = window['LiveReloadOptions'];
for (k in _ref) {
if (!__hasProp.call(_ref, k)) continue;
v = _ref[k];
this.options.set(k, v);
}
} else {
this.options = Options.extract(this.window.document);
if (!this.options) {
this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");
return;
}
if (!(this.options = Options.extract(this.window.document))) {
this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");
return;
}
this.reloader = new Reloader(this.window, this.console, Timer);
this.connector = new Connector(this.options, this.WebSocket, Timer, {
@ -342,7 +326,16 @@
})(this),
error: (function(_this) {
return function(e) {
return console.log("" + e.message + ".");
console.log(e);
// if (e instanceof ProtocolError) {
// if (typeof console !== "undefined" && console !== null) {
// return console.log("" + e.message + ".");
// }
// } else {
// if (typeof console !== "undefined" && console !== null) {
// return console.log("LiveReload internal error: " + e.message);
// }
// }
};
})(this),
disconnected: (function(_this) {
@ -380,7 +373,6 @@
};
})(this)
});
this.initialized = true;
}
LiveReload.prototype.on = function(eventName, handler) {
@ -409,9 +401,6 @@
LiveReload.prototype.shutDown = function() {
var _base;
if (!this.initialized) {
return;
}
this.connector.disconnect();
this.log("LiveReload disconnected.");
return typeof (_base = this.listeners).shutdown === "function" ? _base.shutdown() : void 0;
@ -423,9 +412,6 @@
LiveReload.prototype.addPlugin = function(pluginClass) {
var plugin;
if (!this.initialized) {
return;
}
if (this.hasPlugin(pluginClass.identifier)) {
return;
}
@ -448,9 +434,6 @@
LiveReload.prototype.analyze = function() {
var plugin, pluginData, pluginsData, _i, _len, _ref;
if (!this.initialized) {
return;
}
if (!(this.connector.protocol >= 7)) {
return;
}
@ -480,7 +463,6 @@
exports.Options = Options = (function() {
function Options() {
this.https = false;
this.host = null;
this.port = 35729;
this.snipver = null;
@ -512,7 +494,6 @@
element = _ref[_i];
if ((src = element.src) && (m = src.match(/^[^:]+:\/\/(.*)\/z?livereload\.js(?:\?(.*))?$/))) {
options = new Options();
options.https = src.indexOf("https") === 0;
if (mm = m[1].match(/^([^\/:]+)(?::(\d+))?$/)) {
options.host = mm[1];
if (mm[2]) {
@ -561,7 +542,7 @@
}
Parser.prototype.reset = function() {
return this.protocol = 7;
return this.protocol = null;
};
Parser.prototype.process = function(data) {

7
src/bin/cmd/mod.rs Normal file
View file

@ -0,0 +1,7 @@
mod init;
mod build;
mod serve;
pub use self::init::create_new_project;
pub use self::build::build;
pub use self::serve::serve;

View file

@ -9,14 +9,12 @@ use iron::{Iron, Request, IronResult, Response, status};
use mount::Mount;
use staticfile::Static;
use notify::{Watcher, RecursiveMode, watcher};
use ws::{WebSocket, Sender};
use ws::{WebSocket, Sender, Message};
use gutenberg::Site;
use gutenberg::errors::{Result, ResultExt};
use ::{report_elapsed_time, unravel_errors};
use console;
use rebuild;
#[derive(Debug, PartialEq)]
enum ChangeKind {
@ -47,7 +45,7 @@ fn rebuild_done_handling(broadcaster: &Sender, res: Result<()>, reload_path: &st
}}"#, reload_path)
).unwrap();
},
Err(e) => unravel_errors("Failed to build the site", &e, false)
Err(e) => console::unravel_errors("Failed to build the site", &e)
}
}
@ -67,10 +65,10 @@ pub fn serve(interface: &str, port: &str, config_file: &str) -> Result<()> {
site.load()?;
site.enable_live_reload();
super::notify_site_size(&site);
super::warn_about_ignored_pages(&site);
console::notify_site_size(&site);
console::warn_about_ignored_pages(&site);
site.build()?;
report_elapsed_time(start);
console::report_elapsed_time(start);
// Setup watchers
let (tx, rx) = channel();
@ -93,8 +91,17 @@ pub fn serve(interface: &str, port: &str, config_file: &str) -> Result<()> {
let _iron = Iron::new(mount).http(address.as_str()).unwrap();
// The websocket for livereload
let ws_server = WebSocket::new(|_| {
|_| {
let ws_server = WebSocket::new(|output: Sender| {
move |msg: Message| {
if msg.into_text().unwrap().contains("\"hello\"") {
return output.send(Message::text(r#"
{
"command": "hello",
"protocols": [ "http://livereload.com/protocols/official-7" ],
"serverName": "Gutenberg"
}
"#));
}
Ok(())
}
}).unwrap();
@ -131,12 +138,12 @@ pub fn serve(interface: &str, port: &str, config_file: &str) -> Result<()> {
(ChangeKind::Content, _) => {
console::info(&format!("-> Content changed {}", path.display()));
// Force refresh
rebuild_done_handling(&broadcaster, site.rebuild_after_content_change(&path), "/x.js");
rebuild_done_handling(&broadcaster, rebuild::after_content_change(&mut site, &path), "/x.js");
},
(ChangeKind::Templates, _) => {
console::info(&format!("-> Template changed {}", path.display()));
// Force refresh
rebuild_done_handling(&broadcaster, site.rebuild_after_template_change(&path), "/x.js");
rebuild_done_handling(&broadcaster, rebuild::after_template_change(&mut site, &path), "/x.js");
},
(ChangeKind::StaticFiles, p) => {
if path.is_file() {
@ -145,7 +152,7 @@ pub fn serve(interface: &str, port: &str, config_file: &str) -> Result<()> {
}
},
};
report_elapsed_time(start);
console::report_elapsed_time(start);
}
_ => {}
}

70
src/bin/console.rs Normal file
View file

@ -0,0 +1,70 @@
use std::time::Instant;
use chrono::Duration;
use term_painter::ToStyle;
use term_painter::Color::*;
use gutenberg::errors::Error;
use gutenberg::Site;
pub fn info(message: &str) {
println!("{}", NotSet.bold().paint(message));
}
pub fn warn(message: &str) {
println!("{}", Yellow.bold().paint(message));
}
pub fn success(message: &str) {
println!("{}", Green.bold().paint(message));
}
pub fn error(message: &str) {
println!("{}", Red.bold().paint(message));
}
/// Display in the console the number of pages/sections in the site
pub fn notify_site_size(site: &Site) {
println!(
"-> Creating {} pages ({} orphan) and {} sections",
site.pages.len(),
site.get_all_orphan_pages().len(),
site.sections.len() - 1, // -1 since we do not the index as a section
);
}
/// Display a warning in the console if there are ignored pages in the site
pub fn warn_about_ignored_pages(site: &Site) {
let ignored_pages = site.get_ignored_pages();
if !ignored_pages.is_empty() {
warn(&format!(
"{} page(s) ignored (missing date or order in a sorted section):",
ignored_pages.len()
));
for path in site.get_ignored_pages() {
warn(&format!("- {}", path.display()));
}
}
}
/// Print the time elapsed rounded to 1 decimal
pub fn report_elapsed_time(instant: Instant) {
let duration_ms = Duration::from_std(instant.elapsed()).unwrap().num_milliseconds() as f64;
if duration_ms < 1000.0 {
success(&format!("Done in {}ms.\n", duration_ms));
} else {
let duration_sec = duration_ms / 1000.0;
success(&format!("Done in {:.1}s.\n", ((duration_sec * 10.0).round() / 10.0)));
}
}
/// Display an error message and the actual error(s)
pub fn unravel_errors(message: &str, error: &Error) {
self::error(message);
self::error(&format!("Error: {}", error));
for e in error.iter().skip(1) {
self::error(&format!("Reason: {}", e));
}
}

View file

@ -12,39 +12,11 @@ extern crate mount;
extern crate notify;
extern crate ws;
use std::time::Instant;
use chrono::Duration;
use gutenberg::errors::Error;
mod cmd;
mod console;
/// Print the time elapsed rounded to 1 decimal
fn report_elapsed_time(instant: Instant) {
let duration_ms = Duration::from_std(instant.elapsed()).unwrap().num_milliseconds() as f64;
if duration_ms < 1000.0 {
console::success(&format!("Done in {}ms.\n", duration_ms));
} else {
let duration_sec = duration_ms / 1000.0;
console::success(&format!("Done in {:.1}s.\n", ((duration_sec * 10.0).round() / 10.0)));
}
}
////Display an error message, the actual error and then exits if requested
fn unravel_errors(message: &str, error: &Error, exit: bool) {
console::error(message);
console::error(&format!("Error: {}", error));
for e in error.iter().skip(1) {
console::error(&format!("Reason: {}", e));
}
if exit {
::std::process::exit(1);
}
}
mod rebuild;
fn main() {
@ -74,15 +46,21 @@ fn main() {
("init", Some(matches)) => {
match cmd::create_new_project(matches.value_of("name").unwrap()) {
Ok(()) => console::success("Project created"),
Err(e) => unravel_errors("Failed to create the project", &e, true),
Err(e) => {
console::unravel_errors("Failed to create the project", &e);
::std::process::exit(1);
},
};
},
("build", Some(_)) => {
console::info("Building site...");
let start = Instant::now();
match cmd::build(config_file) {
Ok(()) => report_elapsed_time(start),
Err(e) => unravel_errors("Failed to build the site", &e, true),
Ok(()) => console::report_elapsed_time(start),
Err(e) => {
console::unravel_errors("Failed to build the site", &e);
::std::process::exit(1);
},
};
},
("serve", Some(matches)) => {
@ -91,7 +69,10 @@ fn main() {
console::info("Building site...");
match cmd::serve(interface, port, config_file) {
Ok(()) => (),
Err(e) => unravel_errors("Failed to build the site", &e, true),
Err(e) => {
console::unravel_errors("Failed to build the site", &e);
::std::process::exit(1);
},
};
},
_ => unreachable!(),

227
src/bin/rebuild.rs Normal file
View file

@ -0,0 +1,227 @@
use std::path::Path;
use gutenberg::{Site, SectionFrontMatter, PageFrontMatter};
use gutenberg::errors::Result;
#[derive(Debug, Clone, Copy, PartialEq)]
enum PageChangesNeeded {
/// Editing `tags`
Tags,
/// Editing `categories`
Categories,
/// Editing `date` or `order`
Sort,
/// Editing anything else
Render,
}
// TODO: seems like editing sort_by/render do weird stuff
#[derive(Debug, Clone, Copy, PartialEq)]
enum SectionChangesNeeded {
/// Editing `sort_by`
Sort,
/// Editing `title`, `description`, `extra`, `template` or setting `render` to true
Render,
/// Editing `paginate_by` or `paginate_path`
RenderWithPages,
/// Setting `render` to false
Delete,
}
/// Evaluates all the params in the front matter that changed so we can do the smallest
/// delta in the serve command
fn find_section_front_matter_changes(current: &SectionFrontMatter, other: &SectionFrontMatter) -> Vec<SectionChangesNeeded> {
let mut changes_needed = vec![];
if current.sort_by != other.sort_by {
changes_needed.push(SectionChangesNeeded::Sort);
}
if !current.should_render() && other.should_render() {
changes_needed.push(SectionChangesNeeded::Delete);
// Nothing else we can do
return changes_needed;
}
if current.paginate_by != other.paginate_by || current.paginate_path != other.paginate_path {
changes_needed.push(SectionChangesNeeded::RenderWithPages);
// Nothing else we can do
return changes_needed;
}
// Any other change will trigger a re-rendering of the section page only
changes_needed.push(SectionChangesNeeded::Render);
changes_needed
}
/// Evaluates all the params in the front matter that changed so we can do the smallest
/// delta in the serve command
fn find_page_front_matter_changes(current: &PageFrontMatter, other: &PageFrontMatter) -> Vec<PageChangesNeeded> {
let mut changes_needed = vec![];
if current.tags != other.tags {
changes_needed.push(PageChangesNeeded::Tags);
}
if current.category != other.category {
changes_needed.push(PageChangesNeeded::Categories);
}
if current.date != other.date || current.order != other.order {
changes_needed.push(PageChangesNeeded::Sort);
}
changes_needed.push(PageChangesNeeded::Render);
changes_needed
}
// What happens when a section or a page is changed
pub fn after_content_change(site: &mut Site, path: &Path) -> Result<()> {
let is_section = path.file_name().unwrap() == "_index.md";
// A page or section got deleted
if !path.exists() {
if is_section {
// A section was deleted, many things can be impacted:
// - the pages of the section are becoming orphans
// - any page that was referencing the section (index, etc)
let relative_path = site.sections[path].relative_path.clone();
// Remove the link to it and the section itself from the Site
site.permalinks.remove(&relative_path);
site.sections.remove(path);
site.populate_sections();
} else {
// A page was deleted, many things can be impacted:
// - the section the page is in
// - any page that was referencing the section (index, etc)
let relative_path = site.pages[path].relative_path.clone();
site.permalinks.remove(&relative_path);
if let Some(p) = site.pages.remove(path) {
if p.meta.has_tags() || p.meta.category.is_some() {
site.populate_tags_and_categories();
}
if site.find_parent_section(&p).is_some() {
site.populate_sections();
}
};
}
// Deletion is something that doesn't happen all the time so we
// don't need to optimise it too much
return site.build();
}
// A section was edited
if is_section {
match site.add_section(path, true)? {
Some(prev) => {
// Updating a section
let current_meta = site.sections[path].meta.clone();
// Front matter didn't change, only content did
// so we render only the section page, not its pages
if current_meta == prev.meta {
return site.render_section(&site.sections[path], false);
}
// Front matter changed
for changes in find_section_front_matter_changes(&current_meta, &prev.meta) {
// Sort always comes first if present so the rendering will be fine
match changes {
SectionChangesNeeded::Sort => site.sort_sections_pages(Some(path)),
SectionChangesNeeded::Render => site.render_section(&site.sections[path], false)?,
SectionChangesNeeded::RenderWithPages => site.render_section(&site.sections[path], true)?,
// can't be arsed to make the Delete efficient, it's not a common enough operation
SectionChangesNeeded::Delete => {
site.populate_sections();
site.build()?;
},
};
}
return Ok(());
},
None => {
// New section, only render that one
site.populate_sections();
return site.render_section(&site.sections[path], true);
}
};
}
// A page was edited
match site.add_page(path, true)? {
Some(prev) => {
// Updating a page
let current = site.pages[path].clone();
// Front matter didn't change, only content did
// so we render only the section page, not its pages
if current.meta == prev.meta {
return site.render_page(&site.pages[path]);
}
// Front matter changed
for changes in find_page_front_matter_changes(&current.meta, &prev.meta) {
// Sort always comes first if present so the rendering will be fine
match changes {
PageChangesNeeded::Tags => {
site.populate_tags_and_categories();
site.render_tags()?;
},
PageChangesNeeded::Categories => {
site.populate_tags_and_categories();
site.render_categories()?;
},
PageChangesNeeded::Sort => {
let section_path = match site.find_parent_section(&site.pages[path]) {
Some(s) => s.file_path.clone(),
None => continue // Do nothing if it's an orphan page
};
site.populate_sections();
site.sort_sections_pages(Some(&section_path));
site.render_index()?;
},
PageChangesNeeded::Render => {
site.render_page(&site.pages[path])?;
},
};
}
return Ok(());
},
None => {
// It's a new page!
site.populate_sections();
site.populate_tags_and_categories();
// No need to optimise that yet, we can revisit if it becomes an issue
site.build()?;
}
}
Ok(())
}
/// What happens when a template is changed
pub fn after_template_change(site: &mut Site, path: &Path) -> Result<()> {
site.tera.full_reload()?;
match path.file_name().unwrap().to_str().unwrap() {
"sitemap.xml" => site.render_sitemap(),
"rss.xml" => site.render_rss_feed(),
"robots.txt" => site.render_robots(),
"categories.html" | "category.html" => site.render_categories(),
"tags.html" | "tag.html" => site.render_tags(),
"page.html" => {
site.render_sections()?;
site.render_orphan_pages()
},
"section.html" => site.render_sections(),
// Either the index or some unknown template changed
// We can't really know what this change affects so rebuild all
// the things
_ => {
site.render_sections()?;
site.render_orphan_pages()?;
site.render_categories()?;
site.render_tags()
},
}
}

View file

@ -1,33 +0,0 @@
mod init;
mod build;
mod serve;
pub use self::init::create_new_project;
pub use self::build::build;
pub use self::serve::serve;
use gutenberg::Site;
use console::warn;
fn notify_site_size(site: &Site) {
println!(
"-> Creating {} pages ({} orphan) and {} sections",
site.pages.len(),
site.get_all_orphan_pages().len(),
site.sections.len()
);
}
fn warn_about_ignored_pages(site: &Site) {
let ignored_pages = site.get_ignored_pages();
if !ignored_pages.is_empty() {
warn(&format!(
"{} page(s) ignored (missing date or order in a sorted section):",
ignored_pages.len()
));
for path in site.get_ignored_pages() {
warn(&format!("- {}", path.display()));
}
}
}

View file

@ -58,6 +58,10 @@ impl Config {
set_default!(config.language_code, "en".to_string());
set_default!(config.highlight_code, false);
set_default!(config.generate_rss, false);
set_default!(config.generate_tags_pages, false);
set_default!(config.generate_categories_pages, false);
set_default!(config.insert_anchor_links, false);
match config.highlight_theme {
Some(ref t) => {
@ -68,11 +72,6 @@ impl Config {
None => config.highlight_theme = Some("base16-ocean-dark".to_string())
};
set_default!(config.generate_rss, false);
set_default!(config.generate_tags_pages, true);
set_default!(config.generate_categories_pages, true);
set_default!(config.insert_anchor_links, false);
Ok(config)
}
@ -135,7 +134,7 @@ mod tests {
use super::{Config};
#[test]
fn test_can_import_valid_config() {
fn can_import_valid_config() {
let config = r#"
title = "My site"
base_url = "https://replace-this-with-your-url.com"
@ -146,7 +145,7 @@ base_url = "https://replace-this-with-your-url.com"
}
#[test]
fn test_errors_when_invalid_type() {
fn errors_when_invalid_type() {
let config = r#"
title = 1
base_url = "https://replace-this-with-your-url.com"
@ -157,7 +156,8 @@ base_url = "https://replace-this-with-your-url.com"
}
#[test]
fn test_errors_when_missing_required_field() {
fn errors_when_missing_required_field() {
// base_url is required
let config = r#"
title = ""
"#;
@ -167,7 +167,7 @@ title = ""
}
#[test]
fn test_can_add_extra_values() {
fn can_add_extra_values() {
let config = r#"
title = "My site"
base_url = "https://replace-this-with-your-url.com"
@ -181,15 +181,4 @@ hello = "world"
assert_eq!(config.unwrap().extra.unwrap().get("hello").unwrap().as_str().unwrap(), "world");
}
#[test]
fn test_language_defaults_to_en() {
let config = r#"
title = "My site"
base_url = "https://replace-this-with-your-url.com""#;
let config = Config::parse(config);
assert!(config.is_ok());
let config = config.unwrap();
assert_eq!(config.language_code.unwrap(), "en");
}
}

View file

@ -1,19 +0,0 @@
use term_painter::ToStyle;
use term_painter::Color::*;
pub fn info(message: &str) {
println!("{}", NotSet.bold().paint(message));
}
pub fn warn(message: &str) {
println!("{}", Yellow.bold().paint(message));
}
pub fn success(message: &str) {
println!("{}", Green.bold().paint(message));
}
pub fn error(message: &str) {
println!("{}", Red.bold().paint(message));
}

14
src/content/mod.rs Normal file
View file

@ -0,0 +1,14 @@
// TODO: move section/page and maybe pagination in this mod
// Not sure where pagination stands if I add a render mod
mod page;
mod pagination;
mod section;
mod sorting;
mod utils;
pub use self::page::{Page};
pub use self::section::{Section};
pub use self::pagination::{Paginator, Pager};
pub use self::sorting::{SortBy, sort_pages, populate_previous_and_next_pages};

228
src/content/page.rs Normal file
View file

@ -0,0 +1,228 @@
/// A page, can be a blog post or a basic page
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::result::Result as StdResult;
use tera::{Tera, Context};
use serde::ser::{SerializeStruct, self};
use slug::slugify;
use errors::{Result, ResultExt};
use config::Config;
use front_matter::{PageFrontMatter, split_page_content};
use markdown::markdown_to_html;
use utils::{read_file, find_content_components};
use content::utils::{find_related_assets, get_reading_analytics};
#[derive(Clone, Debug, PartialEq)]
pub struct Page {
/// The front matter meta-data
pub meta: PageFrontMatter,
/// The .md path
pub file_path: PathBuf,
/// The .md path, starting from the content directory, with / slashes
pub relative_path: String,
/// The parent directory of the file. Is actually the grand parent directory
/// if it's an asset folder
pub parent_path: PathBuf,
/// The name of the .md file
pub file_name: String,
/// The directories above our .md file
/// for example a file at content/kb/solutions/blabla.md will have 2 components:
/// `kb` and `solutions`
pub components: Vec<String>,
/// The actual content of the page, in markdown
pub raw_content: String,
/// All the non-md files we found next to the .md file
pub assets: Vec<PathBuf>,
/// The HTML rendered of the page
pub content: String,
/// The slug of that page.
/// First tries to find the slug in the meta and defaults to filename otherwise
pub slug: String,
/// The URL path of the page
pub path: String,
/// The full URL for that page
pub permalink: String,
/// The summary for the article, defaults to None
/// When <!-- more --> is found in the text, will take the content up to that part
/// as summary
pub summary: Option<String>,
/// The previous page, by whatever sorting is used for the index/section
pub previous: Option<Box<Page>>,
/// The next page, by whatever sorting is used for the index/section
pub next: Option<Box<Page>>,
}
impl Page {
pub fn new(meta: PageFrontMatter) -> Page {
Page {
meta: meta,
file_path: PathBuf::new(),
relative_path: String::new(),
parent_path: PathBuf::new(),
file_name: "".to_string(),
components: vec![],
raw_content: "".to_string(),
assets: vec![],
content: "".to_string(),
slug: "".to_string(),
path: "".to_string(),
permalink: "".to_string(),
summary: None,
previous: None,
next: None,
}
}
/// Parse a page given the content of the .md file
/// Files without front matter or with invalid front matter are considered
/// erroneous
pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Page> {
// 1. separate front matter from content
let (meta, content) = split_page_content(file_path, content)?;
let mut page = Page::new(meta);
page.file_path = file_path.to_path_buf();
page.parent_path = page.file_path.parent().unwrap().to_path_buf();
page.raw_content = content;
let path = Path::new(file_path);
page.file_name = path.file_stem().unwrap().to_string_lossy().to_string();
page.slug = {
if let Some(ref slug) = page.meta.slug {
slug.trim().to_string()
} else {
slugify(page.file_name.clone())
}
};
page.components = find_content_components(&page.file_path);
page.relative_path = format!("{}/{}.md", page.components.join("/"), page.file_name);
// 4. Find sections
// Pages with custom urls exists outside of sections
let mut path_set = false;
if let Some(ref u) = page.meta.url {
page.path = u.trim().to_string();
path_set = true;
}
if !page.components.is_empty() {
// If we have a folder with an asset, don't consider it as a component
if page.file_name == "index" {
page.components.pop();
// also set parent_path to grandparent instead
page.parent_path = page.parent_path.parent().unwrap().to_path_buf();
}
if !path_set {
// Don't add a trailing slash to sections
page.path = format!("{}/{}", page.components.join("/"), page.slug);
}
} else if !path_set {
page.path = page.slug.clone();
}
page.permalink = config.make_permalink(&page.path);
Ok(page)
}
/// Read and parse a .md file into a Page struct
pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
let path = path.as_ref();
let content = read_file(path)?;
let mut page = Page::parse(path, &content, config)?;
page.assets = find_related_assets(path.parent().unwrap());
if !page.assets.is_empty() && page.file_name != "index" {
bail!("Page `{}` has assets ({:?}) but is not named index.md", path.display(), page.assets);
}
Ok(page)
}
/// We need access to all pages url to render links relative to content
/// so that can't happen at the same time as parsing
pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
self.content = markdown_to_html(&self.raw_content, permalinks, tera, config)?;
if self.raw_content.contains("<!-- more -->") {
self.summary = Some({
let summary = self.raw_content.splitn(2, "<!-- more -->").collect::<Vec<&str>>()[0];
markdown_to_html(summary, permalinks, tera, config)?
})
}
Ok(())
}
/// Renders the page using the default layout, unless specified in front-matter
pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
let tpl_name = match self.meta.template {
Some(ref l) => l.to_string(),
None => "page.html".to_string()
};
let mut context = Context::new();
context.add("config", config);
context.add("page", self);
context.add("current_url", &self.permalink);
context.add("current_path", &self.path);
tera.render(&tpl_name, &context)
.chain_err(|| format!("Failed to render page '{}'", self.file_path.display()))
}
}
impl Default for Page {
fn default() -> Page {
Page {
meta: PageFrontMatter::default(),
file_path: PathBuf::new(),
relative_path: String::new(),
parent_path: PathBuf::new(),
file_name: "".to_string(),
components: vec![],
raw_content: "".to_string(),
assets: vec![],
content: "".to_string(),
slug: "".to_string(),
path: "".to_string(),
permalink: "".to_string(),
summary: None,
previous: None,
next: None,
}
}
}
impl ser::Serialize for Page {
fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
let mut state = serializer.serialize_struct("page", 16)?;
state.serialize_field("content", &self.content)?;
state.serialize_field("title", &self.meta.title)?;
state.serialize_field("description", &self.meta.description)?;
state.serialize_field("date", &self.meta.date)?;
state.serialize_field("slug", &self.slug)?;
state.serialize_field("path", &format!("/{}", self.path))?;
state.serialize_field("permalink", &self.permalink)?;
state.serialize_field("summary", &self.summary)?;
state.serialize_field("tags", &self.meta.tags)?;
state.serialize_field("draft", &self.meta.draft)?;
state.serialize_field("category", &self.meta.category)?;
state.serialize_field("extra", &self.meta.extra)?;
let (word_count, reading_time) = get_reading_analytics(&self.raw_content);
state.serialize_field("word_count", &word_count)?;
state.serialize_field("reading_time", &reading_time)?;
state.serialize_field("previous", &self.previous)?;
state.serialize_field("next", &self.next)?;
state.end()
}
}

View file

@ -2,8 +2,7 @@ use std::collections::HashMap;
use tera::{Context, to_value, Value};
use errors::{Result, ResultExt};
use page::Page;
use section::Section;
use content::{Page, Section};
use site::Site;
@ -23,10 +22,10 @@ pub struct Pager<'a> {
impl<'a> Pager<'a> {
fn new(index: usize, pages: Vec<&'a Page>, permalink: String, path: String) -> Pager<'a> {
Pager {
index: index,
permalink: permalink,
path: path,
pages: pages,
index,
permalink,
path,
pages,
}
}
}
@ -44,6 +43,8 @@ pub struct Paginator<'a> {
}
impl<'a> Paginator<'a> {
/// Create a new paginator
/// It will always at least create one pager (the first) even if there are no pages to paginate
pub fn new(all_pages: &'a [Page], section: &'a Section) -> Paginator<'a> {
let paginate_by = section.meta.paginate_by.unwrap();
let paginate_path = match section.meta.paginate_path {
@ -87,6 +88,11 @@ impl<'a> Paginator<'a> {
));
}
// We always have the index one at least
if pagers.is_empty() {
pagers.push(Pager::new(1, vec![], section.permalink.clone(), section.path.clone()));
}
Paginator {
all_pages: all_pages,
pagers: pagers,
@ -147,14 +153,13 @@ impl<'a> Paginator<'a> {
mod tests {
use tera::{to_value};
use front_matter::FrontMatter;
use page::Page;
use section::Section;
use front_matter::SectionFrontMatter;
use content::{Page, Section};
use super::{Paginator};
fn create_section(is_index: bool) -> Section {
let mut f = FrontMatter::default();
let mut f = SectionFrontMatter::default();
f.paginate_by = Some(2);
f.paginate_path = Some("page".to_string());
let mut s = Section::new("content/_index.md", f);
@ -171,9 +176,9 @@ mod tests {
#[test]
fn test_can_create_paginator() {
let pages = vec![
Page::new(FrontMatter::default()),
Page::new(FrontMatter::default()),
Page::new(FrontMatter::default()),
Page::default(),
Page::default(),
Page::default(),
];
let section = create_section(false);
let paginator = Paginator::new(pages.as_slice(), &section);
@ -193,9 +198,9 @@ mod tests {
#[test]
fn test_can_create_paginator_for_index() {
let pages = vec![
Page::new(FrontMatter::default()),
Page::new(FrontMatter::default()),
Page::new(FrontMatter::default()),
Page::default(),
Page::default(),
Page::default(),
];
let section = create_section(true);
let paginator = Paginator::new(pages.as_slice(), &section);
@ -215,9 +220,9 @@ mod tests {
#[test]
fn test_can_build_paginator_context() {
let pages = vec![
Page::new(FrontMatter::default()),
Page::new(FrontMatter::default()),
Page::new(FrontMatter::default()),
Page::default(),
Page::default(),
Page::default(),
];
let section = create_section(false);
let paginator = Paginator::new(pages.as_slice(), &section);

View file

@ -6,14 +6,17 @@ use tera::{Tera, Context};
use serde::ser::{SerializeStruct, self};
use config::Config;
use front_matter::{FrontMatter, split_content};
use front_matter::{SectionFrontMatter, split_section_content};
use errors::{Result, ResultExt};
use utils::{read_file, find_content_components};
use page::{Page};
use markdown::markdown_to_html;
use content::Page;
#[derive(Clone, Debug, PartialEq)]
pub struct Section {
/// The front matter meta-data
pub meta: SectionFrontMatter,
/// The _index.md full path
pub file_path: PathBuf,
/// The .md path, starting from the content directory, with / slashes
@ -26,8 +29,10 @@ pub struct Section {
pub path: String,
/// The full URL for that page
pub permalink: String,
/// The front matter meta-data
pub meta: FrontMatter,
/// The actual content of the page, in markdown
pub raw_content: String,
/// The HTML rendered of the page
pub content: String,
/// All direct pages of that section
pub pages: Vec<Page>,
/// All pages that cannot be sorted in this section
@ -37,17 +42,19 @@ pub struct Section {
}
impl Section {
pub fn new<P: AsRef<Path>>(file_path: P, meta: FrontMatter) -> Section {
pub fn new<P: AsRef<Path>>(file_path: P, meta: SectionFrontMatter) -> Section {
let file_path = file_path.as_ref();
Section {
meta: meta,
file_path: file_path.to_path_buf(),
relative_path: "".to_string(),
parent_path: file_path.parent().unwrap().to_path_buf(),
components: vec![],
path: "".to_string(),
permalink: "".to_string(),
meta: meta,
raw_content: "".to_string(),
content: "".to_string(),
pages: vec![],
ignored_pages: vec![],
subsections: vec![],
@ -55,12 +62,14 @@ impl Section {
}
pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Section> {
let (meta, _) = split_content(file_path, content)?;
let (meta, content) = split_section_content(file_path, content)?;
let mut section = Section::new(file_path, meta);
section.raw_content = content.clone();
section.components = find_content_components(&section.file_path);
section.path = section.components.join("/");
section.permalink = config.make_permalink(&section.path);
if section.components.is_empty() {
// the index one
section.relative_path = "_index.md".to_string();
} else {
section.relative_path = format!("{}/_index.md", section.components.join("/"));
@ -89,8 +98,15 @@ impl Section {
}
}
/// We need access to all pages url to render links relative to content
/// so that can't happen at the same time as parsing
pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
self.content = markdown_to_html(&self.raw_content, permalinks, tera, config)?;
Ok(())
}
/// Renders the page using the default layout, unless specified in front-matter
pub fn render_html(&self, sections: &HashMap<String, Section>, tera: &Tera, config: &Config) -> Result<String> {
pub fn render_html(&self, sections: HashMap<String, Section>, tera: &Tera, config: &Config) -> Result<String> {
let tpl_name = self.get_template_name();
let mut context = Context::new();
@ -99,7 +115,7 @@ impl Section {
context.add("current_url", &self.permalink);
context.add("current_path", &self.path);
if self.is_index() {
context.add("sections", sections);
context.add("sections", &sections);
}
tera.render(&tpl_name, &context)
@ -111,17 +127,36 @@ impl Section {
self.components.is_empty()
}
/// Returns all the paths for the pages belonging to that section
pub fn all_pages_path(&self) -> Vec<PathBuf> {
let mut paths = vec![];
paths.extend(self.pages.iter().map(|p| p.file_path.clone()));
paths.extend(self.ignored_pages.iter().map(|p| p.file_path.clone()));
paths
}
/// Whether the page given belongs to that section
pub fn is_child_page(&self, page: &Page) -> bool {
for p in &self.pages {
if p.file_path == page.file_path {
return true;
}
}
for p in &self.ignored_pages {
if p.file_path == page.file_path {
return true;
}
}
false
}
}
impl ser::Serialize for Section {
fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
let mut state = serializer.serialize_struct("section", 6)?;
let mut state = serializer.serialize_struct("section", 7)?;
state.serialize_field("content", &self.content)?;
state.serialize_field("title", &self.meta.title)?;
state.serialize_field("description", &self.meta.description)?;
state.serialize_field("path", &format!("/{}", self.path))?;
@ -131,3 +166,23 @@ impl ser::Serialize for Section {
state.end()
}
}
impl Default for Section {
/// Used to create a default index section if there is no _index.md in the root content directory
fn default() -> Section {
Section {
meta: SectionFrontMatter::default(),
file_path: PathBuf::new(),
relative_path: "".to_string(),
parent_path: PathBuf::new(),
components: vec![],
path: "".to_string(),
permalink: "".to_string(),
raw_content: "".to_string(),
content: "".to_string(),
pages: vec![],
ignored_pages: vec![],
subsections: vec![],
}
}
}

169
src/content/sorting.rs Normal file
View file

@ -0,0 +1,169 @@
use content::Page;
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortBy {
Date,
Order,
None,
}
/// Sort pages using the method for the given section
///
/// Any pages that doesn't have a date when the sorting method is date or order
/// when the sorting method is order will be ignored.
pub fn sort_pages(pages: Vec<Page>, sort_by: SortBy) -> (Vec<Page>, Vec<Page>) {
match sort_by {
SortBy::Date => {
let mut can_be_sorted = vec![];
let mut cannot_be_sorted = vec![];
for page in pages {
if page.meta.date.is_some() {
can_be_sorted.push(page);
} else {
cannot_be_sorted.push(page);
}
}
can_be_sorted.sort_by(|a, b| b.meta.date().unwrap().cmp(&a.meta.date().unwrap()));
(can_be_sorted, cannot_be_sorted)
},
SortBy::Order => {
let mut can_be_sorted = vec![];
let mut cannot_be_sorted = vec![];
for page in pages {
if page.meta.order.is_some() {
can_be_sorted.push(page);
} else {
cannot_be_sorted.push(page);
}
}
can_be_sorted.sort_by(|a, b| b.meta.order().cmp(&a.meta.order()));
(can_be_sorted, cannot_be_sorted)
},
SortBy::None => (pages, vec![])
}
}
/// Horribly inefficient way to set previous and next on each pages
/// So many clones
pub fn populate_previous_and_next_pages(input: &[Page]) -> Vec<Page> {
let pages = input.to_vec();
let mut res = Vec::new();
// the input is already sorted
// We might put prev/next randomly if a page is missing date/order, probably fine
for (i, page) in input.iter().enumerate() {
let mut new_page = page.clone();
if i > 0 {
let next = &pages[i - 1];
new_page.next = Some(Box::new(next.clone()));
}
if i < input.len() - 1 {
let previous = &pages[i + 1];
new_page.previous = Some(Box::new(previous.clone()));
}
res.push(new_page);
}
res
}
#[cfg(test)]
mod tests {
use front_matter::{PageFrontMatter};
use content::Page;
use super::{SortBy, sort_pages, populate_previous_and_next_pages};
fn create_page_with_date(date: &str) -> Page {
let mut front_matter = PageFrontMatter::default();
front_matter.date = Some(date.to_string());
Page::new(front_matter)
}
fn create_page_with_order(order: usize) -> Page {
let mut front_matter = PageFrontMatter::default();
front_matter.order = Some(order);
Page::new(front_matter)
}
#[test]
fn can_sort_by_dates() {
let input = vec![
create_page_with_date("2018-01-01"),
create_page_with_date("2017-01-01"),
create_page_with_date("2019-01-01"),
];
let (pages, _) = sort_pages(input, SortBy::Date);
// Should be sorted by date
assert_eq!(pages[0].clone().meta.date.unwrap(), "2019-01-01");
assert_eq!(pages[1].clone().meta.date.unwrap(), "2018-01-01");
assert_eq!(pages[2].clone().meta.date.unwrap(), "2017-01-01");
}
#[test]
fn can_sort_by_order() {
let input = vec![
create_page_with_order(2),
create_page_with_order(3),
create_page_with_order(1),
];
let (pages, _) = sort_pages(input, SortBy::Order);
// Should be sorted by date
assert_eq!(pages[0].clone().meta.order.unwrap(), 3);
assert_eq!(pages[1].clone().meta.order.unwrap(), 2);
assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
}
#[test]
fn can_sort_by_none() {
let input = vec![
create_page_with_order(2),
create_page_with_order(3),
create_page_with_order(1),
];
let (pages, _) = sort_pages(input, SortBy::None);
// Should be sorted by date
assert_eq!(pages[0].clone().meta.order.unwrap(), 2);
assert_eq!(pages[1].clone().meta.order.unwrap(), 3);
assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
}
#[test]
fn ignore_page_with_missing_field() {
let input = vec![
create_page_with_order(2),
create_page_with_order(3),
create_page_with_date("2019-01-01"),
];
let (pages, unsorted) = sort_pages(input, SortBy::Order);
assert_eq!(pages.len(), 2);
assert_eq!(unsorted.len(), 1);
}
#[test]
fn can_populate_previous_and_next_pages() {
let input = vec![
create_page_with_order(3),
create_page_with_order(2),
create_page_with_order(1),
];
let pages = populate_previous_and_next_pages(input.as_slice());
assert!(pages[0].clone().next.is_none());
assert!(pages[0].clone().previous.is_some());
assert_eq!(pages[0].clone().previous.unwrap().meta.order.unwrap(), 2);
assert!(pages[1].clone().next.is_some());
assert!(pages[1].clone().previous.is_some());
assert_eq!(pages[1].clone().next.unwrap().meta.order.unwrap(), 3);
assert_eq!(pages[1].clone().previous.unwrap().meta.order.unwrap(), 1);
assert!(pages[2].clone().next.is_some());
assert!(pages[2].clone().previous.is_none());
assert_eq!(pages[2].clone().next.unwrap().meta.order.unwrap(), 2);
}
}

77
src/content/utils.rs Normal file
View file

@ -0,0 +1,77 @@
use std::fs::read_dir;
use std::path::{Path, PathBuf};
/// Looks into the current folder for the path and see if there's anything that is not a .md
/// file. Those will be copied next to the rendered .html file
pub fn find_related_assets(path: &Path) -> Vec<PathBuf> {
let mut assets = vec![];
for entry in read_dir(path).unwrap().filter_map(|e| e.ok()) {
let entry_path = entry.path();
if entry_path.is_file() {
match entry_path.extension() {
Some(e) => match e.to_str() {
Some("md") => continue,
_ => assets.push(entry_path.to_path_buf()),
},
None => continue,
}
}
}
assets
}
/// Get word count and estimated reading time
pub fn get_reading_analytics(content: &str) -> (usize, usize) {
// Only works for latin language but good enough for a start
let word_count: usize = content.split_whitespace().count();
// https://help.medium.com/hc/en-us/articles/214991667-Read-time
// 275 seems a bit too high though
(word_count, (word_count / 200))
}
#[cfg(test)]
mod tests {
use std::fs::File;
use tempdir::TempDir;
use super::{find_related_assets, get_reading_analytics};
#[test]
fn can_find_related_assets() {
let tmp_dir = TempDir::new("example").expect("create temp dir");
File::create(tmp_dir.path().join("index.md")).unwrap();
File::create(tmp_dir.path().join("example.js")).unwrap();
File::create(tmp_dir.path().join("graph.jpg")).unwrap();
File::create(tmp_dir.path().join("fail.png")).unwrap();
let assets = find_related_assets(tmp_dir.path());
assert_eq!(assets.len(), 3);
assert_eq!(assets.iter().filter(|p| p.extension().unwrap() != "md").count(), 3);
assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "example.js").count(), 1);
assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "graph.jpg").count(), 1);
assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "fail.png").count(), 1);
}
#[test]
fn reading_analytics_short_text() {
let (word_count, reading_time) = get_reading_analytics("Hello World");
assert_eq!(word_count, 2);
assert_eq!(reading_time, 0);
}
#[test]
fn reading_analytics_long_text() {
let mut content = String::new();
for _ in 0..1000 {
content.push_str(" Hello world");
}
let (word_count, reading_time) = get_reading_analytics(&content);
assert_eq!(word_count, 2000);
assert_eq!(reading_time, 10);
}
}

View file

@ -1,177 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use toml;
use tera::Value;
use chrono::prelude::*;
use regex::Regex;
use errors::{Result, ResultExt};
lazy_static! {
static ref PAGE_RE: Regex = Regex::new(r"^\r?\n?\+\+\+\r?\n((?s).*?(?-s))\+\+\+\r?\n?((?s).*(?-s))$").unwrap();
}
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortBy {
Date,
Order,
None,
}
/// The front matter of every page
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrontMatter {
/// <title> of the page
pub title: Option<String>,
/// Description in <meta> that appears when linked, e.g. on twitter
pub description: Option<String>,
/// Date if we want to order pages (ie blog post)
pub date: Option<String>,
/// The page slug. Will be used instead of the filename if present
/// Can't be an empty string if present
pub slug: Option<String>,
/// The url the page appears at, overrides the slug if set in the front-matter
/// otherwise is set after parsing front matter and sections
/// Can't be an empty string if present
pub url: Option<String>,
/// Tags, not to be confused with categories
pub tags: Option<Vec<String>>,
/// Whether this page is a draft and should be published or not
pub draft: Option<bool>,
/// Only one category allowed
pub category: Option<String>,
/// Whether to sort by "date", "order" or "none". Defaults to `none`.
#[serde(skip_serializing)]
pub sort_by: Option<SortBy>,
/// Integer to use to order content. Lowest is at the bottom, highest first
pub order: Option<usize>,
/// Optional template, if we want to specify which template to render for that page
#[serde(skip_serializing)]
pub template: Option<String>,
/// How many pages to be displayed per paginated page. No pagination will happen if this isn't set
#[serde(skip_serializing)]
pub paginate_by: Option<usize>,
/// Path to be used by pagination: the page number will be appended after it. Defaults to `page`.
#[serde(skip_serializing)]
pub paginate_path: Option<String>,
/// Whether to render that page/section or not. Defaults to `true`.
#[serde(skip_serializing)]
pub render: Option<bool>,
/// Any extra parameter present in the front matter
pub extra: Option<HashMap<String, Value>>,
}
impl FrontMatter {
pub fn parse(toml: &str) -> Result<FrontMatter> {
let mut f: FrontMatter = match toml::from_str(toml) {
Ok(d) => d,
Err(e) => bail!(e),
};
if let Some(ref slug) = f.slug {
if slug == "" {
bail!("`slug` can't be empty if present")
}
}
if let Some(ref url) = f.url {
if url == "" {
bail!("`url` can't be empty if present")
}
}
if f.paginate_path.is_none() {
f.paginate_path = Some("page".to_string());
}
if f.render.is_none() {
f.render = Some(true);
}
Ok(f)
}
/// Converts the date in the front matter, which can be in 2 formats, into a NaiveDateTime
pub fn date(&self) -> Option<NaiveDateTime> {
match self.date {
Some(ref d) => {
if d.contains('T') {
DateTime::parse_from_rfc3339(d).ok().and_then(|s| Some(s.naive_local()))
} else {
NaiveDate::parse_from_str(d, "%Y-%m-%d").ok().and_then(|s| Some(s.and_hms(0,0,0)))
}
},
None => None,
}
}
pub fn order(&self) -> usize {
self.order.unwrap()
}
/// Returns the current sorting method, defaults to `None` (== no sorting)
pub fn sort_by(&self) -> SortBy {
match self.sort_by {
Some(ref s) => *s,
None => SortBy::None,
}
}
/// Only applies to section, whether it is paginated or not.
pub fn is_paginated(&self) -> bool {
match self.paginate_by {
Some(v) => v > 0,
None => false
}
}
pub fn should_render(&self) -> bool {
self.render.unwrap()
}
}
impl Default for FrontMatter {
fn default() -> FrontMatter {
FrontMatter {
title: None,
description: None,
date: None,
slug: None,
url: None,
tags: None,
draft: None,
category: None,
sort_by: None,
order: None,
template: None,
paginate_by: None,
paginate_path: None,
render: None,
extra: None,
}
}
}
/// Split a file between the front matter and its content
/// It will parse the front matter as well and returns any error encountered
pub fn split_content(file_path: &Path, content: &str) -> Result<(FrontMatter, String)> {
if !PAGE_RE.is_match(content) {
bail!("Couldn't find front matter in `{}`. Did you forget to add `+++`?", file_path.to_string_lossy());
}
// 2. extract the front matter and the content
let caps = PAGE_RE.captures(content).unwrap();
// caps[0] is the full match
let front_matter = &caps[1];
let content = &caps[2];
// 3. create our page, parse front matter and assign all of that
let meta = FrontMatter::parse(front_matter)
.chain_err(|| format!("Error when parsing front matter of file `{}`", file_path.to_string_lossy()))?;
Ok((meta, content.to_string()))
}

122
src/front_matter/mod.rs Normal file
View file

@ -0,0 +1,122 @@
use std::path::Path;
use regex::Regex;
use errors::{Result, ResultExt};
mod page;
mod section;
pub use self::page::PageFrontMatter;
pub use self::section::{SectionFrontMatter};
lazy_static! {
static ref PAGE_RE: Regex = Regex::new(r"^[[:space:]]*\+\+\+\r?\n((?s).*?(?-s))\+\+\+\r?\n?((?s).*(?-s))$").unwrap();
}
/// Split a file between the front matter and its content
/// Will return an error if the front matter wasn't found
fn split_content(file_path: &Path, content: &str) -> Result<(String, String)> {
if !PAGE_RE.is_match(content) {
bail!("Couldn't find front matter in `{}`. Did you forget to add `+++`?", file_path.to_string_lossy());
}
// 2. extract the front matter and the content
let caps = PAGE_RE.captures(content).unwrap();
// caps[0] is the full match
// caps[1] => front matter
// caps[2] => content
Ok((caps[1].to_string(), caps[2].to_string()))
}
/// Split a file between the front matter and its content.
/// Returns a parsed `SectionFrontMatter` and the rest of the content
pub fn split_section_content(file_path: &Path, content: &str) -> Result<(SectionFrontMatter, String)> {
let (front_matter, content) = split_content(file_path, content)?;
let meta = SectionFrontMatter::parse(&front_matter)
.chain_err(|| format!("Error when parsing front matter of section `{}`", file_path.to_string_lossy()))?;
Ok((meta, content))
}
/// Split a file between the front matter and its content
/// Returns a parsed `PageFrontMatter` and the rest of the content
pub fn split_page_content(file_path: &Path, content: &str) -> Result<(PageFrontMatter, String)> {
let (front_matter, content) = split_content(file_path, content)?;
let meta = PageFrontMatter::parse(&front_matter)
.chain_err(|| format!("Error when parsing front matter of section `{}`", file_path.to_string_lossy()))?;
Ok((meta, content))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{split_section_content, split_page_content};
#[test]
fn can_split_page_content_valid() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002/10/12"
+++
Hello
"#;
let (front_matter, content) = split_page_content(Path::new(""), content).unwrap();
assert_eq!(content, "Hello\n");
assert_eq!(front_matter.title.unwrap(), "Title");
}
#[test]
fn can_split_section_content_valid() {
let content = r#"
+++
paginate_by = 10
+++
Hello
"#;
let (front_matter, content) = split_section_content(Path::new(""), content).unwrap();
assert_eq!(content, "Hello\n");
assert!(front_matter.is_paginated());
}
#[test]
fn can_split_content_with_only_frontmatter_valid() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002/10/12"
+++"#;
let (front_matter, content) = split_page_content(Path::new(""), content).unwrap();
assert_eq!(content, "");
assert_eq!(front_matter.title.unwrap(), "Title");
}
#[test]
fn can_split_content_lazily() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002-10-02T15:00:00Z"
+++
+++"#;
let (front_matter, content) = split_page_content(Path::new(""), content).unwrap();
assert_eq!(content, "+++");
assert_eq!(front_matter.title.unwrap(), "Title");
}
#[test]
fn errors_if_cannot_locate_frontmatter() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002/10/12""#;
let res = split_page_content(Path::new(""), content);
assert!(res.is_err());
}
}

219
src/front_matter/page.rs Normal file
View file

@ -0,0 +1,219 @@
use std::collections::HashMap;
use chrono::prelude::*;
use tera::Value;
use toml;
use errors::{Result};
/// The front matter of every page
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PageFrontMatter {
/// <title> of the page
pub title: Option<String>,
/// Description in <meta> that appears when linked, e.g. on twitter
pub description: Option<String>,
/// Date if we want to order pages (ie blog post)
pub date: Option<String>,
/// The page slug. Will be used instead of the filename if present
/// Can't be an empty string if present
pub slug: Option<String>,
/// The url the page appears at, overrides the slug if set in the front-matter
/// otherwise is set after parsing front matter and sections
/// Can't be an empty string if present
pub url: Option<String>,
/// Tags, not to be confused with categories
pub tags: Option<Vec<String>>,
/// Whether this page is a draft and should be published or not
pub draft: Option<bool>,
/// Only one category allowed. Can't be an empty string if present
pub category: Option<String>,
/// Integer to use to order content. Lowest is at the bottom, highest first
pub order: Option<usize>,
/// Specify a template different from `page.html` to use for that page
#[serde(skip_serializing)]
pub template: Option<String>,
/// Any extra parameter present in the front matter
pub extra: Option<HashMap<String, Value>>,
}
impl PageFrontMatter {
pub fn parse(toml: &str) -> Result<PageFrontMatter> {
let f: PageFrontMatter = match toml::from_str(toml) {
Ok(d) => d,
Err(e) => bail!(e),
};
if let Some(ref slug) = f.slug {
if slug == "" {
bail!("`slug` can't be empty if present")
}
}
if let Some(ref url) = f.url {
if url == "" {
bail!("`url` can't be empty if present")
}
}
if let Some(ref category) = f.category {
if category == "" {
bail!("`category` can't be empty if present")
}
}
Ok(f)
}
/// Converts the date in the front matter, which can be in 2 formats, into a NaiveDateTime
pub fn date(&self) -> Option<NaiveDateTime> {
match self.date {
Some(ref d) => {
if d.contains('T') {
DateTime::parse_from_rfc3339(d).ok().and_then(|s| Some(s.naive_local()))
} else {
NaiveDate::parse_from_str(d, "%Y-%m-%d").ok().and_then(|s| Some(s.and_hms(0,0,0)))
}
},
None => None,
}
}
pub fn order(&self) -> usize {
self.order.unwrap()
}
pub fn has_tags(&self) -> bool {
match self.tags {
Some(ref t) => !t.is_empty(),
None => false
}
}
}
impl Default for PageFrontMatter {
fn default() -> PageFrontMatter {
PageFrontMatter {
title: None,
description: None,
date: None,
slug: None,
url: None,
tags: None,
draft: None,
category: None,
order: None,
template: None,
extra: None,
}
}
}
#[cfg(test)]
mod tests {
use super::PageFrontMatter;
#[test]
fn can_have_empty_front_matter() {
let content = r#" "#;
let res = PageFrontMatter::parse(content);
assert!(res.is_ok());
}
#[test]
fn can_parse_valid_front_matter() {
let content = r#"
title = "Hello"
description = "hey there""#;
let res = PageFrontMatter::parse(content);
assert!(res.is_ok());
let res = res.unwrap();
assert_eq!(res.title.unwrap(), "Hello".to_string());
assert_eq!(res.description.unwrap(), "hey there".to_string())
}
#[test]
fn can_parse_tags() {
let content = r#"
title = "Hello"
description = "hey there"
slug = "hello-world"
tags = ["rust", "html"]"#;
let res = PageFrontMatter::parse(content);
assert!(res.is_ok());
let res = res.unwrap();
assert_eq!(res.title.unwrap(), "Hello".to_string());
assert_eq!(res.slug.unwrap(), "hello-world".to_string());
assert_eq!(res.tags.unwrap(), ["rust".to_string(), "html".to_string()]);
}
#[test]
fn errors_with_invalid_front_matter() {
let content = r#"title = 1\n"#;
let res = PageFrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn errors_on_non_string_tag() {
let content = r#"
title = "Hello"
description = "hey there"
slug = "hello-world"
tags = ["rust", 1]"#;
let res = PageFrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn errors_on_present_but_empty_slug() {
let content = r#"
title = "Hello"
description = "hey there"
slug = """#;
let res = PageFrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn errors_on_present_but_empty_url() {
let content = r#"
title = "Hello"
description = "hey there"
url = """#;
let res = PageFrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn can_parse_date_yyyy_mm_dd() {
let content = r#"
title = "Hello"
description = "hey there"
date = "2016-10-10""#;
let res = PageFrontMatter::parse(content).unwrap();
assert!(res.date().is_some());
}
#[test]
fn can_parse_date_rfc3339() {
let content = r#"
title = "Hello"
description = "hey there"
date = "2002-10-02T15:00:00Z""#;
let res = PageFrontMatter::parse(content).unwrap();
assert!(res.date().is_some());
}
#[test]
fn cannot_parse_random_date_format() {
let content = r#"
title = "Hello"
description = "hey there"
date = "2002/10/12""#;
let res = PageFrontMatter::parse(content).unwrap();
assert!(res.date().is_none());
}
}

View file

@ -0,0 +1,93 @@
use std::collections::HashMap;
use tera::Value;
use toml;
use errors::{Result};
use content::SortBy;
static DEFAULT_PAGINATE_PATH: &'static str = "page";
/// The front matter of every section
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionFrontMatter {
/// <title> of the page
pub title: Option<String>,
/// Description in <meta> that appears when linked, e.g. on twitter
pub description: Option<String>,
/// Whether to sort by "date", "order" or "none". Defaults to `none`.
#[serde(skip_serializing)]
pub sort_by: Option<SortBy>,
/// Optional template, if we want to specify which template to render for that page
#[serde(skip_serializing)]
pub template: Option<String>,
/// How many pages to be displayed per paginated page. No pagination will happen if this isn't set
#[serde(skip_serializing)]
pub paginate_by: Option<usize>,
/// Path to be used by pagination: the page number will be appended after it. Defaults to `page`.
#[serde(skip_serializing)]
pub paginate_path: Option<String>,
/// Whether to render that section or not. Defaults to `true`.
/// Useful when the section is only there to organize things but is not meant
/// to be used directly, like a posts section in a personal site
#[serde(skip_serializing)]
pub render: Option<bool>,
/// Any extra parameter present in the front matter
pub extra: Option<HashMap<String, Value>>,
}
impl SectionFrontMatter {
pub fn parse(toml: &str) -> Result<SectionFrontMatter> {
let mut f: SectionFrontMatter = match toml::from_str(toml) {
Ok(d) => d,
Err(e) => bail!(e),
};
if f.paginate_path.is_none() {
f.paginate_path = Some(DEFAULT_PAGINATE_PATH.to_string());
}
if f.render.is_none() {
f.render = Some(true);
}
if f.sort_by.is_none() {
f.sort_by = Some(SortBy::None);
}
Ok(f)
}
/// Returns the current sorting method, defaults to `None` (== no sorting)
pub fn sort_by(&self) -> SortBy {
self.sort_by.unwrap()
}
/// Only applies to section, whether it is paginated or not.
pub fn is_paginated(&self) -> bool {
match self.paginate_by {
Some(v) => v > 0,
None => false
}
}
pub fn should_render(&self) -> bool {
self.render.unwrap()
}
}
impl Default for SectionFrontMatter {
fn default() -> SectionFrontMatter {
SectionFrontMatter {
title: None,
description: None,
sort_by: None,
template: None,
paginate_by: None,
paginate_path: Some(DEFAULT_PAGINATE_PATH.to_string()),
render: Some(true),
extra: None,
}
}
}

View file

@ -22,21 +22,16 @@ extern crate tempdir;
mod utils;
mod config;
pub mod errors;
mod page;
mod front_matter;
mod content;
mod site;
mod markdown;
mod section;
mod pagination;
/// Additional filters for Tera
mod filters;
/// Global fns for Tera
mod global_fns;
// Filters, Global Fns and default instance of Tera
mod templates;
pub use site::{Site, GUTENBERG_TERA};
pub use site::{Site};
pub use config::{Config, get_config};
pub use front_matter::{FrontMatter, split_content, SortBy};
pub use page::{Page, populate_previous_and_next_pages};
pub use section::{Section};
pub use front_matter::{PageFrontMatter, SectionFrontMatter, split_page_content, split_section_content};
pub use content::{Page, Section, SortBy, sort_pages, populate_previous_and_next_pages};
pub use utils::{create_file};
pub use markdown::markdown_to_html;

View file

@ -340,7 +340,7 @@ pub fn markdown_to_html(content: &str, permalinks: &HashMap<String, String>, ter
mod tests {
use std::collections::HashMap;
use site::GUTENBERG_TERA;
use templates::GUTENBERG_TERA;
use tera::Tera;
use config::Config;

View file

@ -1,428 +0,0 @@
/// A page, can be a blog post or a basic page
use std::collections::HashMap;
use std::fs::{read_dir};
use std::path::{Path, PathBuf};
use std::result::Result as StdResult;
use tera::{Tera, Context};
use serde::ser::{SerializeStruct, self};
use slug::slugify;
use errors::{Result, ResultExt};
use config::Config;
use front_matter::{FrontMatter, SortBy, split_content};
use markdown::markdown_to_html;
use utils::{read_file, find_content_components};
/// Looks into the current folder for the path and see if there's anything that is not a .md
/// file. Those will be copied next to the rendered .html file
fn find_related_assets(path: &Path) -> Vec<PathBuf> {
let mut assets = vec![];
for entry in read_dir(path).unwrap().filter_map(|e| e.ok()) {
let entry_path = entry.path();
if entry_path.is_file() {
match entry_path.extension() {
Some(e) => match e.to_str() {
Some("md") => continue,
_ => assets.push(entry_path.to_path_buf()),
},
None => continue,
}
}
}
assets
}
#[derive(Clone, Debug, PartialEq)]
pub struct Page {
/// The .md path
pub file_path: PathBuf,
/// The .md path, starting from the content directory, with / slashes
pub relative_path: String,
/// The parent directory of the file. Is actually the grand parent directory
/// if it's an asset folder
pub parent_path: PathBuf,
/// The name of the .md file
pub file_name: String,
/// The directories above our .md file
/// for example a file at content/kb/solutions/blabla.md will have 2 components:
/// `kb` and `solutions`
pub components: Vec<String>,
/// The actual content of the page, in markdown
pub raw_content: String,
/// All the non-md files we found next to the .md file
pub assets: Vec<PathBuf>,
/// The HTML rendered of the page
pub content: String,
/// The front matter meta-data
pub meta: FrontMatter,
/// The slug of that page.
/// First tries to find the slug in the meta and defaults to filename otherwise
pub slug: String,
/// The URL path of the page
pub path: String,
/// The full URL for that page
pub permalink: String,
/// The summary for the article, defaults to None
/// When <!-- more --> is found in the text, will take the content up to that part
/// as summary
pub summary: Option<String>,
/// The previous page, by whatever sorting is used for the index/section
pub previous: Option<Box<Page>>,
/// The next page, by whatever sorting is used for the index/section
pub next: Option<Box<Page>>,
}
impl Page {
pub fn new(meta: FrontMatter) -> Page {
Page {
file_path: PathBuf::new(),
relative_path: String::new(),
parent_path: PathBuf::new(),
file_name: "".to_string(),
components: vec![],
raw_content: "".to_string(),
assets: vec![],
content: "".to_string(),
slug: "".to_string(),
path: "".to_string(),
permalink: "".to_string(),
summary: None,
meta: meta,
previous: None,
next: None,
}
}
pub fn has_date(&self) -> bool {
self.meta.date.is_some()
}
/// Get word count and estimated reading time
pub fn get_reading_analytics(&self) -> (usize, usize) {
// Only works for latin language but good enough for a start
let word_count: usize = self.raw_content.split_whitespace().count();
// https://help.medium.com/hc/en-us/articles/214991667-Read-time
// 275 seems a bit too high though
(word_count, (word_count / 200))
}
/// Parse a page given the content of the .md file
/// Files without front matter or with invalid front matter are considered
/// erroneous
pub fn parse(file_path: &Path, content: &str, config: &Config) -> Result<Page> {
// 1. separate front matter from content
let (meta, content) = split_content(file_path, content)?;
let mut page = Page::new(meta);
page.file_path = file_path.to_path_buf();
page.parent_path = page.file_path.parent().unwrap().to_path_buf();
page.raw_content = content;
let path = Path::new(file_path);
page.file_name = path.file_stem().unwrap().to_string_lossy().to_string();
page.slug = {
if let Some(ref slug) = page.meta.slug {
slug.trim().to_string()
} else {
slugify(page.file_name.clone())
}
};
page.components = find_content_components(&page.file_path);
page.relative_path = format!("{}/{}.md", page.components.join("/"), page.file_name);
// 4. Find sections
// Pages with custom urls exists outside of sections
let mut path_set = false;
if let Some(ref u) = page.meta.url {
page.path = u.trim().to_string();
path_set = true;
}
if !page.components.is_empty() {
// If we have a folder with an asset, don't consider it as a component
if page.file_name == "index" {
page.components.pop();
// also set parent_path to grandparent instead
page.parent_path = page.parent_path.parent().unwrap().to_path_buf();
}
if !path_set {
// Don't add a trailing slash to sections
page.path = format!("{}/{}", page.components.join("/"), page.slug);
}
} else if !path_set {
page.path = page.slug.clone();
}
page.permalink = config.make_permalink(&page.path);
Ok(page)
}
/// Read and parse a .md file into a Page struct
pub fn from_file<P: AsRef<Path>>(path: P, config: &Config) -> Result<Page> {
let path = path.as_ref();
let content = read_file(path)?;
let mut page = Page::parse(path, &content, config)?;
page.assets = find_related_assets(path.parent().unwrap());
if !page.assets.is_empty() && page.file_name != "index" {
bail!("Page `{}` has assets ({:?}) but is not named index.md", path.display(), page.assets);
}
Ok(page)
}
/// We need access to all pages url to render links relative to content
/// so that can't happen at the same time as parsing
pub fn render_markdown(&mut self, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<()> {
self.content = markdown_to_html(&self.raw_content, permalinks, tera, config)?;
if self.raw_content.contains("<!-- more -->") {
self.summary = Some({
let summary = self.raw_content.splitn(2, "<!-- more -->").collect::<Vec<&str>>()[0];
markdown_to_html(summary, permalinks, tera, config)?
})
}
Ok(())
}
/// Renders the page using the default layout, unless specified in front-matter
pub fn render_html(&self, tera: &Tera, config: &Config) -> Result<String> {
let tpl_name = match self.meta.template {
Some(ref l) => l.to_string(),
None => "page.html".to_string()
};
let mut context = Context::new();
context.add("config", config);
context.add("page", self);
context.add("current_url", &self.permalink);
context.add("current_path", &self.path);
tera.render(&tpl_name, &context)
.chain_err(|| format!("Failed to render page '{}'", self.file_path.display()))
}
}
impl ser::Serialize for Page {
fn serialize<S>(&self, serializer: S) -> StdResult<S::Ok, S::Error> where S: ser::Serializer {
let mut state = serializer.serialize_struct("page", 16)?;
state.serialize_field("content", &self.content)?;
state.serialize_field("title", &self.meta.title)?;
state.serialize_field("description", &self.meta.description)?;
state.serialize_field("date", &self.meta.date)?;
state.serialize_field("slug", &self.slug)?;
state.serialize_field("path", &format!("/{}", self.path))?;
state.serialize_field("permalink", &self.permalink)?;
state.serialize_field("summary", &self.summary)?;
state.serialize_field("tags", &self.meta.tags)?;
state.serialize_field("draft", &self.meta.draft)?;
state.serialize_field("category", &self.meta.category)?;
state.serialize_field("extra", &self.meta.extra)?;
let (word_count, reading_time) = self.get_reading_analytics();
state.serialize_field("word_count", &word_count)?;
state.serialize_field("reading_time", &reading_time)?;
state.serialize_field("previous", &self.previous)?;
state.serialize_field("next", &self.next)?;
state.end()
}
}
/// Sort pages using the method for the given section
///
/// Any pages that doesn't have a date when the sorting method is date or order
/// when the sorting method is order will be ignored.
pub fn sort_pages(pages: Vec<Page>, sort_by: SortBy) -> (Vec<Page>, Vec<Page>) {
match sort_by {
SortBy::Date => {
let mut can_be_sorted = vec![];
let mut cannot_be_sorted = vec![];
for page in pages {
if page.meta.date.is_some() {
can_be_sorted.push(page);
} else {
cannot_be_sorted.push(page);
}
}
can_be_sorted.sort_by(|a, b| b.meta.date().unwrap().cmp(&a.meta.date().unwrap()));
(can_be_sorted, cannot_be_sorted)
},
SortBy::Order => {
let mut can_be_sorted = vec![];
let mut cannot_be_sorted = vec![];
for page in pages {
if page.meta.order.is_some() {
can_be_sorted.push(page);
} else {
cannot_be_sorted.push(page);
}
}
can_be_sorted.sort_by(|a, b| b.meta.order().cmp(&a.meta.order()));
(can_be_sorted, cannot_be_sorted)
},
SortBy::None => {
let mut p = vec![];
for page in pages {
p.push(page);
}
(p, vec![])
},
}
}
/// Horribly inefficient way to set previous and next on each pages
/// So many clones
pub fn populate_previous_and_next_pages(input: &[Page]) -> Vec<Page> {
let pages = input.to_vec();
let mut res = Vec::new();
// the input is already sorted
// We might put prev/next randomly if a page is missing date/order, probably fine
for (i, page) in input.iter().enumerate() {
let mut new_page = page.clone();
if i > 0 {
let next = &pages[i - 1];
new_page.next = Some(Box::new(next.clone()));
}
if i < input.len() - 1 {
let previous = &pages[i + 1];
new_page.previous = Some(Box::new(previous.clone()));
}
res.push(new_page);
}
res
}
#[cfg(test)]
mod tests {
use tempdir::TempDir;
use std::fs::File;
use front_matter::{FrontMatter, SortBy};
use super::{Page, find_related_assets, sort_pages, populate_previous_and_next_pages};
fn create_page_with_date(date: &str) -> Page {
let mut front_matter = FrontMatter::default();
front_matter.date = Some(date.to_string());
Page::new(front_matter)
}
fn create_page_with_order(order: usize) -> Page {
let mut front_matter = FrontMatter::default();
front_matter.order = Some(order);
Page::new(front_matter)
}
#[test]
fn test_find_related_assets() {
let tmp_dir = TempDir::new("example").expect("create temp dir");
File::create(tmp_dir.path().join("index.md")).unwrap();
File::create(tmp_dir.path().join("example.js")).unwrap();
File::create(tmp_dir.path().join("graph.jpg")).unwrap();
File::create(tmp_dir.path().join("fail.png")).unwrap();
let assets = find_related_assets(tmp_dir.path());
assert_eq!(assets.len(), 3);
assert_eq!(assets.iter().filter(|p| p.extension().unwrap() != "md").count(), 3);
assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "example.js").count(), 1);
assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "graph.jpg").count(), 1);
assert_eq!(assets.iter().filter(|p| p.file_name().unwrap() == "fail.png").count(), 1);
}
#[test]
fn test_can_sort_dates() {
let input = vec![
create_page_with_date("2018-01-01"),
create_page_with_date("2017-01-01"),
create_page_with_date("2019-01-01"),
];
let (pages, _) = sort_pages(input, SortBy::Date);
// Should be sorted by date
assert_eq!(pages[0].clone().meta.date.unwrap(), "2019-01-01");
assert_eq!(pages[1].clone().meta.date.unwrap(), "2018-01-01");
assert_eq!(pages[2].clone().meta.date.unwrap(), "2017-01-01");
}
#[test]
fn test_can_sort_order() {
let input = vec![
create_page_with_order(2),
create_page_with_order(3),
create_page_with_order(1),
];
let (pages, _) = sort_pages(input, SortBy::Order);
// Should be sorted by date
assert_eq!(pages[0].clone().meta.order.unwrap(), 3);
assert_eq!(pages[1].clone().meta.order.unwrap(), 2);
assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
}
#[test]
fn test_can_sort_none() {
let input = vec![
create_page_with_order(2),
create_page_with_order(3),
create_page_with_order(1),
];
let (pages, _) = sort_pages(input, SortBy::None);
// Should be sorted by date
assert_eq!(pages[0].clone().meta.order.unwrap(), 2);
assert_eq!(pages[1].clone().meta.order.unwrap(), 3);
assert_eq!(pages[2].clone().meta.order.unwrap(), 1);
}
#[test]
fn test_ignore_page_with_missing_field() {
let input = vec![
create_page_with_order(2),
create_page_with_order(3),
create_page_with_date("2019-01-01"),
];
let (pages, unsorted) = sort_pages(input, SortBy::Order);
assert_eq!(pages.len(), 2);
assert_eq!(unsorted.len(), 1);
}
#[test]
fn test_populate_previous_and_next_pages() {
let input = vec![
create_page_with_order(3),
create_page_with_order(2),
create_page_with_order(1),
];
let pages = populate_previous_and_next_pages(input.as_slice());
assert!(pages[0].clone().next.is_none());
assert!(pages[0].clone().previous.is_some());
assert_eq!(pages[0].clone().previous.unwrap().meta.order.unwrap(), 2);
assert!(pages[1].clone().next.is_some());
assert!(pages[1].clone().previous.is_some());
assert_eq!(pages[1].clone().next.unwrap().meta.order.unwrap(), 3);
assert_eq!(pages[1].clone().previous.unwrap().meta.order.unwrap(), 1);
assert!(pages[2].clone().next.is_some());
assert!(pages[2].clone().previous.is_none());
assert_eq!(pages[2].clone().next.unwrap().meta.order.unwrap(), 2);
}
}

View file

@ -10,43 +10,9 @@ use walkdir::WalkDir;
use errors::{Result, ResultExt};
use config::{Config, get_config};
use page::{Page, populate_previous_and_next_pages, sort_pages};
use pagination::Paginator;
use utils::{create_file, create_directory};
use section::{Section};
use front_matter::{SortBy};
use filters;
use global_fns;
lazy_static! {
pub static ref GUTENBERG_TERA: Tera = {
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("rss.xml", include_str!("templates/rss.xml")),
("sitemap.xml", include_str!("templates/sitemap.xml")),
("robots.txt", include_str!("templates/robots.txt")),
("anchor-link.html", include_str!("templates/anchor-link.html")),
("shortcodes/youtube.html", include_str!("templates/shortcodes/youtube.html")),
("shortcodes/vimeo.html", include_str!("templates/shortcodes/vimeo.html")),
("shortcodes/gist.html", include_str!("templates/shortcodes/gist.html")),
("internal/alias.html", include_str!("templates/internal/alias.html")),
]).unwrap();
tera
};
}
/// Renders the `internal/alias.html` template that will redirect
/// via refresh to the url given
fn render_alias(url: &str, tera: &Tera) -> Result<String> {
let mut context = Context::new();
context.add("url", &url);
tera.render("internal/alias.html", &context)
.chain_err(|| format!("Failed to render alias for '{}'", url))
}
use content::{Page, Section, Paginator, SortBy, populate_previous_and_next_pages, sort_pages};
use templates::{GUTENBERG_TERA, global_fns, render_redirect_template};
#[derive(Debug, PartialEq)]
@ -85,6 +51,8 @@ pub struct Site {
static_path: PathBuf,
pub tags: HashMap<String, Vec<PathBuf>>,
pub categories: HashMap<String, Vec<PathBuf>>,
/// A map of all .md files (section and pages) and their permalink
/// We need that if there are relative links in the content that need to be resolved
pub permalinks: HashMap<String, String>,
}
@ -97,9 +65,6 @@ impl Site {
let tpl_glob = format!("{}/{}", path.to_string_lossy().replace("\\", "/"), "templates/**/*.*ml");
let mut tera = Tera::new(&tpl_glob).chain_err(|| "Error parsing templates")?;
tera.extend(&GUTENBERG_TERA)?;
tera.register_filter("markdown", filters::markdown);
tera.register_filter("base64_encode", filters::base64_encode);
tera.register_filter("base64_decode", filters::base64_decode);
let site = Site {
base_path: path.to_path_buf(),
@ -124,6 +89,7 @@ impl Site {
}
/// Gets the path of all ignored pages in the site
/// Used for reporting them in the CLI
pub fn get_ignored_pages(&self) -> Vec<PathBuf> {
self.sections
.values()
@ -149,6 +115,17 @@ impl Site {
orphans
}
/// Finds the section that contains the page given if there is one
pub fn find_parent_section(&self, page: &Page) -> Option<&Section> {
for section in self.sections.values() {
if section.is_child_page(page) {
return Some(section)
}
}
None
}
/// Used by tests to change the output path to a tmp dir
#[doc(hidden)]
pub fn set_output_path<P: AsRef<Path>>(&mut self, path: P) {
@ -161,34 +138,32 @@ impl Site {
let base_path = self.base_path.to_string_lossy().replace("\\", "/");
let content_glob = format!("{}/{}", base_path, "content/**/*.md");
// TODO: make that parallel, that's the main bottleneck
// `add_section` and `add_page` can't be used in the parallel version afaik
for entry in glob(&content_glob).unwrap().filter_map(|e| e.ok()) {
let path = entry.as_path();
if path.file_name().unwrap() == "_index.md" {
self.add_section(path)?;
self.add_section(path, false)?;
} else {
self.add_page(path)?;
self.add_page(path, false)?;
}
}
// A map of all .md files (section and pages) and their permalink
// We need that if there are relative links in the content that need to be resolved
let mut permalinks = HashMap::new();
for page in self.pages.values() {
permalinks.insert(page.relative_path.clone(), page.permalink.clone());
}
for section in self.sections.values() {
permalinks.insert(section.relative_path.clone(), section.permalink.clone());
// Insert a default index section so we don't need to create a _index.md to render
// the index page
let index_path = self.base_path.join("content").join("_index.md");
if !self.sections.contains_key(&index_path) {
let mut index_section = Section::default();
index_section.permalink = self.config.make_permalink("");
self.sections.insert(index_path, index_section);
}
// TODO: make that parallel
for page in self.pages.values_mut() {
page.render_markdown(&permalinks, &self.tera, &self.config)?;
page.render_markdown(&self.permalinks, &self.tera, &self.config)?;
}
// TODO: make that parallel
for section in self.sections.values_mut() {
section.render_markdown(&self.permalinks, &self.tera, &self.config)?;
}
self.permalinks = permalinks;
self.populate_sections();
self.populate_tags_and_categories();
@ -197,59 +172,82 @@ impl Site {
Ok(())
}
/// Simple wrapper fn to avoid repeating that code in several places
fn add_page(&mut self, path: &Path) -> Result<()> {
/// Add a page to the site
/// The `render` parameter is used in the serve command, when rebuilding a page.
/// If `true`, it will also render the markdown for that page
/// Returns the previous page struct if there was one
pub fn add_page(&mut self, path: &Path, render: bool) -> Result<Option<Page>> {
let page = Page::from_file(&path, &self.config)?;
self.pages.insert(page.file_path.clone(), page);
Ok(())
}
/// Simple wrapper fn to avoid repeating that code in several places
fn add_section(&mut self, path: &Path) -> Result<()> {
let section = Section::from_file(path, &self.config)?;
self.sections.insert(section.parent_path.clone(), section);
Ok(())
}
/// Called in serve, add a page again updating permalinks and its content
/// The bool in the result is whether the front matter has been updated or not
fn add_page_and_render(&mut self, path: &Path) -> Result<(bool, Page)> {
let existing_page = self.pages.get(path).expect("Page was supposed to exist in add_page_and_render").clone();
self.add_page(path)?;
let mut page = self.pages.get_mut(path).unwrap();
self.permalinks.insert(page.relative_path.clone(), page.permalink.clone());
page.render_markdown(&self.permalinks, &self.tera, &self.config)?;
let prev = self.pages.insert(page.file_path.clone(), page);
Ok((existing_page.meta != page.meta, page.clone()))
if render {
let mut page = self.pages.get_mut(path).unwrap();
page.render_markdown(&self.permalinks, &self.tera, &self.config)?;
}
Ok(prev)
}
/// Add a section to the site
/// The `render` parameter is used in the serve command, when rebuilding a page.
/// If `true`, it will also render the markdown for that page
/// Returns the previous page struct if there was one
pub fn add_section(&mut self, path: &Path, render: bool) -> Result<Option<Section>> {
let section = Section::from_file(path, &self.config)?;
self.permalinks.insert(section.relative_path.clone(), section.permalink.clone());
let prev = self.sections.insert(section.file_path.clone(), section);
if render {
let mut section = self.sections.get_mut(path).unwrap();
section.render_markdown(&self.permalinks, &self.tera, &self.config)?;
}
Ok(prev)
}
/// Find out the direct subsections of each subsection if there are some
/// as well as the pages for each section
fn populate_sections(&mut self) {
pub fn populate_sections(&mut self) {
let mut grandparent_paths = HashMap::new();
for section in self.sections.values_mut() {
if let Some(grand_parent) = section.parent_path.parent() {
grandparent_paths.entry(grand_parent.to_path_buf()).or_insert_with(|| vec![]).push(section.clone());
}
// Make sure the pages of a section are empty since we can call that many times on `serve`
section.pages = vec![];
section.ignored_pages = vec![];
}
for page in self.pages.values() {
if self.sections.contains_key(&page.parent_path) {
self.sections.get_mut(&page.parent_path).unwrap().pages.push(page.clone());
if self.sections.contains_key(&page.parent_path.join("_index.md")) {
self.sections.get_mut(&page.parent_path.join("_index.md")).unwrap().pages.push(page.clone());
}
}
let mut grandparent_paths = HashMap::new();
for section in self.sections.values() {
let grand_parent = section.parent_path.parent().unwrap().to_path_buf();
grandparent_paths.entry(grand_parent).or_insert_with(|| vec![]).push(section.clone());
}
for (parent_path, section) in &mut self.sections {
// TODO: avoid this clone
let (mut sorted_pages, cannot_be_sorted_pages) = sort_pages(section.pages.clone(), section.meta.sort_by());
sorted_pages = populate_previous_and_next_pages(&sorted_pages);
section.pages = sorted_pages;
section.ignored_pages = cannot_be_sorted_pages;
match grandparent_paths.get(parent_path) {
for section in self.sections.values_mut() {
match grandparent_paths.get(&section.parent_path) {
Some(paths) => section.subsections.extend(paths.clone()),
None => continue,
};
}
self.sort_sections_pages(None);
}
/// Sorts the pages of the section at the given path
/// By default will sort all sections but can be made to only sort a single one by providing a path
pub fn sort_sections_pages(&mut self, only: Option<&Path>) {
for (path, section) in &mut self.sections {
if let Some(p) = only {
if p != path {
continue;
}
}
let (sorted_pages, cannot_be_sorted_pages) = sort_pages(section.pages.clone(), section.meta.sort_by());
section.pages = populate_previous_and_next_pages(&sorted_pages);
section.ignored_pages = cannot_be_sorted_pages;
}
}
/// Separated from `parse` for easier testing
@ -285,7 +283,7 @@ impl Site {
html
}
pub fn ensure_public_directory_exists(&self) -> Result<()> {
fn ensure_public_directory_exists(&self) -> Result<()> {
let public = self.output_path.clone();
if !public.exists() {
create_directory(&public)?;
@ -332,57 +330,6 @@ impl Site {
Ok(())
}
pub fn rebuild_after_content_change(&mut self, path: &Path) -> Result<()> {
let is_section = path.ends_with("_index.md");
if path.exists() {
// file exists, either a new one or updating content
if is_section {
self.add_section(path)?;
} else {
// probably just an update so just re-parse that page
let (frontmatter_changed, page) = self.add_page_and_render(path)?;
// TODO: can probably be smarter and check what changed
if frontmatter_changed {
self.populate_sections();
self.populate_tags_and_categories();
self.build()?;
} else {
self.render_page(&page)?;
}
}
} else {
// File doesn't exist -> a deletion so we remove it from everything
let relative_path = if is_section {
self.sections[path].relative_path.clone()
} else {
self.pages[path].relative_path.clone()
};
self.permalinks.remove(&relative_path);
if is_section {
self.sections.remove(path);
} else {
self.pages.remove(path);
}
// TODO: probably no need to do that, we should be able to only re-render a page or a section.
self.populate_sections();
self.populate_tags_and_categories();
self.build()?;
}
Ok(())
}
pub fn rebuild_after_template_change(&mut self, path: &Path) -> Result<()> {
self.tera.full_reload()?;
match path.file_name().unwrap().to_str().unwrap() {
"sitemap.xml" => self.render_sitemap(),
"rss.xml" => self.render_rss_feed(),
_ => self.build() // TODO: change that
}
}
/// Renders a single content page
pub fn render_page(&self, page: &Page) -> Result<()> {
self.ensure_public_directory_exists()?;
@ -424,18 +371,16 @@ impl Site {
self.render_rss_feed()?;
}
self.render_robots()?;
if self.config.generate_categories_pages.unwrap() {
self.render_categories_and_tags(RenderList::Categories)?;
}
if self.config.generate_tags_pages.unwrap() {
self.render_categories_and_tags(RenderList::Tags)?;
}
// `render_categories` and `render_tags` will check whether the config allows
// them to render or not
self.render_categories()?;
self.render_tags()?;
self.copy_static_directory()
}
/// Renders robots.txt
fn render_robots(&self) -> Result<()> {
pub fn render_robots(&self) -> Result<()> {
self.ensure_public_directory_exists()?;
create_file(
self.output_path.join("robots.txt"),
@ -443,8 +388,27 @@ impl Site {
)
}
/// Renders all categories if the config allows it
pub fn render_categories(&self) -> Result<()> {
if self.config.generate_categories_pages.unwrap() {
self.render_categories_and_tags(RenderList::Categories)
} else {
Ok(())
}
}
/// Renders all tags if the config allows it
pub fn render_tags(&self) -> Result<()> {
if self.config.generate_tags_pages.unwrap() {
self.render_categories_and_tags(RenderList::Tags)
} else {
Ok(())
}
}
/// Render the /{categories, list} pages and each individual category/tag page
/// They are the same thing fundamentally, a list of pages with something in common
/// TODO: revisit this function, lots of things have changed since then
fn render_categories_and_tags(&self, kind: RenderList) -> Result<()> {
let items = match kind {
RenderList::Categories => &self.categories,
@ -516,7 +480,8 @@ impl Site {
Ok(())
}
fn render_sitemap(&self) -> Result<()> {
/// What it says on the tin
pub fn render_sitemap(&self) -> Result<()> {
self.ensure_public_directory_exists()?;
let mut context = Context::new();
context.add("pages", &self.pages.values().collect::<Vec<&Page>>());
@ -551,7 +516,7 @@ impl Site {
Ok(())
}
fn render_rss_feed(&self) -> Result<()> {
pub fn render_rss_feed(&self) -> Result<()> {
self.ensure_public_directory_exists()?;
let mut context = Context::new();
@ -584,49 +549,67 @@ impl Site {
Ok(())
}
fn render_sections(&self) -> Result<()> {
self.ensure_public_directory_exists()?;
let public = self.output_path.clone();
let sections: HashMap<String, Section> = self.sections
/// Create a hashmap of paths to section
/// For example `content/posts/_index.md` key will be `posts`
fn get_sections_map(&self) -> HashMap<String, Section> {
self.sections
.values()
.map(|s| (s.components.join("/"), s.clone()))
.collect();
.collect()
}
for section in self.sections.values() {
let mut output_path = public.to_path_buf();
for component in &section.components {
output_path.push(component);
/// Renders a single section
pub fn render_section(&self, section: &Section, render_pages: bool) -> Result<()> {
self.ensure_public_directory_exists()?;
let public = self.output_path.clone();
if !output_path.exists() {
create_directory(&output_path)?;
}
let mut output_path = public.to_path_buf();
for component in &section.components {
output_path.push(component);
if !output_path.exists() {
create_directory(&output_path)?;
}
}
if render_pages {
for page in &section.pages {
self.render_page(page)?;
}
}
if !section.meta.should_render() {
continue;
}
if !section.meta.should_render() {
return Ok(());
}
if section.meta.is_paginated() {
self.render_paginated(&output_path, section)?;
} else {
let output = section.render_html(
&sections,
&self.tera,
&self.config,
)?;
create_file(output_path.join("index.html"), &self.inject_livereload(output))?;
}
if section.meta.is_paginated() {
self.render_paginated(&output_path, section)?;
} else {
let output = section.render_html(
if section.is_index() { self.get_sections_map() } else { HashMap::new() },
&self.tera,
&self.config,
)?;
create_file(output_path.join("index.html"), &self.inject_livereload(output))?;
}
Ok(())
}
pub fn render_index(&self) -> Result<()> {
self.render_section(&self.sections[&self.base_path.join("content").join("_index.md")], false)
}
/// Renders all sections
pub fn render_sections(&self) -> Result<()> {
for section in self.sections.values() {
self.render_section(section, true)?;
}
Ok(())
}
/// Renders all pages that do not belong to any sections
fn render_orphan_pages(&self) -> Result<()> {
pub fn render_orphan_pages(&self) -> Result<()> {
self.ensure_public_directory_exists()?;
for page in self.get_all_orphan_pages() {
@ -646,7 +629,6 @@ impl Site {
};
let paginator = Paginator::new(&section.pages, section);
for (i, pager) in paginator.pagers.iter().enumerate() {
let folder_path = output_path.join(&paginate_path);
let page_path = folder_path.join(&format!("{}", i + 1));
@ -657,7 +639,7 @@ impl Site {
create_file(page_path.join("index.html"), &self.inject_livereload(output))?;
} else {
create_file(output_path.join("index.html"), &self.inject_livereload(output))?;
create_file(page_path.join("index.html"), &render_alias(&section.permalink, &self.tera)?)?;
create_file(page_path.join("index.html"), &render_redirect_template(&section.permalink, &self.tera)?)?;
}
}

View file

@ -3,7 +3,7 @@ use std::path::{PathBuf};
use tera::{GlobalFn, Value, from_value, to_value, Result};
use page::Page;
use content::Page;
pub fn make_get_page(all_pages: &HashMap<PathBuf, Page>) -> GlobalFn {

39
src/templates/mod.rs Normal file
View file

@ -0,0 +1,39 @@
use tera::{Tera, Context};
use errors::{Result, ResultExt};
pub mod filters;
pub mod global_fns;
lazy_static! {
pub static ref GUTENBERG_TERA: Tera = {
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("rss.xml", include_str!("builtins/rss.xml")),
("sitemap.xml", include_str!("builtins/sitemap.xml")),
("robots.txt", include_str!("builtins/robots.txt")),
("anchor-link.html", include_str!("builtins/anchor-link.html")),
("shortcodes/youtube.html", include_str!("builtins/shortcodes/youtube.html")),
("shortcodes/vimeo.html", include_str!("builtins/shortcodes/vimeo.html")),
("shortcodes/gist.html", include_str!("builtins/shortcodes/gist.html")),
("internal/alias.html", include_str!("builtins/internal/alias.html")),
]).unwrap();
tera.register_filter("markdown", filters::markdown);
tera.register_filter("base64_encode", filters::base64_encode);
tera.register_filter("base64_decode", filters::base64_decode);
tera
};
}
/// Renders the `internal/alias.html` template that will redirect
/// via refresh to the url given
pub fn render_redirect_template(url: &str, tera: &Tera) -> Result<String> {
let mut context = Context::new();
context.add("url", &url);
tera.render("internal/alias.html", &context)
.chain_err(|| format!("Failed to render alias for '{}'", url))
}

View file

@ -21,7 +21,6 @@ pub fn create_directory<P: AsRef<Path>>(path: P) -> Result<()> {
Ok(())
}
/// Return the content of a file, with error handling added
pub fn read_file<P: AsRef<Path>>(path: P) -> Result<String> {
let path = path.as_ref();
@ -35,7 +34,7 @@ pub fn read_file<P: AsRef<Path>>(path: P) -> Result<String> {
}
/// Takes a full path to a .md and returns only the components after the `content` directory
/// Takes a full path to a .md and returns only the components after the first `content` directory
/// Will not return the filename as last component
pub fn find_content_components<P: AsRef<Path>>(path: P) -> Vec<String> {
let path = path.as_ref();

View file

@ -0,0 +1,180 @@
%YAML 1.2
---
# http://www.sublimetext.com/docs/3/syntax.html
name: Jinja2
file_extensions:
- j2
- jinja2
scope: source.jinja2
contexts:
main:
- match: '({%)\s*(raw)\s*(%})'
captures:
1: entity.other.jinja2.delimiter.tag
2: keyword.control.jinja2
3: entity.other.jinja2.delimiter.tag
push:
- meta_scope: comment.block.jinja2.raw
- match: '({%)\s*(endraw)\s*(%})'
captures:
1: entity.other.jinja2.delimiter.tag
2: keyword.control.jinja2
3: entity.other.jinja2.delimiter.tag
pop: true
- match: "{#-?"
captures:
0: entity.other.jinja2.delimiter.comment
push:
- meta_scope: comment.block.jinja2
- match: "-?#}"
captures:
0: entity.other.jinja2.delimiter.comment
pop: true
- match: "{{-?"
captures:
0: entity.other.jinja2.delimiter.variable
push:
- meta_scope: meta.scope.jinja2.variable
- match: "-?}}"
captures:
0: entity.other.jinja2.delimiter.variable
pop: true
- include: expression
- match: "{%-?"
captures:
0: entity.other.jinja2.delimiter.tag
push:
- meta_scope: meta.scope.jinja2.tag
- match: "-?%}"
captures:
0: entity.other.jinja2.delimiter.tag
pop: true
- include: expression
escaped_char:
- match: '\\x[0-9A-F]{2}'
scope: constant.character.escape.hex.jinja2
escaped_unicode_char:
- match: '(\\U[0-9A-Fa-f]{8})|(\\u[0-9A-Fa-f]{4})|(\\N\{[a-zA-Z ]+\})'
captures:
1: constant.character.escape.unicode.16-bit-hex.jinja2
2: constant.character.escape.unicode.32-bit-hex.jinja2
3: constant.character.escape.unicode.name.jinja2
expression:
- match: '\s*\b(macro)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b'
captures:
1: keyword.control.jinja2
2: variable.other.jinja2.macro
- match: '\s*\b(block)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b'
captures:
1: keyword.control.jinja2
2: variable.other.jinja2.block
- match: '\s*\b(filter)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b'
captures:
1: keyword.control.jinja2
2: variable.other.jinja2.filter
- match: '\s*\b(is)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b'
captures:
1: keyword.control.jinja2
2: variable.other.jinja2.test
- match: '(?<=\{\%-|\{\%)\s*\b([a-zA-Z_][a-zA-Z0-9_]*)\b(?!\s*[,=])'
captures:
1: keyword.control.jinja2
- match: \b(and|else|if|in|import|not|or|recursive|with(out)?\s+context)\b
scope: keyword.control.jinja2
- match: '\b([Tt]rue|[Ff]alse|[Nn]one)\b'
scope: constant.language.jinja2
- match: \b(loop|super|self|varargs|kwargs)\b
scope: variable.language.jinja2
- match: "[a-zA-Z_][a-zA-Z0-9_]*"
scope: variable.other.jinja2
- match: (\+|\-|\*\*|\*|//|/|%)
scope: keyword.operator.arithmetic.jinja2
- match: '(\|)([a-zA-Z_][a-zA-Z0-9_]*)'
captures:
1: punctuation.other.jinja2
2: variable.other.jinja2.filter
- match: '(\.)([a-zA-Z_][a-zA-Z0-9_]*)'
captures:
1: punctuation.other.jinja2
2: variable.other.jinja2.attribute
- match: '\['
captures:
0: punctuation.other.jinja2
push:
- match: '\]'
captures:
0: punctuation.other.jinja2
pop: true
- include: expression
- match: \(
captures:
0: punctuation.other.jinja2
push:
- match: \)
captures:
0: punctuation.other.jinja2
pop: true
- include: expression
- match: '\{'
captures:
0: punctuation.other.jinja2
push:
- match: '\}'
captures:
0: punctuation.other.jinja2
pop: true
- include: expression
- match: (\.|:|\||,)
scope: punctuation.other.jinja2
- match: (==|<=|=>|<|>|!=)
scope: keyword.operator.comparison.jinja2
- match: "="
scope: keyword.operator.assignment.jinja2
- match: '"'
captures:
0: punctuation.definition.string.begin.jinja2
push:
- meta_scope: string.quoted.double.jinja2
- match: '"'
captures:
0: punctuation.definition.string.end.jinja2
pop: true
- include: string
- match: "'"
captures:
0: punctuation.definition.string.begin.jinja2
push:
- meta_scope: string.quoted.single.jinja2
- match: "'"
captures:
0: punctuation.definition.string.end.jinja2
pop: true
- include: string
- match: "@/"
captures:
0: punctuation.definition.regexp.begin.jinja2
push:
- meta_scope: string.regexp.jinja2
- match: /
captures:
0: punctuation.definition.regexp.end.jinja2
pop: true
- include: simple_escapes
simple_escapes:
- match: (\\\n)|(\\\\)|(\\\")|(\\')|(\\a)|(\\b)|(\\f)|(\\n)|(\\r)|(\\t)|(\\v)
captures:
1: constant.character.escape.newline.jinja2
2: constant.character.escape.backlash.jinja2
3: constant.character.escape.double-quote.jinja2
4: constant.character.escape.single-quote.jinja2
5: constant.character.escape.bell.jinja2
6: constant.character.escape.backspace.jinja2
7: constant.character.escape.formfeed.jinja2
8: constant.character.escape.linefeed.jinja2
9: constant.character.escape.return.jinja2
10: constant.character.escape.tab.jinja2
11: constant.character.escape.vertical-tab.jinja2
string:
- include: simple_escapes
- include: escaped_char
- include: escaped_unicode_char

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,4 @@
+++
paginate_by = 10
template = "section_paginated.html"
+++

View file

@ -1,4 +1,5 @@
+++
title = "Posts"
description = ""
paginate_by = 2
template = "section_paginated.html"
+++

View file

@ -1,236 +0,0 @@
extern crate gutenberg;
extern crate tera;
use std::path::Path;
use gutenberg::{FrontMatter, split_content, SortBy};
use tera::to_value;
#[test]
fn test_can_parse_a_valid_front_matter() {
let content = r#"
title = "Hello"
description = "hey there""#;
let res = FrontMatter::parse(content);
println!("{:?}", res);
assert!(res.is_ok());
let res = res.unwrap();
assert_eq!(res.title.unwrap(), "Hello".to_string());
assert_eq!(res.description.unwrap(), "hey there".to_string());
}
#[test]
fn test_can_parse_tags() {
let content = r#"
title = "Hello"
description = "hey there"
slug = "hello-world"
tags = ["rust", "html"]"#;
let res = FrontMatter::parse(content);
assert!(res.is_ok());
let res = res.unwrap();
assert_eq!(res.title.unwrap(), "Hello".to_string());
assert_eq!(res.slug.unwrap(), "hello-world".to_string());
assert_eq!(res.tags.unwrap(), ["rust".to_string(), "html".to_string()]);
}
#[test]
fn test_can_parse_extra_attributes_in_frontmatter() {
let content = r#"
title = "Hello"
description = "hey there"
slug = "hello-world"
[extra]
language = "en"
authors = ["Bob", "Alice"]"#;
let res = FrontMatter::parse(content);
assert!(res.is_ok());
let res = res.unwrap();
assert_eq!(res.title.unwrap(), "Hello".to_string());
assert_eq!(res.slug.unwrap(), "hello-world".to_string());
let extra = res.extra.unwrap();
assert_eq!(extra["language"], to_value("en").unwrap());
assert_eq!(
extra["authors"],
to_value(["Bob".to_string(), "Alice".to_string()]).unwrap()
);
}
#[test]
fn test_is_ok_with_url_instead_of_slug() {
let content = r#"
title = "Hello"
description = "hey there"
url = "hello-world""#;
let res = FrontMatter::parse(content);
assert!(res.is_ok());
let res = res.unwrap();
assert!(res.slug.is_none());
assert_eq!(res.url.unwrap(), "hello-world".to_string());
}
#[test]
fn test_is_ok_with_empty_front_matter() {
let content = r#" "#;
let res = FrontMatter::parse(content);
assert!(res.is_ok());
}
#[test]
fn test_errors_with_invalid_front_matter() {
let content = r#"title = 1\n"#;
let res = FrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn test_errors_on_non_string_tag() {
let content = r#"
title = "Hello"
description = "hey there"
slug = "hello-world"
tags = ["rust", 1]"#;
let res = FrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn test_errors_on_present_but_empty_slug() {
let content = r#"
title = "Hello"
description = "hey there"
slug = """#;
let res = FrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn test_errors_on_present_but_empty_url() {
let content = r#"
title = "Hello"
description = "hey there"
url = """#;
let res = FrontMatter::parse(content);
assert!(res.is_err());
}
#[test]
fn test_parse_date_yyyy_mm_dd() {
let content = r#"
title = "Hello"
description = "hey there"
date = "2016-10-10""#;
let res = FrontMatter::parse(content).unwrap();
assert!(res.date().is_some());
}
#[test]
fn test_parse_date_rfc3339() {
let content = r#"
title = "Hello"
description = "hey there"
date = "2002-10-02T15:00:00Z""#;
let res = FrontMatter::parse(content).unwrap();
assert!(res.date().is_some());
}
#[test]
fn test_cant_parse_random_date_format() {
let content = r#"
title = "Hello"
description = "hey there"
date = "2002/10/12""#;
let res = FrontMatter::parse(content).unwrap();
assert!(res.date().is_none());
}
#[test]
fn test_cant_parse_sort_by_date() {
let content = r#"
title = "Hello"
description = "hey there"
sort_by = "date""#;
let res = FrontMatter::parse(content).unwrap();
assert!(res.sort_by.is_some());
assert_eq!(res.sort_by.unwrap(), SortBy::Date);
}
#[test]
fn test_cant_parse_sort_by_order() {
let content = r#"
title = "Hello"
description = "hey there"
sort_by = "order""#;
let res = FrontMatter::parse(content).unwrap();
assert!(res.sort_by.is_some());
assert_eq!(res.sort_by.unwrap(), SortBy::Order);
}
#[test]
fn test_cant_parse_sort_by_none() {
let content = r#"
title = "Hello"
description = "hey there"
sort_by = "none""#;
let res = FrontMatter::parse(content).unwrap();
assert!(res.sort_by.is_some());
assert_eq!(res.sort_by.unwrap(), SortBy::None);
}
#[test]
fn test_can_split_content_valid() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002/10/12"
+++
Hello
"#;
let (front_matter, content) = split_content(Path::new(""), content).unwrap();
assert_eq!(content, "Hello\n");
assert_eq!(front_matter.title.unwrap(), "Title");
}
#[test]
fn test_can_split_content_with_only_frontmatter_valid() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002/10/12"
+++"#;
let (front_matter, content) = split_content(Path::new(""), content).unwrap();
assert_eq!(content, "");
assert_eq!(front_matter.title.unwrap(), "Title");
}
#[test]
fn test_can_split_content_lazily() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002-10-02T15:00:00Z"
+++
+++"#;
let (front_matter, content) = split_content(Path::new(""), content).unwrap();
assert_eq!(content, "+++");
assert_eq!(front_matter.title.unwrap(), "Title");
}
#[test]
fn test_error_if_cannot_locate_frontmatter() {
let content = r#"
+++
title = "Title"
description = "hey there"
date = "2002/10/12"
"#;
let res = split_content(Path::new(""), content);
assert!(res.is_err());
}

View file

@ -163,43 +163,6 @@ Hello world"#;
assert_eq!(page.permalink, format!("{}{}", Config::default().base_url, "file-with-space"));
}
#[test]
fn test_reading_analytics_short() {
let content = r#"
+++
title = "Hello"
description = "hey there"
+++
Hello world"#;
let res = Page::parse(Path::new("hello.md"), content, &Config::default());
assert!(res.is_ok());
let mut page = res.unwrap();
page.render_markdown(&HashMap::default(), &Tera::default(), &Config::default()).unwrap();
let (word_count, reading_time) = page.get_reading_analytics();
assert_eq!(word_count, 2);
assert_eq!(reading_time, 0);
}
#[test]
fn test_reading_analytics_long() {
let mut content = r#"
+++
title = "Hello"
description = "hey there"
+++
Hello world"#.to_string();
for _ in 0..1000 {
content.push_str(" Hello world");
}
let res = Page::parse(Path::new("hello.md"), &content, &Config::default());
assert!(res.is_ok());
let mut page = res.unwrap();
page.render_markdown(&HashMap::default(), &Tera::default(), &Config::default()).unwrap();
let (word_count, reading_time) = page.get_reading_analytics();
assert_eq!(word_count, 2002);
assert_eq!(reading_time, 10);
}
#[test]
fn test_automatic_summary_is_empty_string() {
let content = r#"

View file

@ -35,26 +35,26 @@ fn test_can_parse_site() {
assert_eq!(asset_folder_post.components, vec!["posts".to_string()]);
// That we have the right number of sections
assert_eq!(site.sections.len(), 5);
assert_eq!(site.sections.len(), 6);
// And that the sections are correct
let index_section = &site.sections[&path.join("content")];
assert_eq!(index_section.subsections.len(), 1);
let index_section = &site.sections[&path.join("content").join("_index.md")];
assert_eq!(index_section.subsections.len(), 2);
assert_eq!(index_section.pages.len(), 1);
let posts_section = &site.sections[&posts_path];
let posts_section = &site.sections[&posts_path.join("_index.md")];
assert_eq!(posts_section.subsections.len(), 1);
assert_eq!(posts_section.pages.len(), 5);
let tutorials_section = &site.sections[&posts_path.join("tutorials")];
let tutorials_section = &site.sections[&posts_path.join("tutorials").join("_index.md")];
assert_eq!(tutorials_section.subsections.len(), 2);
assert_eq!(tutorials_section.pages.len(), 0);
let devops_section = &site.sections[&posts_path.join("tutorials").join("devops")];
let devops_section = &site.sections[&posts_path.join("tutorials").join("devops").join("_index.md")];
assert_eq!(devops_section.subsections.len(), 0);
assert_eq!(devops_section.pages.len(), 2);
let prog_section = &site.sections[&posts_path.join("tutorials").join("programming")];
let prog_section = &site.sections[&posts_path.join("tutorials").join("programming").join("_index.md")];
assert_eq!(prog_section.subsections.len(), 0);
assert_eq!(prog_section.pages.len(), 2);
}
@ -173,6 +173,7 @@ fn test_can_build_site_with_categories() {
let mut path = env::current_dir().unwrap().to_path_buf();
path.push("test_site");
let mut site = Site::new(&path, "config.toml").unwrap();
site.config.generate_categories_pages = Some(true);
site.load().unwrap();
for (i, page) in site.pages.values_mut().enumerate() {
@ -224,6 +225,7 @@ fn test_can_build_site_with_tags() {
let mut path = env::current_dir().unwrap().to_path_buf();
path.push("test_site");
let mut site = Site::new(&path, "config.toml").unwrap();
site.config.generate_tags_pages = Some(true);
site.load().unwrap();
for (i, page) in site.pages.values_mut().enumerate() {
@ -294,6 +296,9 @@ fn test_can_build_site_with_pagination_for_section() {
let mut site = Site::new(&path, "config.toml").unwrap();
site.load().unwrap();
for section in site.sections.values_mut(){
if section.is_index() {
continue;
}
section.meta.paginate_by = Some(2);
section.meta.template = Some("section_paginated.html".to_string());
}
@ -316,6 +321,9 @@ fn test_can_build_site_with_pagination_for_section() {
assert!(file_exists!(public, "posts/index.html"));
// And pagination!
assert!(file_exists!(public, "posts/page/1/index.html"));
// even if there is no pages, only the section!
assert!(file_exists!(public, "paginated/page/1/index.html"));
assert!(file_exists!(public, "paginated/index.html"));
// should redirect to posts/
assert!(file_contains!(
public,
@ -347,7 +355,7 @@ fn test_can_build_site_with_pagination_for_index() {
let mut site = Site::new(&path, "config.toml").unwrap();
site.load().unwrap();
{
let mut index = site.sections.get_mut(&path.join("content")).unwrap();
let mut index = site.sections.get_mut(&path.join("content").join("_index.md")).unwrap();
index.meta.paginate_by = Some(2);
index.meta.template = Some("index_paginated.html".to_string());
}
@ -368,6 +376,9 @@ fn test_can_build_site_with_pagination_for_index() {
// And pagination!
assert!(file_exists!(public, "page/1/index.html"));
// even if there is no pages, only the section!
assert!(file_exists!(public, "paginated/page/1/index.html"));
assert!(file_exists!(public, "paginated/index.html"));
// should redirect to index
assert!(file_contains!(
public,