mirror of
https://github.com/zyllian/zyllian.github.io.git
synced 2025-05-09 18:16:43 -07:00
the webdog update
This commit is contained in:
parent
00c90a12d7
commit
3c43403089
26 changed files with 29 additions and 4752 deletions
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
|
@ -37,10 +37,14 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Install webdog
|
||||||
|
run: |
|
||||||
|
cargo install webdog --git https://github.com/zyllian/webdog --no-default-features
|
||||||
|
|
||||||
# Runs a single command using the runners shell
|
# Runs a single command using the runners shell
|
||||||
- name: Build site
|
- name: Build site
|
||||||
run: |
|
run: |
|
||||||
cargo run --no-default-features --release
|
webdog build --site-path site
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v5
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,2 @@
|
||||||
/target
|
|
||||||
/site/build
|
/site/build
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
hard_tabs = true
|
|
3002
Cargo.lock
generated
3002
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
38
Cargo.toml
38
Cargo.toml
|
@ -1,38 +0,0 @@
|
||||||
[package]
|
|
||||||
edition = "2018"
|
|
||||||
name = "zyl-site"
|
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
color-eyre = {version = "0.6", optional = true}
|
|
||||||
extract-frontmatter = "4"
|
|
||||||
eyre = "0.6"
|
|
||||||
fs_extra = "1.2"
|
|
||||||
futures = {version = "0.3", optional = true}
|
|
||||||
grass = {version = "0.13", default-features = false}
|
|
||||||
hotwatch = {version = "0.5", optional = true}
|
|
||||||
itertools = "0.13"
|
|
||||||
lol_html = "2"
|
|
||||||
minifier = {version = "0.3", features = ["html"]}
|
|
||||||
percent-encoding = {version = "2", optional = true}
|
|
||||||
pulldown-cmark = {version = "0.12", default-features = false, features = [
|
|
||||||
"simd",
|
|
||||||
"html",
|
|
||||||
]}
|
|
||||||
rayon = "1"
|
|
||||||
rss = {version = "2", features = ["validation"]}
|
|
||||||
serde = {version = "1", features = ["derive"]}
|
|
||||||
serde_yml = "0.0.12"
|
|
||||||
tera = "1"
|
|
||||||
time = {version = "0.3", features = ["serde-human-readable"]}
|
|
||||||
tokio = {version = "1.10", features = [
|
|
||||||
"macros",
|
|
||||||
"rt-multi-thread",
|
|
||||||
], optional = true}
|
|
||||||
url = {version = "2", features = ["serde"]}
|
|
||||||
walkdir = "2"
|
|
||||||
warp = {version = "0.3", optional = true}
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["serve", "color-eyre"]
|
|
||||||
serve = ["futures", "hotwatch", "percent-encoding", "tokio", "warp"]
|
|
|
@ -1,5 +1,3 @@
|
||||||
# zyl.gay
|
# zyl.gay
|
||||||
|
|
||||||
Source for my website (located at [zyl.gay](https://zyl.gay)) and the custom static site generator I've built alongside it.
|
Source for my website (located at [zyl.gay](https://zyl.gay)). The generated has become its own tool, located at [webdog.zyl.gay](https://webdog.zyl.gay).
|
||||||
|
|
||||||
Feel free to make or suggest changes.
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
[toolchain]
|
|
||||||
channel = "stable"
|
|
|
@ -3,16 +3,17 @@ title: zyl is gay
|
||||||
description: "zyl's website."
|
description: "zyl's website."
|
||||||
sass_styles: [index.scss, pet.scss, click.scss]
|
sass_styles: [index.scss, pet.scss, click.scss]
|
||||||
cdn_url: "https://i.zyl.gay"
|
cdn_url: "https://i.zyl.gay"
|
||||||
|
code_theme: base16-ocean.dark
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
blog:
|
blog:
|
||||||
source_path: blog
|
source_path: blog
|
||||||
output_path_short: blog
|
output_path_short: blog
|
||||||
output_path_long: blog
|
output_path_long: blog
|
||||||
resource_template: blog-post.tera
|
resource_template: blog/resource.tera
|
||||||
resource_list_template: blog-list.tera
|
resource_list_template: blog/list.tera
|
||||||
tag_list_template: basic-link-list.tera
|
tag_list_template: basic-link-list.tera
|
||||||
rss_template: rss/blog-post.tera
|
rss_template: blog/rss.tera
|
||||||
rss_title: zyl's blog
|
rss_title: zyl's blog
|
||||||
rss_description: feed of recent blog posts on zyl's website.
|
rss_description: feed of recent blog posts on zyl's website.
|
||||||
list_title: blog
|
list_title: blog
|
||||||
|
@ -23,10 +24,10 @@ resources:
|
||||||
source_path: images
|
source_path: images
|
||||||
output_path_short: i
|
output_path_short: i
|
||||||
output_path_long: images
|
output_path_long: images
|
||||||
resource_template: image.tera
|
resource_template: image/resource.tera
|
||||||
resource_list_template: images.tera
|
resource_list_template: image/list.tera
|
||||||
tag_list_template: basic-link-list.tera
|
tag_list_template: basic-link-list.tera
|
||||||
rss_template: rss/image.tera
|
rss_template: image/rss.tera
|
||||||
rss_title: zyl's images
|
rss_title: zyl's images
|
||||||
rss_description: feed of newly uploaded images from zyl's website.
|
rss_description: feed of newly uploaded images from zyl's website.
|
||||||
list_title: images
|
list_title: images
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
<div class="flex-spacer"></div>
|
<div class="flex-spacer"></div>
|
||||||
<hr />
|
<hr />
|
||||||
<footer id="footer">
|
<footer id="footer">
|
||||||
bark bark awruff :3
|
bark bark awruff :3 • powered by <strong><a href="https://webdog.zyl.gay">webdog</a></strong>
|
||||||
|
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
{{ self::badge(badge="transbian.png", url="https://badge.les.bi", alt="transgender and lesbian flags") }}
|
{{ self::badge(badge="transbian.png", url="https://badge.les.bi", alt="transgender and lesbian flags") }}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
{% extends "base.tera" %}
|
||||||
|
{% block content %}
|
||||||
<h1>{{ title }}</h1>
|
<h1>{{ title }}</h1>
|
||||||
<div class="link-list">
|
<div class="link-list">
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -6,3 +8,4 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
{% extends "base.tera" %}
|
||||||
|
{% block content %}
|
||||||
{% if tag %}
|
{% if tag %}
|
||||||
<h1>blog posts tagged {{tag}}</h1>
|
<h1>blog posts tagged {{tag}}</h1>
|
||||||
<p><a href="/blog/">View all blog posts</a></p>
|
<p><a href="/blog/">View all blog posts</a></p>
|
||||||
|
@ -22,3 +24,4 @@
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock content %}
|
|
@ -1,3 +1,5 @@
|
||||||
|
{% extends "base.tera" %}
|
||||||
|
{% block content %}
|
||||||
<div class="blog-post">
|
<div class="blog-post">
|
||||||
<h1 class="title">{{title}}</h1>
|
<h1 class="title">{{title}}</h1>
|
||||||
<span class="timestamp">published {{timestamp}}</span>
|
<span class="timestamp">published {{timestamp}}</span>
|
||||||
|
@ -20,3 +22,4 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock content %}
|
|
@ -1,3 +1,5 @@
|
||||||
|
{% extends "base.tera" %}
|
||||||
|
{% block content %}
|
||||||
{% if tag %}
|
{% if tag %}
|
||||||
<h1>images tagged {{tag}}</h1>
|
<h1>images tagged {{tag}}</h1>
|
||||||
<p><a href="/images/">view all images</a></p>
|
<p><a href="/images/">view all images</a></p>
|
||||||
|
@ -21,3 +23,4 @@
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock content %}
|
|
@ -1,3 +1,5 @@
|
||||||
|
{% extends "base.tera" %}
|
||||||
|
{% block content %}
|
||||||
<div class="image-full">
|
<div class="image-full">
|
||||||
<h1 class="title">{{title}}</h1>
|
<h1 class="title">{{title}}</h1>
|
||||||
<span class="timestamp">published {{timestamp}}</span>
|
<span class="timestamp">published {{timestamp}}</span>
|
||||||
|
@ -11,3 +13,4 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock content %}
|
394
src/builder.rs
394
src/builder.rs
|
@ -1,394 +0,0 @@
|
||||||
//! Module containing the site builder.
|
|
||||||
|
|
||||||
use std::{collections::HashMap, path::PathBuf};
|
|
||||||
|
|
||||||
use eyre::{eyre, Context, OptionExt};
|
|
||||||
use lol_html::{element, html_content::ContentType, HtmlRewriter, Settings};
|
|
||||||
use pulldown_cmark::{Options, Parser};
|
|
||||||
use serde::Serialize;
|
|
||||||
use tera::Tera;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::{resource::ResourceBuilder, util, PageMetadata, Site, ROOT_PATH, SASS_PATH};
|
|
||||||
|
|
||||||
/// Struct containing data to be sent to templates when rendering them.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct TemplateData<'a, T> {
|
|
||||||
/// The rendered page.
|
|
||||||
pub page: &'a str,
|
|
||||||
/// The page's title.
|
|
||||||
pub title: &'a str,
|
|
||||||
/// Custom head data for the page.
|
|
||||||
pub head: Option<String>,
|
|
||||||
/// The page's custom scripts.
|
|
||||||
pub scripts: &'a [String],
|
|
||||||
/// the page's custom styles.
|
|
||||||
pub styles: &'a [String],
|
|
||||||
/// Custom template data.
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra_data: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Struct used to build the site.
|
|
||||||
pub struct SiteBuilder {
|
|
||||||
/// The Handlebars registry used to render templates.
|
|
||||||
pub(crate) tera: Tera,
|
|
||||||
/// The site info used to build the site.
|
|
||||||
pub site: Site,
|
|
||||||
/// The path to the build directory.
|
|
||||||
pub build_path: PathBuf,
|
|
||||||
/// Whether the site is going to be served locally with the dev server.
|
|
||||||
serving: bool,
|
|
||||||
|
|
||||||
/// The resource builders available to the builder.
|
|
||||||
pub resource_builders: HashMap<String, ResourceBuilder>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SiteBuilder {
|
|
||||||
/// Creates a new site builder.
|
|
||||||
pub fn new(site: Site, serving: bool) -> Self {
|
|
||||||
let mut build_path = match &site.config.build {
|
|
||||||
Some(build) => site.site_path.join(build),
|
|
||||||
_ => site.site_path.join("build"),
|
|
||||||
};
|
|
||||||
if serving {
|
|
||||||
build_path = site.site_path.join("build");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tera = Tera::new(
|
|
||||||
site.site_path
|
|
||||||
.join("templates/**/*.tera")
|
|
||||||
.to_str()
|
|
||||||
.expect("failed to convert path to string"),
|
|
||||||
)
|
|
||||||
.expect("failed to create tera instance");
|
|
||||||
tera.autoescape_on(vec![".tera"]);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
tera,
|
|
||||||
resource_builders: HashMap::new(),
|
|
||||||
site,
|
|
||||||
build_path,
|
|
||||||
serving,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prepares the site builder for use and sets up the build directory.
|
|
||||||
pub fn prepare(mut self) -> eyre::Result<Self> {
|
|
||||||
self.tera.full_reload()?;
|
|
||||||
if self.build_path.exists() {
|
|
||||||
for entry in self.build_path.read_dir()? {
|
|
||||||
let path = &entry?.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
std::fs::remove_dir_all(path).with_context(|| {
|
|
||||||
format!("Failed to remove directory at {}", path.display())
|
|
||||||
})?;
|
|
||||||
} else {
|
|
||||||
std::fs::remove_file(path)
|
|
||||||
.with_context(|| format!("Failed to remove file at {}", path.display()))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
std::fs::create_dir(&self.build_path).wrap_err("Failed to create build directory")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let root_path = self.site.site_path.join(ROOT_PATH);
|
|
||||||
if root_path.exists() {
|
|
||||||
for entry in walkdir::WalkDir::new(&root_path) {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let output_path = self.build_path.join(path.strip_prefix(&root_path)?);
|
|
||||||
let parent_path = output_path.parent().expect("should never fail");
|
|
||||||
std::fs::create_dir_all(parent_path)?;
|
|
||||||
std::fs::copy(path, output_path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.reload()?;
|
|
||||||
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Performs actions that need to be done when the config changes while serving.
|
|
||||||
pub fn reload(&mut self) -> eyre::Result<()> {
|
|
||||||
self.resource_builders.clear();
|
|
||||||
for (prefix, config) in &self.site.config.resources {
|
|
||||||
self.resource_builders
|
|
||||||
.insert(prefix.to_owned(), ResourceBuilder::new(config.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
for prefix in self.resource_builders.keys().cloned().collect::<Vec<_>>() {
|
|
||||||
self.reload_resource_builder(&prefix)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reloads a particular resource builder's metadata.
|
|
||||||
pub fn reload_resource_builder(&mut self, builder: &str) -> eyre::Result<()> {
|
|
||||||
let mut resource_builder = self
|
|
||||||
.resource_builders
|
|
||||||
.remove(builder)
|
|
||||||
.ok_or_else(|| eyre!("missing resource builder: {builder}"))?;
|
|
||||||
resource_builder.load_all(self)?;
|
|
||||||
self.resource_builders
|
|
||||||
.insert(builder.to_string(), resource_builder);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Function to rewrite HTML wow.
|
|
||||||
pub fn rewrite_html(&self, html: String) -> eyre::Result<String> {
|
|
||||||
let mut output = Vec::new();
|
|
||||||
let mut rewriter = HtmlRewriter::new(
|
|
||||||
Settings {
|
|
||||||
element_content_handlers: vec![
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
element!("body", |el| {
|
|
||||||
el.set_attribute("class", "debug")?;
|
|
||||||
Ok(())
|
|
||||||
}),
|
|
||||||
element!("head", |el| {
|
|
||||||
el.prepend(r#"<meta charset="utf-8">"#, ContentType::Html);
|
|
||||||
if self.serving {
|
|
||||||
el.append(r#"<script src="/_dev.js"></script>"#, ContentType::Html);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}),
|
|
||||||
element!("a", |el| {
|
|
||||||
if let Some(mut href) = el.get_attribute("href") {
|
|
||||||
if let Some((command, mut new_href)) = href.split_once('$') {
|
|
||||||
#[allow(clippy::single_match)]
|
|
||||||
match command {
|
|
||||||
"me" => {
|
|
||||||
el.set_attribute(
|
|
||||||
"rel",
|
|
||||||
&(el.get_attribute("rel").unwrap_or_default() + " me"),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
new_href = &href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
href = new_href.to_string();
|
|
||||||
el.set_attribute("href", &href)?;
|
|
||||||
}
|
|
||||||
if let Ok(url) = Url::parse(&href) {
|
|
||||||
if url.host().is_some() {
|
|
||||||
// Make external links open in new tabs without referral information
|
|
||||||
el.set_attribute(
|
|
||||||
"rel",
|
|
||||||
(el.get_attribute("rel").unwrap_or_default()
|
|
||||||
+ " noopener noreferrer")
|
|
||||||
.trim(),
|
|
||||||
)?;
|
|
||||||
el.set_attribute("target", "_blank")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}),
|
|
||||||
element!("md", |el| {
|
|
||||||
el.remove();
|
|
||||||
let class = el.get_attribute("class");
|
|
||||||
|
|
||||||
let md_type = el
|
|
||||||
.get_attribute("type")
|
|
||||||
.ok_or_eyre("missing type attribute on markdown tag")?;
|
|
||||||
|
|
||||||
if md_type == "blog-image" {
|
|
||||||
let mut src = el
|
|
||||||
.get_attribute("src")
|
|
||||||
.ok_or_eyre("missing src attribute")?;
|
|
||||||
|
|
||||||
if src.starts_with("cdn$") {
|
|
||||||
src = self.site.config.cdn_url(&src[4..])?.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let class = format!("image {}", class.unwrap_or_default());
|
|
||||||
let content = el
|
|
||||||
.get_attribute("content")
|
|
||||||
.ok_or_eyre("missing content attribute")?;
|
|
||||||
|
|
||||||
el.replace(
|
|
||||||
&format!(
|
|
||||||
r#"
|
|
||||||
<div class="{class}">
|
|
||||||
<a href="{src}">
|
|
||||||
<img src="{src}">
|
|
||||||
</a>
|
|
||||||
<span>{content}</span>
|
|
||||||
</div>
|
|
||||||
"#
|
|
||||||
),
|
|
||||||
ContentType::Html,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Err(eyre!("unknown markdown tag type: {md_type}").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
strict: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
|c: &[u8]| output.extend_from_slice(c),
|
|
||||||
);
|
|
||||||
|
|
||||||
rewriter.write(html.as_bytes())?;
|
|
||||||
rewriter.end()?;
|
|
||||||
|
|
||||||
Ok(String::from_utf8(output)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_page_raw(
|
|
||||||
&self,
|
|
||||||
page_metadata: PageMetadata,
|
|
||||||
page_html: &str,
|
|
||||||
) -> eyre::Result<String> {
|
|
||||||
self.build_page_raw_with_extra_data(page_metadata, page_html, ())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to build a page without writing it to disk.
|
|
||||||
pub fn build_page_raw_with_extra_data<T>(
|
|
||||||
&self,
|
|
||||||
mut page_metadata: PageMetadata,
|
|
||||||
page_html: &str,
|
|
||||||
extra_data: T,
|
|
||||||
) -> eyre::Result<String>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
let extra = page_metadata.extra.take();
|
|
||||||
|
|
||||||
let title = match &page_metadata.title {
|
|
||||||
Some(page_title) => format!("{} / {}", self.site.config.title, page_title),
|
|
||||||
_ => self.site.config.title.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let head = page_metadata.embed.map(|mut embed| {
|
|
||||||
embed.site_name.clone_from(&self.site.config.title);
|
|
||||||
embed.build()
|
|
||||||
});
|
|
||||||
|
|
||||||
let out = self.tera.render(
|
|
||||||
&page_metadata
|
|
||||||
.template
|
|
||||||
.unwrap_or_else(|| "base.tera".to_string()),
|
|
||||||
&tera::Context::from_serialize(TemplateData {
|
|
||||||
page: page_html,
|
|
||||||
title: &title,
|
|
||||||
head,
|
|
||||||
scripts: &page_metadata.scripts,
|
|
||||||
styles: &page_metadata.styles,
|
|
||||||
extra_data,
|
|
||||||
})?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Modify HTML output
|
|
||||||
let mut out = self.rewrite_html(out)?;
|
|
||||||
|
|
||||||
if let Some(data) = extra {
|
|
||||||
if let Some(extra) = crate::extras::get_extra(&data.name) {
|
|
||||||
out = extra.handle(out, self, &data)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.serving {
|
|
||||||
out = minifier::html::minify(&out);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a standard page.
|
|
||||||
pub fn build_page(&self, page_name: &str) -> eyre::Result<()> {
|
|
||||||
let page_path = self.site.page_index.get(page_name).expect("Missing page");
|
|
||||||
|
|
||||||
let input = std::fs::read_to_string(page_path)
|
|
||||||
.with_context(|| format!("Failed to read page at {}", page_path.display()))?;
|
|
||||||
let page = crate::frontmatter::FrontMatter::parse(input)?;
|
|
||||||
|
|
||||||
let parser = Parser::new_ext(&page.content, Options::all());
|
|
||||||
let mut page_html = String::new();
|
|
||||||
pulldown_cmark::html::push_html(&mut page_html, parser);
|
|
||||||
|
|
||||||
let out = self.build_page_raw(page.data.unwrap_or_default(), &page_html)?;
|
|
||||||
|
|
||||||
let out_path = self.build_path.join(page_name).with_extension("html");
|
|
||||||
std::fs::create_dir_all(out_path.parent().unwrap())
|
|
||||||
.with_context(|| format!("Failed to create directory for page {}", page_name))?;
|
|
||||||
std::fs::write(&out_path, out).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Failed to create HTML file at {} for page {}",
|
|
||||||
out_path.display(),
|
|
||||||
page_name
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the Sass styles in the site.
|
|
||||||
pub fn build_sass(&self) -> eyre::Result<()> {
|
|
||||||
let styles_path = self.build_path.join("styles");
|
|
||||||
if !styles_path.exists() {
|
|
||||||
std::fs::create_dir(&styles_path)?;
|
|
||||||
}
|
|
||||||
if self.serving {
|
|
||||||
util::remove_dir_contents(&styles_path)
|
|
||||||
.wrap_err("Failed to remove old contents of styles directory")?;
|
|
||||||
}
|
|
||||||
let sass_path = self.site.site_path.join(SASS_PATH);
|
|
||||||
for sheet in &self.site.config.sass_styles {
|
|
||||||
let sheet_path = sass_path.join(sheet);
|
|
||||||
if let Some(sheet_path) = sheet_path.to_str() {
|
|
||||||
match grass::from_path(sheet_path, &grass::Options::default()) {
|
|
||||||
Ok(mut css) => {
|
|
||||||
if !self.serving {
|
|
||||||
css = minifier::css::minify(&css)
|
|
||||||
.map_err(|err| eyre::anyhow!(err))?
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
std::fs::write(styles_path.join(sheet).with_extension("css"), css)
|
|
||||||
.with_context(|| {
|
|
||||||
format!("Failed to write new CSS file for Sass: {:?}", sheet)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!(
|
|
||||||
"Failed to compile Sass stylesheet at {:?}: {}",
|
|
||||||
sheet_path, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"Sass stylesheet path contains invalid UTF-8: {:?}",
|
|
||||||
sheet_path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds all resource types.
|
|
||||||
pub fn build_all_resources(&self) -> eyre::Result<()> {
|
|
||||||
for builder in self.resource_builders.values() {
|
|
||||||
builder.build_all(self)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a resource type from the site.
|
|
||||||
pub fn build_resources(&self, resource: &str) -> eyre::Result<()> {
|
|
||||||
self.resource_builders
|
|
||||||
.get(resource)
|
|
||||||
.ok_or_else(|| eyre!("missing resource: {resource}"))?
|
|
||||||
.build_all(self)
|
|
||||||
}
|
|
||||||
}
|
|
114
src/extras.rs
114
src/extras.rs
|
@ -1,114 +0,0 @@
|
||||||
use lol_html::{element, RewriteStrSettings};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{builder::SiteBuilder, resource::ResourceTemplateData};
|
|
||||||
|
|
||||||
/// Types of extras.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Extra {
|
|
||||||
/// Simply appends to the page within content.
|
|
||||||
Basic,
|
|
||||||
/// May modify the HTML output in any way.
|
|
||||||
HtmlModification(
|
|
||||||
fn(page: String, builder: &SiteBuilder, data: &ExtraData) -> eyre::Result<String>,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Extra {
|
|
||||||
/// runs the handler for the extra
|
|
||||||
pub fn handle(
|
|
||||||
&self,
|
|
||||||
page: String,
|
|
||||||
builder: &SiteBuilder,
|
|
||||||
data: &ExtraData,
|
|
||||||
) -> eyre::Result<String> {
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BasicData {
|
|
||||||
template: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
match self {
|
|
||||||
Self::Basic => {
|
|
||||||
let data: BasicData = serde_yml::from_value(data.inner.clone())?;
|
|
||||||
let content = builder.tera.render(&data.template, &tera::Context::new())?;
|
|
||||||
append_to(&page, &content, "main.page")
|
|
||||||
}
|
|
||||||
Self::HtmlModification(f) => (f)(page, builder, data),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Data for extras.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ExtraData {
|
|
||||||
/// The name of the extra to run.
|
|
||||||
pub name: String,
|
|
||||||
/// The inner data for the extra.
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub inner: serde_yml::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the extra for the given value.
|
|
||||||
pub fn get_extra(extra: &str) -> Option<Extra> {
|
|
||||||
match extra {
|
|
||||||
"basic" => Some(Extra::Basic),
|
|
||||||
"resource-list-outside" => Some(Extra::HtmlModification(resource_list_outside)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extra to append a tempalte to the page.
|
|
||||||
fn append_to(page: &str, content: &str, selector: &str) -> eyre::Result<String> {
|
|
||||||
Ok(lol_html::rewrite_str(
|
|
||||||
page,
|
|
||||||
RewriteStrSettings {
|
|
||||||
element_content_handlers: vec![element!(selector, move |el| {
|
|
||||||
el.append(content, lol_html::html_content::ContentType::Html);
|
|
||||||
Ok(())
|
|
||||||
})],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extra to add a sidebar to the index page with recent blog posts on it.
|
|
||||||
fn resource_list_outside(
|
|
||||||
page: String,
|
|
||||||
builder: &SiteBuilder,
|
|
||||||
data: &ExtraData,
|
|
||||||
) -> eyre::Result<String> {
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ResourceListData {
|
|
||||||
template: String,
|
|
||||||
resource: String,
|
|
||||||
count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ResourceListTemplateData<'r> {
|
|
||||||
resources: Vec<ResourceTemplateData<'r>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: ResourceListData = serde_yml::from_value(data.inner.clone())?;
|
|
||||||
|
|
||||||
let resource_list = builder.tera.render(
|
|
||||||
&data.template,
|
|
||||||
&tera::Context::from_serialize(ResourceListTemplateData {
|
|
||||||
resources: builder
|
|
||||||
.resource_builders
|
|
||||||
.get(&data.resource)
|
|
||||||
.ok_or_else(|| eyre::eyre!("missing resource builder: {}", data.resource))?
|
|
||||||
.loaded_metadata
|
|
||||||
.iter()
|
|
||||||
.take(data.count)
|
|
||||||
.map(|(id, v)| ResourceTemplateData {
|
|
||||||
resource: v,
|
|
||||||
id: id.clone(),
|
|
||||||
timestamp: v.timestamp,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
append_to(&page, &resource_list, "#content")
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
|
|
||||||
/// Very basic YAML front matter parser.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct FrontMatter<T> {
|
|
||||||
/// The content past the front matter.
|
|
||||||
pub content: String,
|
|
||||||
/// The front matter found, if any.
|
|
||||||
pub data: Option<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> FrontMatter<T>
|
|
||||||
where
|
|
||||||
T: DeserializeOwned,
|
|
||||||
{
|
|
||||||
/// Parses the given input for front matter.
|
|
||||||
pub fn parse(input: String) -> eyre::Result<Self> {
|
|
||||||
if input.starts_with("---\n") {
|
|
||||||
if let Some((frontmatter, content)) = input[3..].split_once("---\n") {
|
|
||||||
let data = serde_yml::from_str(frontmatter)?;
|
|
||||||
return Ok(Self {
|
|
||||||
content: content.to_string(),
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
content: input,
|
|
||||||
data: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
174
src/lib.rs
174
src/lib.rs
|
@ -1,174 +0,0 @@
|
||||||
mod builder;
|
|
||||||
mod extras;
|
|
||||||
mod frontmatter;
|
|
||||||
mod link_list;
|
|
||||||
mod resource;
|
|
||||||
#[cfg(feature = "serve")]
|
|
||||||
pub mod serving;
|
|
||||||
mod util;
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
use extras::ExtraData;
|
|
||||||
use eyre::Context;
|
|
||||||
use rayon::prelude::*;
|
|
||||||
use resource::{EmbedMetadata, ResourceBuilderConfig};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use url::Url;
|
|
||||||
use util::get_name;
|
|
||||||
use walkdir::WalkDir;
|
|
||||||
|
|
||||||
use builder::SiteBuilder;
|
|
||||||
|
|
||||||
const PAGES_PATH: &str = "pages";
|
|
||||||
const TEMPLATES_PATH: &str = "templates";
|
|
||||||
const SASS_PATH: &str = "sass";
|
|
||||||
const ROOT_PATH: &str = "root";
|
|
||||||
|
|
||||||
/// Struct for the site's configuration.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SiteConfig {
|
|
||||||
/// The location the site is at.
|
|
||||||
pub base_url: Url,
|
|
||||||
/// The site's title.
|
|
||||||
pub title: String,
|
|
||||||
/// The site's description? Not sure if this will actually be used or not
|
|
||||||
pub description: String,
|
|
||||||
/// The site's build directory. Defaults to <site>/build if not specified.
|
|
||||||
pub build: Option<String>,
|
|
||||||
/// A list of Sass stylesheets that will be built.
|
|
||||||
pub sass_styles: Vec<PathBuf>,
|
|
||||||
/// URL to the CDN used for the site's images.
|
|
||||||
pub cdn_url: Url,
|
|
||||||
/// List of resources the site should build.
|
|
||||||
pub resources: HashMap<String, ResourceBuilderConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SiteConfig {
|
|
||||||
/// Gets a CDN url from the given file name.
|
|
||||||
pub fn cdn_url(&self, file: &str) -> eyre::Result<Url> {
|
|
||||||
Ok(self.cdn_url.join(file)?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Struct for the front matter in templates. (nothing here yet)
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub struct TemplateMetadata {}
|
|
||||||
|
|
||||||
/// Struct for the front matter in pages.
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub struct PageMetadata {
|
|
||||||
/// The page's title.
|
|
||||||
pub title: Option<String>,
|
|
||||||
/// The template to use for the page. If not specified, it defaults to "base".
|
|
||||||
pub template: Option<String>,
|
|
||||||
/// custom embed info for a template
|
|
||||||
#[serde(default)]
|
|
||||||
pub embed: Option<EmbedMetadata>,
|
|
||||||
/// The page's custom scripts, if any.
|
|
||||||
#[serde(default)]
|
|
||||||
pub scripts: Vec<String>,
|
|
||||||
/// the page's custom styles, if any.
|
|
||||||
#[serde(default)]
|
|
||||||
pub styles: Vec<String>,
|
|
||||||
/// The extra stuff to run for the page, if any.
|
|
||||||
#[serde(default)]
|
|
||||||
pub extra: Option<ExtraData>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Struct containing information about the site.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Site {
|
|
||||||
/// The path to the site.
|
|
||||||
pub site_path: PathBuf,
|
|
||||||
/// The site's configuration.
|
|
||||||
pub config: SiteConfig,
|
|
||||||
/// An index of available templates.
|
|
||||||
pub template_index: HashMap<String, PathBuf>,
|
|
||||||
/// An index of available pages.
|
|
||||||
pub page_index: HashMap<String, PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Site {
|
|
||||||
/// Creates a new site from the given path.
|
|
||||||
pub fn new(site_path: &Path) -> eyre::Result<Self> {
|
|
||||||
let config: SiteConfig = serde_yml::from_str(
|
|
||||||
&std::fs::read_to_string(site_path.join("config.yaml"))
|
|
||||||
.wrap_err("Failed to read site config")?,
|
|
||||||
)
|
|
||||||
.wrap_err("Failed to parse site config")?;
|
|
||||||
|
|
||||||
let mut template_index = HashMap::new();
|
|
||||||
let templates_path = site_path.join(TEMPLATES_PATH);
|
|
||||||
for entry in WalkDir::new(&templates_path).into_iter() {
|
|
||||||
let entry = entry.wrap_err("Failed to read template entry")?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if let Some(ext) = path.extension() {
|
|
||||||
if ext == "hbs" && entry.file_type().is_file() {
|
|
||||||
let (_, name) = get_name(
|
|
||||||
path.strip_prefix(&templates_path)
|
|
||||||
.wrap_err("This really shouldn't have happened")?,
|
|
||||||
);
|
|
||||||
template_index.insert(name, path.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut page_index = HashMap::new();
|
|
||||||
let pages_path = site_path.join(PAGES_PATH);
|
|
||||||
for entry in WalkDir::new(&pages_path).into_iter() {
|
|
||||||
let entry = entry.wrap_err("Failed to read page entry")?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if let Some(ext) = path.extension() {
|
|
||||||
if ext == "md" && entry.file_type().is_file() {
|
|
||||||
page_index.insert(
|
|
||||||
path.strip_prefix(&pages_path)
|
|
||||||
.wrap_err("This really shouldn't have happened")?
|
|
||||||
.with_extension("")
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string(),
|
|
||||||
path.to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
site_path: site_path.to_owned(),
|
|
||||||
config,
|
|
||||||
template_index,
|
|
||||||
page_index,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the site once.
|
|
||||||
pub fn build_once(self) -> eyre::Result<()> {
|
|
||||||
let builder = SiteBuilder::new(self, false).prepare()?;
|
|
||||||
|
|
||||||
builder.site.build_all_pages(&builder)?;
|
|
||||||
builder.build_sass()?;
|
|
||||||
|
|
||||||
for (_source_path, config) in builder.site.config.resources.iter() {
|
|
||||||
let mut res_builder = resource::ResourceBuilder::new(config.clone());
|
|
||||||
res_builder.load_all(&builder)?;
|
|
||||||
res_builder.build_all(&builder)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper method to build all available pages.
|
|
||||||
fn build_all_pages(&self, builder: &SiteBuilder) -> eyre::Result<()> {
|
|
||||||
self.page_index
|
|
||||||
.keys()
|
|
||||||
.par_bridge()
|
|
||||||
.try_for_each(|page_name| builder.build_page(page_name))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
use std::borrow::Cow;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::{builder::SiteBuilder, PageMetadata};
|
|
||||||
|
|
||||||
/// Helper for links.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct Link<'l> {
|
|
||||||
/// The link's actual link.
|
|
||||||
pub link: Cow<'l, str>,
|
|
||||||
/// The link's title.
|
|
||||||
pub title: Cow<'l, str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'l> Link<'l> {
|
|
||||||
/// Creates a new link.
|
|
||||||
pub fn new(link: impl Into<Cow<'l, str>>, title: impl Into<Cow<'l, str>>) -> Self {
|
|
||||||
let link = link.into();
|
|
||||||
let title = title.into();
|
|
||||||
Self { link, title }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders a basic list of links.
|
|
||||||
pub fn render_basic_link_list(
|
|
||||||
builder: &SiteBuilder,
|
|
||||||
template: &str,
|
|
||||||
links: Vec<Link>,
|
|
||||||
title: &str,
|
|
||||||
) -> eyre::Result<String> {
|
|
||||||
let data = LinkTemplateData { links, title };
|
|
||||||
let out = builder
|
|
||||||
.tera
|
|
||||||
.render(template, &tera::Context::from_serialize(data)?)?;
|
|
||||||
let out = builder.build_page_raw(
|
|
||||||
PageMetadata {
|
|
||||||
title: Some(title.to_owned()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
&out,
|
|
||||||
)?;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Template data for a list of links.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct LinkTemplateData<'l> {
|
|
||||||
/// The actual links.
|
|
||||||
links: Vec<Link<'l>>,
|
|
||||||
/// The title for the page.
|
|
||||||
title: &'l str,
|
|
||||||
}
|
|
64
src/main.rs
64
src/main.rs
|
@ -1,64 +0,0 @@
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use zyl_site::Site;
|
|
||||||
|
|
||||||
#[cfg(feature = "serve")]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
enum Mode {
|
|
||||||
Build,
|
|
||||||
Serve,
|
|
||||||
Now,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "serve")]
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> eyre::Result<()> {
|
|
||||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
|
||||||
|
|
||||||
#[cfg(feature = "color-eyre")]
|
|
||||||
color_eyre::install()?;
|
|
||||||
|
|
||||||
let site = Site::new(&Path::new("site").canonicalize()?)?;
|
|
||||||
|
|
||||||
let mut mode = Mode::Build;
|
|
||||||
for arg in std::env::args() {
|
|
||||||
if arg == "serve" {
|
|
||||||
mode = Mode::Serve;
|
|
||||||
break;
|
|
||||||
} else if arg == "now" {
|
|
||||||
mode = Mode::Now;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
Mode::Build => {
|
|
||||||
build(site)?;
|
|
||||||
}
|
|
||||||
Mode::Serve => site.serve().await?,
|
|
||||||
Mode::Now => {
|
|
||||||
let time = OffsetDateTime::now_utc();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
time.format(&Rfc3339)
|
|
||||||
.expect("failed to format the current time")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "serve"))]
|
|
||||||
fn main() -> eyre::Result<()> {
|
|
||||||
let site = Site::new(&Path::new("site").canonicalize()?)?;
|
|
||||||
build(site)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build(site: Site) -> eyre::Result<()> {
|
|
||||||
println!("Building site...");
|
|
||||||
let now = std::time::Instant::now();
|
|
||||||
site.build_once()?;
|
|
||||||
println!("Build completed in {:?}", now.elapsed());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
console.log("Connecting...");
|
|
||||||
|
|
||||||
let socket;
|
|
||||||
|
|
||||||
function start(reload) {
|
|
||||||
socket = new WebSocket("ws://127.0.0.1:8080");
|
|
||||||
let reloading = false;
|
|
||||||
socket.onmessage = function (ev) {
|
|
||||||
if (ev.data === "reload") {
|
|
||||||
reloading = true;
|
|
||||||
console.log("Reloading...");
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.onclose = function () {
|
|
||||||
if (!reloading) {
|
|
||||||
console.error("Connection closed.");
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log("Retrying connection...");
|
|
||||||
start(true);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.onopen = function () {
|
|
||||||
console.log("Connected!");
|
|
||||||
if (reload) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
start(false);
|
|
||||||
})();
|
|
477
src/resource.rs
477
src/resource.rs
|
@ -1,477 +0,0 @@
|
||||||
use std::{
|
|
||||||
collections::BTreeMap,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
use eyre::Context;
|
|
||||||
use itertools::Itertools;
|
|
||||||
use pulldown_cmark::{Options, Parser};
|
|
||||||
use rss::{validation::Validate, ChannelBuilder, ItemBuilder};
|
|
||||||
use serde::{Deserialize, Serialize, Serializer};
|
|
||||||
use time::{format_description::well_known::Rfc2822, OffsetDateTime};
|
|
||||||
|
|
||||||
use crate::{builder::SiteBuilder, frontmatter::FrontMatter, link_list::Link, PageMetadata};
|
|
||||||
|
|
||||||
/// Source base path for resources.
|
|
||||||
pub const RESOURCES_PATH: &str = "resources";
|
|
||||||
|
|
||||||
/// Metadata for resources.
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
pub struct ResourceMetadata {
|
|
||||||
/// The resource's title.
|
|
||||||
pub title: String,
|
|
||||||
/// The resource's timestamp.
|
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
|
||||||
pub timestamp: OffsetDateTime,
|
|
||||||
/// The resource's tags.
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
/// Special field that gets transformed to the full CDN URL for the given path.
|
|
||||||
pub cdn_file: Option<String>,
|
|
||||||
/// The resource's description, if any.
|
|
||||||
pub desc: Option<String>,
|
|
||||||
/// Extra resource data not included.
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub inner: serde_yml::Value,
|
|
||||||
/// Whether the resource is a draft. Drafts can be committed without being published to the live site.
|
|
||||||
#[serde(default)]
|
|
||||||
pub draft: bool,
|
|
||||||
/// The resource's content. Defaults to nothing until loaded in another step.
|
|
||||||
#[serde(default)]
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ResourceTemplateData<'r> {
|
|
||||||
/// The resource's metadata.
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub resource: &'r ResourceMetadata,
|
|
||||||
/// The resource's ID.
|
|
||||||
pub id: String,
|
|
||||||
/// The resource's timestamp. Duplicated to change serialization method.
|
|
||||||
#[serde(serialize_with = "ResourceTemplateData::timestamp_formatter")]
|
|
||||||
pub timestamp: OffsetDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'r> ResourceTemplateData<'r> {
|
|
||||||
fn timestamp_formatter<S>(timestamp: &OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
let out = timestamp
|
|
||||||
.format(
|
|
||||||
&time::format_description::parse("[weekday], [month repr:long] [day], [year]")
|
|
||||||
.expect("Should never fail"),
|
|
||||||
)
|
|
||||||
.expect("Should never fail");
|
|
||||||
serializer.serialize_str(&out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// struct for adding custom meta content embeds
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct EmbedMetadata {
|
|
||||||
pub title: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub site_name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub image: Option<String>,
|
|
||||||
#[serde(default = "EmbedMetadata::default_theme_color")]
|
|
||||||
pub theme_color: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub large_image: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EmbedMetadata {
|
|
||||||
/// builds the embed html tags
|
|
||||||
pub fn build(self) -> String {
|
|
||||||
let mut s = format!(r#"<meta content="{}" property="og:title">"#, self.title);
|
|
||||||
s = format!(
|
|
||||||
r#"{s}<meta content="{}" property="og:site_name">"#,
|
|
||||||
self.site_name
|
|
||||||
);
|
|
||||||
if let Some(description) = self.description {
|
|
||||||
s = format!(r#"{s}<meta content="{description}" property="og:description">"#);
|
|
||||||
}
|
|
||||||
if let Some(url) = self.url {
|
|
||||||
s = format!(r#"{s}<meta content="{url}" property="og:url">"#);
|
|
||||||
}
|
|
||||||
if let Some(image) = self.image {
|
|
||||||
s = format!(r#"{s}<meta content="{image}" property="og:image">"#);
|
|
||||||
}
|
|
||||||
s = format!(
|
|
||||||
r#"{s}<meta content="{}" name="theme-color">"#,
|
|
||||||
self.theme_color
|
|
||||||
);
|
|
||||||
if self.large_image {
|
|
||||||
s = format!(r#"{s}<meta name="twitter:card" content="summary_large_image">"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_theme_color() -> String {
|
|
||||||
"#ffc4fc".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ResourceListTemplateData<'r> {
|
|
||||||
resources: Vec<&'r ResourceTemplateData<'r>>,
|
|
||||||
tag: Option<&'r str>,
|
|
||||||
page: usize,
|
|
||||||
page_max: usize,
|
|
||||||
previous: Option<usize>,
|
|
||||||
next: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct ExtraResourceRenderData {
|
|
||||||
head: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Config for the resource builder.
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
pub struct ResourceBuilderConfig {
|
|
||||||
/// Path to where the resources should be loaded from.
|
|
||||||
pub source_path: String,
|
|
||||||
/// Path to where the resource pages should be written to.
|
|
||||||
pub output_path_short: String,
|
|
||||||
/// Path to where the main list should be written to.
|
|
||||||
pub output_path_long: String,
|
|
||||||
/// The template used to render a single resource.
|
|
||||||
pub resource_template: String,
|
|
||||||
/// The template used to render a list of resources.
|
|
||||||
pub resource_list_template: String,
|
|
||||||
/// The template used to render the resource's tag pages.
|
|
||||||
pub tag_list_template: String,
|
|
||||||
/// Template used when rendering the RSS feed.
|
|
||||||
pub rss_template: String,
|
|
||||||
/// The RSS feed's title.
|
|
||||||
pub rss_title: String,
|
|
||||||
/// The description for the RSS feed.
|
|
||||||
pub rss_description: String,
|
|
||||||
/// Title for the main list of resources.
|
|
||||||
pub list_title: String,
|
|
||||||
/// Title for the page containing a list of tags.
|
|
||||||
pub tag_list_title: String,
|
|
||||||
/// Name for the resource type in plural.
|
|
||||||
pub resource_name_plural: String,
|
|
||||||
/// The number of resources to display on a single page.
|
|
||||||
pub resources_per_page: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to genericize resource building.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct ResourceBuilder {
|
|
||||||
/// The builder's config.
|
|
||||||
pub config: ResourceBuilderConfig,
|
|
||||||
/// The currently loaded resource metadata.
|
|
||||||
pub loaded_metadata: Vec<(String, ResourceMetadata)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResourceBuilder {
|
|
||||||
/// Creates a new resource builder.
|
|
||||||
pub fn new(config: ResourceBuilderConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
loaded_metadata: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets a resource's ID from its path.
|
|
||||||
fn get_id(path: &Path) -> String {
|
|
||||||
path.with_extension("")
|
|
||||||
.file_name()
|
|
||||||
.expect("Should never fail")
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads resource metadata from the given path.
|
|
||||||
fn load(builder: &SiteBuilder, path: &Path) -> eyre::Result<(String, ResourceMetadata)> {
|
|
||||||
let id = Self::get_id(path);
|
|
||||||
|
|
||||||
let input = std::fs::read_to_string(path)?;
|
|
||||||
let page = FrontMatter::<ResourceMetadata>::parse(input)
|
|
||||||
.wrap_err_with(|| eyre::eyre!("Failed to parse resource front matter"))?;
|
|
||||||
|
|
||||||
let parser = Parser::new_ext(&page.content, Options::all());
|
|
||||||
let mut html = String::new();
|
|
||||||
pulldown_cmark::html::push_html(&mut html, parser);
|
|
||||||
|
|
||||||
let mut data = page
|
|
||||||
.data
|
|
||||||
.ok_or_else(|| eyre::eyre!("missing front matter for file at {path:?}"))?;
|
|
||||||
data.content = html;
|
|
||||||
if let Some(cdn_file) = data.cdn_file {
|
|
||||||
data.cdn_file = Some(builder.site.config.cdn_url(&cdn_file)?.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((id, data))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all resource metadata from the given config.
|
|
||||||
pub fn load_all(&mut self, builder: &SiteBuilder) -> eyre::Result<()> {
|
|
||||||
let lmd = &mut self.loaded_metadata;
|
|
||||||
lmd.clear();
|
|
||||||
for e in builder
|
|
||||||
.site
|
|
||||||
.site_path
|
|
||||||
.join(RESOURCES_PATH)
|
|
||||||
.join(&self.config.source_path)
|
|
||||||
.read_dir()?
|
|
||||||
{
|
|
||||||
let p = e?.path();
|
|
||||||
if let Some("md") = p.extension().and_then(|e| e.to_str()) {
|
|
||||||
let (id, metadata) = Self::load(builder, &p)?;
|
|
||||||
if cfg!(not(debug_assertions)) && metadata.draft {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lmd.push((id, metadata));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lmd.sort_by(|a, b| b.1.timestamp.cmp(&a.1.timestamp));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets a resource's build path.
|
|
||||||
fn build_path(&self, base_path: &Path, id: &str) -> PathBuf {
|
|
||||||
base_path
|
|
||||||
.join(&self.config.output_path_short)
|
|
||||||
.join(id)
|
|
||||||
.with_extension("html")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a single resource page.
|
|
||||||
fn build(
|
|
||||||
&self,
|
|
||||||
builder: &SiteBuilder,
|
|
||||||
id: String,
|
|
||||||
resource: &ResourceMetadata,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
let out_path = self.build_path(&builder.build_path, &id);
|
|
||||||
let out = {
|
|
||||||
let data = ResourceTemplateData {
|
|
||||||
resource,
|
|
||||||
id,
|
|
||||||
timestamp: resource.timestamp,
|
|
||||||
};
|
|
||||||
builder.tera.render(
|
|
||||||
&self.config.resource_template,
|
|
||||||
&tera::Context::from_serialize(data)?,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
|
|
||||||
let out = builder.build_page_raw_with_extra_data(
|
|
||||||
PageMetadata {
|
|
||||||
title: Some(resource.title.clone()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
&out,
|
|
||||||
ExtraResourceRenderData {
|
|
||||||
head: EmbedMetadata {
|
|
||||||
title: resource.title.clone(),
|
|
||||||
site_name: builder.site.config.title.clone(),
|
|
||||||
description: resource.desc.clone(),
|
|
||||||
image: if let Some(cdn_file) = &resource.cdn_file {
|
|
||||||
Some(builder.site.config.cdn_url(cdn_file)?.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
url: None,
|
|
||||||
theme_color: EmbedMetadata::default_theme_color(),
|
|
||||||
large_image: true,
|
|
||||||
}
|
|
||||||
.build(),
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
std::fs::write(out_path, out)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_all(&self, builder: &SiteBuilder) -> eyre::Result<()> {
|
|
||||||
let out_short = builder.build_path.join(&self.config.output_path_short);
|
|
||||||
let out_long = builder.build_path.join(&self.config.output_path_long);
|
|
||||||
|
|
||||||
if !out_short.exists() {
|
|
||||||
std::fs::create_dir_all(&out_short)?;
|
|
||||||
}
|
|
||||||
if !out_long.exists() {
|
|
||||||
std::fs::create_dir_all(&out_long)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lmd = &self.loaded_metadata;
|
|
||||||
|
|
||||||
for (id, resource) in lmd.iter() {
|
|
||||||
self.build(builder, id.clone(), resource)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut data = Vec::with_capacity(lmd.len());
|
|
||||||
for (id, resource) in lmd.iter() {
|
|
||||||
data.push(ResourceTemplateData {
|
|
||||||
resource,
|
|
||||||
id: id.clone(),
|
|
||||||
timestamp: resource.timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_list(
|
|
||||||
builder: &SiteBuilder,
|
|
||||||
config: &ResourceBuilderConfig,
|
|
||||||
list: Vec<&ResourceTemplateData>,
|
|
||||||
title: &str,
|
|
||||||
tag: Option<&str>,
|
|
||||||
out_path: &Path,
|
|
||||||
items_per_page: usize,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
if !out_path.exists() {
|
|
||||||
std::fs::create_dir_all(out_path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let page_max = list.len() / items_per_page + (list.len() % items_per_page).min(1);
|
|
||||||
let mut previous = None;
|
|
||||||
let mut next;
|
|
||||||
for (page, iter) in list.iter().chunks(items_per_page).into_iter().enumerate() {
|
|
||||||
next = (page + 1 != page_max).then_some(page + 2);
|
|
||||||
let data = ResourceListTemplateData {
|
|
||||||
resources: iter.copied().collect(),
|
|
||||||
tag,
|
|
||||||
page: page + 1,
|
|
||||||
page_max,
|
|
||||||
previous,
|
|
||||||
next,
|
|
||||||
};
|
|
||||||
let out = builder.tera.render(
|
|
||||||
&config.resource_list_template,
|
|
||||||
&tera::Context::from_serialize(data)?,
|
|
||||||
)?;
|
|
||||||
let out = builder.build_page_raw(
|
|
||||||
PageMetadata {
|
|
||||||
title: Some(title.to_owned()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
&out,
|
|
||||||
)?;
|
|
||||||
if page == 0 {
|
|
||||||
std::fs::write(out_path.join("index.html"), &out)?;
|
|
||||||
}
|
|
||||||
std::fs::write(
|
|
||||||
out_path.join((page + 1).to_string()).with_extension("html"),
|
|
||||||
out,
|
|
||||||
)?;
|
|
||||||
previous = Some(page + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build main list of resources
|
|
||||||
build_list(
|
|
||||||
builder,
|
|
||||||
&self.config,
|
|
||||||
data.iter().collect(),
|
|
||||||
&self.config.list_title,
|
|
||||||
None,
|
|
||||||
&out_long,
|
|
||||||
self.config.resources_per_page,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Build resource lists by tag
|
|
||||||
let mut tags: BTreeMap<String, Vec<&ResourceTemplateData>> = BTreeMap::new();
|
|
||||||
for resource in &data {
|
|
||||||
for tag in resource.resource.tags.iter().cloned() {
|
|
||||||
tags.entry(tag).or_default().push(resource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build list of tags
|
|
||||||
{
|
|
||||||
let links = tags
|
|
||||||
.iter()
|
|
||||||
.map(|(tag, data)| {
|
|
||||||
let count = data.len();
|
|
||||||
(
|
|
||||||
Link::new(
|
|
||||||
format!("/{}/tag/{tag}/", self.config.output_path_short),
|
|
||||||
format!("{tag} ({count})"),
|
|
||||||
),
|
|
||||||
count,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.sorted_by(|(_, a), (_, b)| b.cmp(a))
|
|
||||||
.map(|(l, _)| l)
|
|
||||||
.collect();
|
|
||||||
let out = crate::link_list::render_basic_link_list(
|
|
||||||
builder,
|
|
||||||
&self.config.tag_list_template,
|
|
||||||
links,
|
|
||||||
&self.config.tag_list_title,
|
|
||||||
)?;
|
|
||||||
std::fs::write(out_short.join("tags.html"), out)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (tag, data) in tags {
|
|
||||||
build_list(
|
|
||||||
builder,
|
|
||||||
&self.config,
|
|
||||||
data,
|
|
||||||
&format!("{} tagged {tag}", self.config.resource_name_plural),
|
|
||||||
Some(tag.as_str()),
|
|
||||||
&out_short.join("tag").join(&tag),
|
|
||||||
self.config.resources_per_page,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build RSS feed
|
|
||||||
let mut items = Vec::with_capacity(data.len());
|
|
||||||
for resource in data {
|
|
||||||
items.push(
|
|
||||||
ItemBuilder::default()
|
|
||||||
.title(Some(resource.resource.title.to_owned()))
|
|
||||||
.link(Some(
|
|
||||||
builder
|
|
||||||
.site
|
|
||||||
.config
|
|
||||||
.base_url
|
|
||||||
.join(&format!(
|
|
||||||
"{}/{}",
|
|
||||||
self.config.output_path_short, resource.id
|
|
||||||
))?
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.description(resource.resource.desc.clone())
|
|
||||||
.pub_date(Some(resource.timestamp.format(&Rfc2822)?))
|
|
||||||
.content(Some(builder.tera.render(
|
|
||||||
&self.config.rss_template,
|
|
||||||
&tera::Context::from_serialize(resource)?,
|
|
||||||
)?))
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let channel = ChannelBuilder::default()
|
|
||||||
.title(self.config.rss_title.clone())
|
|
||||||
.link(
|
|
||||||
builder
|
|
||||||
.site
|
|
||||||
.config
|
|
||||||
.base_url
|
|
||||||
.join(&format!("{}/", self.config.output_path_long))
|
|
||||||
.expect("Should never fail"),
|
|
||||||
)
|
|
||||||
.description(self.config.rss_description.clone())
|
|
||||||
.last_build_date(Some(OffsetDateTime::now_utc().format(&Rfc2822)?))
|
|
||||||
.items(items)
|
|
||||||
.build();
|
|
||||||
channel.validate().wrap_err("Failed to validate RSS feed")?;
|
|
||||||
let out = channel.to_string();
|
|
||||||
std::fs::write(out_long.join("rss.xml"), out)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
325
src/serving.rs
325
src/serving.rs
|
@ -1,325 +0,0 @@
|
||||||
//! Module containing code to serve the dev site.
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
net::SocketAddr,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use eyre::Context;
|
|
||||||
use futures::SinkExt;
|
|
||||||
use hotwatch::{EventKind, Hotwatch};
|
|
||||||
use warp::{
|
|
||||||
hyper::StatusCode,
|
|
||||||
path::FullPath,
|
|
||||||
reply::Response,
|
|
||||||
ws::{Message, WebSocket},
|
|
||||||
Filter,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
resource::RESOURCES_PATH, util::get_name, Site, SiteBuilder, PAGES_PATH, ROOT_PATH, SASS_PATH,
|
|
||||||
TEMPLATES_PATH,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn with_build_path(
|
|
||||||
build_path: PathBuf,
|
|
||||||
) -> impl Filter<Extract = (PathBuf,), Error = std::convert::Infallible> + Clone {
|
|
||||||
warp::any().map(move || build_path.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to make a path relative.
|
|
||||||
fn rel(path: &Path, prefix: &Path) -> Result<PathBuf, std::path::StripPrefixError> {
|
|
||||||
Ok(path.strip_prefix(prefix)?.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to build resources in the case of creation or removal.
|
|
||||||
fn build_resources(builder: &mut SiteBuilder, path: &Path) -> eyre::Result<()> {
|
|
||||||
let paths: Vec<_> = builder
|
|
||||||
.resource_builders
|
|
||||||
.values()
|
|
||||||
.map(|b| {
|
|
||||||
(
|
|
||||||
b.config.source_path.clone(),
|
|
||||||
path.strip_prefix(&b.config.source_path),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.filter_map(|(p, v)| v.ok().map(|v| (p, v)))
|
|
||||||
.collect();
|
|
||||||
if paths.len() > 1 {
|
|
||||||
todo!("handle more than one possible match");
|
|
||||||
}
|
|
||||||
if let Some((prefix, _path)) = paths.first() {
|
|
||||||
// HACK: this could get very inefficient with a larger number of resources. should definitely optimize
|
|
||||||
builder.reload_resource_builder(prefix)?;
|
|
||||||
builder.build_resources(prefix)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates or updates a resource.
|
|
||||||
fn create(
|
|
||||||
builder: &mut SiteBuilder,
|
|
||||||
path: &Path,
|
|
||||||
relative_path: &Path,
|
|
||||||
build: bool,
|
|
||||||
) -> eyre::Result<()> {
|
|
||||||
if path.is_dir() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
println!("{relative_path:?}");
|
|
||||||
if let Ok(page_path) = relative_path.strip_prefix(PAGES_PATH) {
|
|
||||||
let (_page_name, page_name_str) = get_name(page_path);
|
|
||||||
|
|
||||||
builder
|
|
||||||
.site
|
|
||||||
.page_index
|
|
||||||
.insert(page_name_str.clone(), path.to_owned());
|
|
||||||
if build {
|
|
||||||
builder.build_page(&page_name_str)?;
|
|
||||||
}
|
|
||||||
} else if let Ok(_template_path) = relative_path.strip_prefix(TEMPLATES_PATH) {
|
|
||||||
builder.tera.full_reload()?;
|
|
||||||
if build {
|
|
||||||
builder.site.build_all_pages(builder)?;
|
|
||||||
builder.build_all_resources()?;
|
|
||||||
}
|
|
||||||
} else if relative_path.display().to_string() == "config.yaml" {
|
|
||||||
let new_config = serde_yml::from_str(&std::fs::read_to_string(path)?)?;
|
|
||||||
builder.site.config = new_config;
|
|
||||||
builder.reload()?;
|
|
||||||
builder.site.build_all_pages(builder)?;
|
|
||||||
} else if let Ok(_sass_path) = relative_path.strip_prefix(SASS_PATH) {
|
|
||||||
if build {
|
|
||||||
builder.build_sass().wrap_err("Failed to rebuild Sass")?;
|
|
||||||
}
|
|
||||||
} else if let Ok(root_path) = relative_path.strip_prefix(ROOT_PATH) {
|
|
||||||
std::fs::copy(path, builder.build_path.join(root_path))?;
|
|
||||||
} else if let Ok(resources_path) = relative_path.strip_prefix(RESOURCES_PATH) {
|
|
||||||
build_resources(builder, resources_path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes an existing resource.
|
|
||||||
fn remove(builder: &mut SiteBuilder, path: &Path, relative_path: &Path) -> eyre::Result<()> {
|
|
||||||
if path.is_dir() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Ok(page_path) = relative_path.strip_prefix(PAGES_PATH) {
|
|
||||||
let (page_name, page_name_str) = get_name(page_path);
|
|
||||||
|
|
||||||
builder.site.page_index.remove(&page_name_str);
|
|
||||||
std::fs::remove_file(builder.build_path.join(page_name.with_extension("html")))
|
|
||||||
.with_context(|| format!("Failed to remove page at {:?}", path))?;
|
|
||||||
} else if let Ok(template_path) = relative_path.strip_prefix(TEMPLATES_PATH) {
|
|
||||||
let (_template_name, template_name_str) = get_name(template_path);
|
|
||||||
builder.site.template_index.remove(&template_name_str);
|
|
||||||
builder.tera.full_reload()?;
|
|
||||||
builder
|
|
||||||
.site
|
|
||||||
.build_all_pages(builder)
|
|
||||||
.wrap_err("Failed to rebuild pages")?;
|
|
||||||
} else if let Ok(_sass_path) = relative_path.strip_prefix(SASS_PATH) {
|
|
||||||
builder.build_sass().wrap_err("Failed to rebuild Sass")?;
|
|
||||||
} else if let Ok(root_path) = relative_path.strip_prefix(ROOT_PATH) {
|
|
||||||
std::fs::remove_file(builder.build_path.join(root_path))?;
|
|
||||||
} else if let Ok(resources_path) = relative_path.strip_prefix(RESOURCES_PATH) {
|
|
||||||
build_resources(builder, resources_path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decides whether to skip a path in the watcher.
|
|
||||||
fn skip_path(builder: &SiteBuilder, path: &Path) -> bool {
|
|
||||||
path.strip_prefix(&builder.build_path).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Site {
|
|
||||||
/// Serves the site for development. Don't use this in production.
|
|
||||||
pub async fn serve(self) -> eyre::Result<()> {
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
|
|
||||||
|
|
||||||
let mut builder = SiteBuilder::new(self, true).prepare()?;
|
|
||||||
let site = &builder.site;
|
|
||||||
let build_path = builder.build_path.clone();
|
|
||||||
|
|
||||||
// Perform initial build
|
|
||||||
for page_name in site.page_index.keys() {
|
|
||||||
if let Err(e) = builder.build_page(page_name) {
|
|
||||||
eprintln!("Failed to build page {}: {}", page_name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder.build_sass().wrap_err("Failed to build Sass")?;
|
|
||||||
builder
|
|
||||||
.build_all_resources()
|
|
||||||
.wrap_err("Failed to build resources")?;
|
|
||||||
|
|
||||||
// Map of websocket connections
|
|
||||||
let peers: Arc<Mutex<HashMap<SocketAddr, WebSocket>>> =
|
|
||||||
Arc::new(Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
// Watch for changes to the site
|
|
||||||
let mut hotwatch = Hotwatch::new().expect("Hotwatch failed to initialize");
|
|
||||||
let hw_peers = peers.clone();
|
|
||||||
hotwatch
|
|
||||||
.watch(site.site_path.clone(), move |event| {
|
|
||||||
let peers = hw_peers.clone();
|
|
||||||
|
|
||||||
let r = (|| {
|
|
||||||
let path = event
|
|
||||||
.paths
|
|
||||||
.first()
|
|
||||||
.expect("Should always be at least one path");
|
|
||||||
match event.kind {
|
|
||||||
EventKind::Modify(_) => {
|
|
||||||
if skip_path(&builder, path) {
|
|
||||||
Ok(false)
|
|
||||||
} else {
|
|
||||||
let relp = rel(path, &builder.site.site_path)?;
|
|
||||||
if event.paths.len() > 1 {
|
|
||||||
let new = event.paths.last().expect("Can never fail");
|
|
||||||
let new_rel = rel(new, &builder.site.site_path)?;
|
|
||||||
println!("RENAMED - {:?} -> {:?}", relp, new_rel);
|
|
||||||
create(&mut builder, new, &new_rel, false)?;
|
|
||||||
remove(&mut builder, path, &relp)?;
|
|
||||||
} else {
|
|
||||||
println!("CHANGED - {:?}", relp);
|
|
||||||
create(&mut builder, path, &relp, true)?;
|
|
||||||
}
|
|
||||||
Ok::<_, eyre::Error>(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EventKind::Create(_) => {
|
|
||||||
if skip_path(&builder, path) {
|
|
||||||
Ok(false)
|
|
||||||
} else {
|
|
||||||
let rel = rel(path, &builder.site.site_path)?;
|
|
||||||
println!("CREATED - {:?}", rel);
|
|
||||||
create(&mut builder, path, &rel, true)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EventKind::Remove(_) => {
|
|
||||||
if skip_path(&builder, path) {
|
|
||||||
Ok(false)
|
|
||||||
} else {
|
|
||||||
let rel = rel(path, &builder.site.site_path)?;
|
|
||||||
println!("REMOVED - {:?}", rel);
|
|
||||||
remove(&mut builder, path, &rel)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Ok(false),
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
match r {
|
|
||||||
Ok(reload) => {
|
|
||||||
if reload {
|
|
||||||
// Send reload event to connected websockets
|
|
||||||
let mut peers = peers.lock().unwrap();
|
|
||||||
let mut to_remove = Vec::new();
|
|
||||||
for (addr, peer) in peers.iter_mut() {
|
|
||||||
let task = async {
|
|
||||||
peer.send(Message::text("reload".to_string())).await?;
|
|
||||||
Ok::<_, eyre::Error>(())
|
|
||||||
};
|
|
||||||
to_remove.push(*addr);
|
|
||||||
if let Err(e) = futures::executor::block_on(task) {
|
|
||||||
eprintln!("{}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for addr in &to_remove {
|
|
||||||
peers.remove(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("Failed to update: {}", e),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("Failed to watch file");
|
|
||||||
|
|
||||||
let routes = warp::any()
|
|
||||||
.and(warp::ws())
|
|
||||||
.and(warp::filters::addr::remote())
|
|
||||||
.and_then(move |ws: warp::ws::Ws, addr| {
|
|
||||||
let peers = peers.clone();
|
|
||||||
async move {
|
|
||||||
// Add websocket connection to peers list
|
|
||||||
if let Some(addr) = addr {
|
|
||||||
let peers = peers.clone();
|
|
||||||
return Ok(ws.on_upgrade(move |websocket| async move {
|
|
||||||
peers.lock().unwrap().insert(addr, websocket);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Err(warp::reject())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.or(warp::any().and(warp::get()).and(
|
|
||||||
warp::path::full()
|
|
||||||
.and(with_build_path(build_path.clone()))
|
|
||||||
.and_then(move |path: FullPath, build_path: PathBuf| async move {
|
|
||||||
// Serve static files
|
|
||||||
let p = &path.as_str()[1..];
|
|
||||||
let p = percent_encoding::percent_decode_str(p)
|
|
||||||
.decode_utf8()
|
|
||||||
.expect("Failed to decode URL");
|
|
||||||
|
|
||||||
if p == "_dev.js" {
|
|
||||||
let res = Response::new(include_str!("./refresh_websocket.js").into());
|
|
||||||
return Ok(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut p = build_path.join(p.as_ref());
|
|
||||||
|
|
||||||
if !p.exists() {
|
|
||||||
p = p.with_extension("html");
|
|
||||||
}
|
|
||||||
if p.is_dir() {
|
|
||||||
p = p.join("index.html");
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.exists() {
|
|
||||||
let mut res = Response::new("".into());
|
|
||||||
match std::fs::read(&p) {
|
|
||||||
Ok(body) => {
|
|
||||||
*res.body_mut() = body.into();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("{}", e);
|
|
||||||
*res.body_mut() = format!("Failed to load: {}", e).into();
|
|
||||||
*res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(res);
|
|
||||||
}
|
|
||||||
Err(warp::reject())
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
.or(warp::any()
|
|
||||||
.and(warp::path::full())
|
|
||||||
.and_then(move |path: FullPath| {
|
|
||||||
let build_path = build_path.clone();
|
|
||||||
async move {
|
|
||||||
// Handle missing files
|
|
||||||
println!("404 - {}", path.as_str());
|
|
||||||
let body = match std::fs::read_to_string(build_path.join("404.html")) {
|
|
||||||
Ok(body) => body,
|
|
||||||
_ => "404 Not Found".to_string(),
|
|
||||||
};
|
|
||||||
let mut res = Response::new(body.into());
|
|
||||||
*res.status_mut() = StatusCode::NOT_FOUND;
|
|
||||||
Ok::<_, std::convert::Infallible>(res)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
println!("Starting server at http://{}", addr);
|
|
||||||
warp::serve(routes).run(addr).await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
28
src/util.rs
28
src/util.rs
|
@ -1,28 +0,0 @@
|
||||||
//! Module containing various utilities.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// Simple helper to remove the contents of a directory without removing the directory itself.
|
|
||||||
pub fn remove_dir_contents(path: &Path) -> eyre::Result<()> {
|
|
||||||
for entry in path.read_dir()? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_file() {
|
|
||||||
std::fs::remove_file(&path)?;
|
|
||||||
} else {
|
|
||||||
std::fs::remove_dir_all(&path)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to get the "name" of a path.
|
|
||||||
pub fn get_name(path: &Path) -> (PathBuf, String) {
|
|
||||||
let name = path.with_extension("");
|
|
||||||
let name_str = name
|
|
||||||
.display()
|
|
||||||
.to_string()
|
|
||||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
|
||||||
(name, name_str)
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue