introduce webdog cli tool

This commit is contained in:
zyl 2024-11-08 18:16:23 -08:00
parent 8b7620b0bd
commit 399a0c1b87
Signed by: zyl
SSH key fingerprint: SHA256:uxxbSXbdroP/OnKBGnEDk5q7EKB2razvstC/KmzdXXs
26 changed files with 528 additions and 108 deletions

View file

@ -40,7 +40,7 @@ jobs:
# 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 cargo run --no-default-features --release -- build --site-path site
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v5 uses: actions/configure-pages@v5

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/target /target
/site/build /site/build
.DS_Store .DS_Store
/sitetest

40
Cargo.lock generated
View file

@ -280,6 +280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive",
] ]
[[package]] [[package]]
@ -294,6 +295,18 @@ dependencies = [
"strsim", "strsim",
] ]
[[package]]
name = "clap_derive"
version = "4.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.87",
]
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.7.2" version = "0.7.2"
@ -937,6 +950,12 @@ dependencies = [
"http 0.2.12", "http 0.2.12",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.3.9" version = "0.3.9"
@ -1216,6 +1235,25 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "indenter" name = "indenter"
version = "0.3.3" version = "0.3.3"
@ -2838,6 +2876,7 @@ checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
name = "webdog" name = "webdog"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap",
"color-eyre", "color-eyre",
"extract-frontmatter", "extract-frontmatter",
"eyre", "eyre",
@ -2845,6 +2884,7 @@ dependencies = [
"futures", "futures",
"grass", "grass",
"hotwatch", "hotwatch",
"include_dir",
"itertools", "itertools",
"lol_html", "lol_html",
"minifier", "minifier",

View file

@ -4,6 +4,7 @@ name = "webdog"
version = "0.1.0" version = "0.1.0"
[dependencies] [dependencies]
clap = { version = "4", features = ["derive"] }
color-eyre = {version = "0.6", optional = true} color-eyre = {version = "0.6", optional = true}
extract-frontmatter = "4" extract-frontmatter = "4"
eyre = "0.6" eyre = "0.6"
@ -11,6 +12,7 @@ fs_extra = "1.2"
futures = {version = "0.3", optional = true} futures = {version = "0.3", optional = true}
grass = {version = "0.13", default-features = false} grass = {version = "0.13", default-features = false}
hotwatch = {version = "0.5", optional = true} hotwatch = {version = "0.5", optional = true}
include_dir = "0.7"
itertools = "0.13" itertools = "0.13"
lol_html = "2" lol_html = "2"
minifier = {version = "0.3", features = ["html"]} minifier = {version = "0.3", features = ["html"]}

View file

@ -10,10 +10,10 @@ resources:
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/blog.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: webdog blog rss_title: webdog blog
rss_description: feed of recent webdog blog posts. rss_description: feed of recent webdog blog posts.
list_title: blog list_title: blog

View file

@ -46,7 +46,7 @@ pub struct SiteBuilder {
/// The path to the build directory. /// The path to the build directory.
pub build_path: PathBuf, pub build_path: PathBuf,
/// Whether the site is going to be served locally with the dev server. /// Whether the site is going to be served locally with the dev server.
serving: bool, pub serving: bool,
/// The resource builders available to the builder. /// The resource builders available to the builder.
pub resource_builders: HashMap<String, ResourceBuilder>, pub resource_builders: HashMap<String, ResourceBuilder>,
@ -105,7 +105,7 @@ impl SiteBuilder {
std::fs::create_dir(&webdog_path)?; std::fs::create_dir(&webdog_path)?;
std::fs::write( std::fs::write(
webdog_path.join("webdog.js"), webdog_path.join("webdog.js"),
include_str!("./js/webdog.js"), include_str!("./embedded/js/webdog.js"),
)?; )?;
let root_path = self.site.site_path.join(ROOT_PATH); let root_path = self.site.site_path.join(ROOT_PATH);

1
src/embedded/default_site/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,7 @@
---
title: Not Found
---
# 404 Not Found
The page or resource requested does not exist. [Back to home](/)

View file

@ -0,0 +1 @@
hello from webdog! awruff!!

View file

@ -0,0 +1,4 @@
.page {
background-color: #bbb;
padding: 8px;
}

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<link rel="stylesheet" href="/styles/index.css">
<title>{{ title }}</title>
{# header information from webdog #}
{{ head | safe }}
{# include scripts defined in the page frontmatter #}
{% for script in scripts %}
<script type="text/javascript" src="{{script}}" defer></script>
{% endfor %}
{# include styles defined in the page frontmatter #}
{% for style in styles %}
<link rel="stylesheet" href="/styles/{{style}}">
{% endfor %}
</head>
<body>
<h1><a href="/">webdog site</a></h1>
<main class="page">
{% block content %}{{ page | safe }}{% endblock content %}
</main>
</body>
</html>

View file

@ -0,0 +1,9 @@
{% extends "base.tera" %}
{% block content %}
<h1>{{ title }}</h1>
<ul>
{% for link in links %}
<li><a href="{{link.link}}">{{ link.title }}</a></li>
{% endfor %}
</ul>
{% endblock content %}

View file

@ -0,0 +1,23 @@
{% extends "base.tera" %}
{% block content %}
{% if tag %}
<h1>!!RESOURCE_NAME_PLURAL!! tagged {{ tag }}</h1>
<p><a href="/!!RESOURCE_TYPE!!/">View all !!RESOURCE_NAME_PLURAL_LOWERCASE!!</a></p>
{% else %}
<h1>!!RESOURCE_NAME_PLURAL!!</h1>
<p><a href="tags">view !!RESOURCE_NAME!! tags</a></p>
<p><a href="rss.xml">rss feed</a></p>
{% endif %}
<h1>Page {{ page }}/{{ page_max }}</h1>
{% if previous %}
<a href="./{{previous}}">previous page</a>
{% endif %}
{% if next %}
<a href="./{{next}}">next page</a>
{% endif %}
<div>
{% for resource in resources %}
<p><a href="/!!RESOURCE_TYPE!!/{{resource.id}}">{{ resource.title }}</a></p>
{% endfor %}
</div>
{% endblock content %}

View file

@ -0,0 +1,21 @@
{% extends "base.tera" %}
{% block content %}
<div>
<h1>{{ title }}</h1>
<span>published {{ timestamp }}</span>
{% if draft %}
<h2>DRAFT</h2>
{% endif %}
<p>{{ desc }}</p>
<div>
{{ content | safe }}
</div>
<hr />
<h3>tags</h3>
<div>
{% for tag in tags %}
<a href="/!!RESOURCE_TYPE!!/tag/{{tag}}">{{ tag }}</a>{% if not loop.last %},{% endif %}
{% endfor %}
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1 @@
<div>{{ desc | safe }}</div>

View file

@ -104,7 +104,7 @@ fn resource_list_outside(
.map(|(id, v)| ResourceTemplateData { .map(|(id, v)| ResourceTemplateData {
resource: v, resource: v,
id: id.clone(), id: id.clone(),
timestamp: v.timestamp, timestamp: v.data().timestamp,
}) })
.collect(), .collect(),
})?, })?,

View file

@ -1,11 +1,14 @@
use serde::{de::DeserializeOwned, Serialize}; use std::ops::Deref;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
/// Very basic YAML front matter parser. /// Very basic YAML front matter parser.
#[derive(Debug)] #[derive(Debug, Serialize, Deserialize)]
pub struct FrontMatter<T> { pub struct FrontMatter<T> {
/// The content past the front matter. /// The content past the front matter.
pub content: String, pub content: String,
/// The front matter found, if any. /// The front matter found, if any.
#[serde(flatten)]
pub data: Option<T>, pub data: Option<T>,
} }
@ -54,3 +57,46 @@ where
Ok(output) Ok(output)
} }
} }
/// Wrapper around `FrontMatter` to only function when the data is present.
#[derive(Debug, Serialize, Deserialize)]
pub struct FrontMatterRequired<T>(FrontMatter<T>);
impl<T> FrontMatterRequired<T> {
/// Gets a reference to the front matter's data.
pub fn data(&self) -> &T {
self.0.data.as_ref().expect("missing front matter data")
}
/// Gets a mutable reference to the front matter's data.
pub fn data_mut(&mut self) -> &mut T {
self.0.data.as_mut().expect("missing front matter data")
}
/// Gets a mutable reference to the front matter's content.
pub fn content_mut(&mut self) -> &mut String {
&mut self.0.content
}
}
impl<T> FrontMatterRequired<T>
where
T: DeserializeOwned,
{
/// Parses the given input for front matter, failing if missing.
pub fn parse(input: String) -> eyre::Result<Self> {
let fm = FrontMatter::parse(input)?;
if fm.data.is_none() {
eyre::bail!("missing frontmatter!");
}
Ok(Self(fm))
}
}
impl<T> Deref for FrontMatterRequired<T> {
type Target = FrontMatter<T>;
fn deref(&self) -> &Self::Target {
&self.0
}
}

View file

@ -1,8 +1,8 @@
mod builder; mod builder;
mod extras; mod extras;
mod frontmatter; pub mod frontmatter;
mod link_list; mod link_list;
mod resource; pub mod resource;
#[cfg(feature = "serve")] #[cfg(feature = "serve")]
pub mod serving; pub mod serving;
mod util; mod util;
@ -16,19 +16,25 @@ use extras::ExtraData;
use eyre::Context; use eyre::Context;
use rayon::prelude::*; use rayon::prelude::*;
use resource::{EmbedMetadata, ResourceBuilderConfig}; use resource::{EmbedMetadata, ResourceBuilderConfig};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use walkdir::WalkDir; use walkdir::WalkDir;
use builder::SiteBuilder; use builder::SiteBuilder;
const PAGES_PATH: &str = "pages"; /// Source base path for normal site pages.
const TEMPLATES_PATH: &str = "templates"; pub const PAGES_PATH: &str = "pages";
const SASS_PATH: &str = "sass"; /// Source base path for site templates.
const ROOT_PATH: &str = "root"; pub const TEMPLATES_PATH: &str = "templates";
/// Source base path for SASS stylesheets.
pub const SASS_PATH: &str = "sass";
/// Source base path for files which will be copied to the site root.
pub const ROOT_PATH: &str = "root";
/// Source base path for resources.
pub const RESOURCES_PATH: &str = "resources";
/// Struct for the site's configuration. /// Struct for the site's configuration.
#[derive(Debug, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct SiteConfig { pub struct SiteConfig {
/// The location the site is at. /// The location the site is at.
pub base_url: Url, pub base_url: Url,
@ -53,16 +59,18 @@ pub struct SiteConfig {
} }
impl SiteConfig { impl SiteConfig {
/// The filename for site config files.
pub const FILENAME: &str = "config.yaml";
/// Creates a new site config from the given title. /// Creates a new site config from the given title.
pub fn new(title: String) -> Self { pub fn new(base_url: Url, cdn_url: Url, title: String) -> Self {
let url: Url = "/".parse().expect("should never fail");
Self { Self {
base_url: url.clone(), base_url,
title, title,
description: Default::default(), description: Default::default(),
build: None, build: None,
sass_styles: vec!["index.scss".into()], sass_styles: vec!["index.scss".into()],
cdn_url: url, cdn_url,
code_theme: "base16-ocean.dark".to_string(), code_theme: "base16-ocean.dark".to_string(),
resources: Default::default(), resources: Default::default(),
} }
@ -83,6 +91,15 @@ impl SiteConfig {
.ok_or_else(|| eyre::eyre!("missing code theme: {}", self.code_theme))?; .ok_or_else(|| eyre::eyre!("missing code theme: {}", self.code_theme))?;
Ok(()) Ok(())
} }
/// Helper to read the site config from the given path.
pub fn read(site_path: &Path) -> eyre::Result<Self> {
let config_path = site_path.join(SiteConfig::FILENAME);
if !config_path.exists() {
eyre::bail!("no site config found!");
}
Ok(serde_yml::from_str(&std::fs::read_to_string(config_path)?)?)
}
} }
/// Struct for the front matter in templates. (nothing here yet) /// Struct for the front matter in templates. (nothing here yet)
@ -124,11 +141,7 @@ pub struct Site {
impl Site { impl Site {
/// Creates a new site from the given path. /// Creates a new site from the given path.
pub fn new(site_path: &Path) -> eyre::Result<Self> { pub fn new(site_path: &Path) -> eyre::Result<Self> {
let config: SiteConfig = serde_yml::from_str( let config = SiteConfig::read(site_path)?;
&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 page_index = HashMap::new(); let mut page_index = HashMap::new();
let pages_path = site_path.join(PAGES_PATH); let pages_path = site_path.join(PAGES_PATH);

View file

@ -1,64 +1,281 @@
use std::path::Path; use std::path::{Path, PathBuf};
use webdog::Site; use clap::{Parser, Subcommand};
use include_dir::{include_dir, Dir};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use url::Url;
use webdog::{
frontmatter::FrontMatter,
resource::{ResourceBuilderConfig, ResourceMetadata},
Site, SiteConfig,
};
#[cfg(feature = "serve")] /// The default project to use when creating a new one, embedded into the binary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] static DEFAULT_PROJECT: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/embedded/default_site");
enum Mode { /// The default resource template, embedded into the binary.
Build, static DEFAULT_RESOURCE_TEMPLATES: Dir =
Serve, include_dir!("$CARGO_MANIFEST_DIR/src/embedded/resource-template");
Now,
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// The path to the site.
#[arg(global = true, long, default_value = ".")]
site_path: PathBuf,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Create a new webdog site.
Create {
/// The site's base URL.
base_url: Url,
/// The site's title.
title: String,
/// The site's CDN URL. (defaults to the base URL)
#[arg(long)]
cdn_url: Option<Url>,
},
/// Builds the site.
Build {},
/// Serves the site for locally viewing edits made before publishing.
#[cfg(feature = "serve")]
Serve {
/// The IP address to bind to.
#[arg(long, default_value = "127.0.0.1")]
ip: String,
/// The port to bind to.
#[arg(short, long, default_value = "8080")]
port: u16,
},
/// Helper to get the current timestamp.
Now,
/// For dealing with site resources.
Resource {
#[clap(subcommand)]
command: ResourceCommands,
},
/// Creates a new resource of the given type.
New {
/// The type of resource to create.
resource_type: String,
/// The resource's ID.
id: String,
/// The resource's title.
title: String,
/// The resource's tags.
#[arg(short, long = "tag")]
tags: Vec<String>,
/// The resource's description.
#[arg(short, long)]
description: Option<String>,
/// Whether to skip setting the resource as a draft or not.
#[arg(long, default_value = "false")]
skip_draft: bool,
},
}
#[derive(Debug, Subcommand)]
enum ResourceCommands {
/// Creates a new resource type.
Create {
/// The resource type's ID.
id: String,
/// The name of the resource type to create.
name: String,
/// The name of the resource type, but plural.
plural: String,
},
} }
#[cfg(feature = "serve")]
#[tokio::main] #[tokio::main]
async fn main() -> eyre::Result<()> { async fn main() -> eyre::Result<()> {
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
#[cfg(feature = "color-eyre")] #[cfg(feature = "color-eyre")]
color_eyre::install()?; color_eyre::install()?;
let site = Site::new(&Path::new("site").canonicalize()?)?; let cli = Cli::parse();
let mut mode = Mode::Build; let site = || -> eyre::Result<Site> { Site::new(&Path::new(&cli.site_path).canonicalize()?) };
for arg in std::env::args() {
if arg == "serve" { match cli.command {
mode = Mode::Serve; Commands::Create {
break; base_url,
} else if arg == "now" { cdn_url,
mode = Mode::Now; title,
break; } => {
} if cli.site_path.exists() {
} eprintln!("content exists in the given path! canceling!");
return Ok(());
}
std::fs::create_dir_all(&cli.site_path)?;
let config = SiteConfig::new(base_url.clone(), cdn_url.unwrap_or(base_url), title);
std::fs::write(
cli.site_path.join(SiteConfig::FILENAME),
serde_yml::to_string(&config)?,
)?;
DEFAULT_PROJECT.extract(&cli.site_path)?;
std::fs::create_dir(cli.site_path.join(webdog::ROOT_PATH))?;
match mode {
Mode::Build => {
build(site)?;
}
Mode::Serve => site.serve("127.0.0.1:8080").await?,
Mode::Now => {
let time = OffsetDateTime::now_utc();
println!( println!(
"{}", "Base site created at {:?}! Ready for editing, woof!",
time.format(&Rfc3339) cli.site_path
.expect("failed to format the current time")
); );
Ok(())
}
Commands::Build {} => {
println!("Building site...");
let now = std::time::Instant::now();
site()?.build_once()?;
println!("Build completed in {:?}", now.elapsed());
Ok(())
}
#[cfg(feature = "serve")]
Commands::Serve { ip, port } => {
let site = site()?;
site.serve(&format!("{}:{}", ip, port)).await
}
Commands::Now => {
let time = OffsetDateTime::now_utc();
println!("{}", time.format(&Rfc3339)?);
Ok(())
}
Commands::Resource { command } => match command {
ResourceCommands::Create { id, name, plural } => {
let config_path = cli.site_path.join(SiteConfig::FILENAME);
let mut config = SiteConfig::read(&cli.site_path)?;
if config.resources.contains_key(&id) {
eprintln!("resource type {id} already exists, canceling");
return Ok(());
}
let resource_template_path = cli.site_path.join(webdog::TEMPLATES_PATH).join(&id);
if resource_template_path.exists() {
eprintln!(
"path for resource already exists at {resource_template_path:?}, canceling"
);
return Ok(());
}
std::fs::create_dir_all(&resource_template_path)?;
for file in DEFAULT_RESOURCE_TEMPLATES.files() {
let resource_path = resource_template_path.join(file.path());
if let Some(contents) = file.contents_utf8() {
let mut contents = contents.to_owned();
contents = contents.replace("!!RESOURCE_TYPE!!", &id);
contents = contents.replace("!!RESOURCE_NAME!!", &name);
contents =
contents.replace("!!RESOURCE_NAME_LOWERCASE!!", &name.to_lowercase());
contents = contents.replace("!!RESOURCE_NAME_PLURAL!!", &plural);
contents = contents
.replace("!!RESOURCE_NAME_PLURAL_LOWERCASE!!", &plural.to_lowercase());
std::fs::write(resource_path, contents)?;
} else {
std::fs::write(resource_path, file.contents())?;
}
}
let resource_config = ResourceBuilderConfig {
source_path: id.clone(),
output_path_short: id.clone(),
output_path_long: id.clone(),
resource_template: format!("{id}/resource.tera"),
resource_list_template: format!("{id}/list.tera"),
tag_list_template: "basic-link-list.tera".to_string(),
rss_template: format!("{id}/rss.tera"),
rss_title: id.clone(),
rss_description: Default::default(),
list_title: name.clone(),
tag_list_title: format!("{name} tags"),
resource_name_plural: plural,
resources_per_page: 3,
};
config.resources.insert(id.clone(), resource_config);
std::fs::write(config_path, serde_yml::to_string(&config)?)?;
let resource_path = cli.site_path.join(webdog::RESOURCES_PATH).join(&id);
std::fs::create_dir_all(&resource_path)?;
create_resource(
&resource_path.join("first.md"),
&ResourceMetadata {
title: format!("First {name}"),
timestamp: OffsetDateTime::now_utc(),
tags: vec!["first".to_string()],
cdn_file: None,
desc: Some(format!("This is the first {name} :)")),
inner: serde_yml::Value::Null,
draft: true,
},
)?;
println!("Created the new resource type {id}! The first resource of this time is available at {:?}.", resource_path);
Ok(())
}
},
Commands::New {
resource_type,
id,
title,
tags,
description,
skip_draft,
} => {
let config = SiteConfig::read(&cli.site_path)?;
if let Some(resource) = config.resources.get(&resource_type) {
let resource_path = cli
.site_path
.join(webdog::RESOURCES_PATH)
.join(&resource.source_path)
.join(&id)
.with_extension("md");
if resource_path.exists() {
eprintln!(
"A {resource_type} resource of the ID {id} already exists, canceling!"
);
return Ok(());
}
create_resource(
&resource_path,
&ResourceMetadata {
title,
timestamp: OffsetDateTime::now_utc(),
tags,
cdn_file: None,
desc: description,
inner: serde_yml::Value::Null,
draft: !skip_draft,
},
)?;
println!(
"Created the new {resource_type} resource {id}! Available at {:?}",
resource_path
);
} else {
eprintln!("no resource of type {resource_type}, canceling");
}
Ok(())
} }
} }
}
Ok(()) /// Creates a new resource from the given metadata.
} fn create_resource(resource_path: &Path, metadata: &ResourceMetadata) -> eyre::Result<()> {
std::fs::write(
#[cfg(not(feature = "serve"))] resource_path,
fn main() -> eyre::Result<()> { FrontMatter {
let site = Site::new(&Path::new("site").canonicalize()?)?; content: "hello world :)".to_string(),
build(site) data: Some(metadata),
} }
.format()?,
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(()) Ok(())
} }

View file

@ -10,10 +10,9 @@ use rss::{validation::Validate, ChannelBuilder, ItemBuilder};
use serde::{Deserialize, Serialize, Serializer}; use serde::{Deserialize, Serialize, Serializer};
use time::{format_description::well_known::Rfc2822, OffsetDateTime}; use time::{format_description::well_known::Rfc2822, OffsetDateTime};
use crate::{builder::SiteBuilder, frontmatter::FrontMatter, link_list::Link, PageMetadata}; use crate::{
builder::SiteBuilder, frontmatter::FrontMatterRequired, link_list::Link, PageMetadata,
/// Source base path for resources. };
pub const RESOURCES_PATH: &str = "resources";
/// Metadata for resources. /// Metadata for resources.
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@ -35,16 +34,13 @@ pub struct ResourceMetadata {
/// Whether the resource is a draft. Drafts can be committed without being published to the live site. /// Whether the resource is a draft. Drafts can be committed without being published to the live site.
#[serde(default)] #[serde(default)]
pub draft: bool, pub draft: bool,
/// The resource's content. Defaults to nothing until loaded in another step.
#[serde(default)]
pub content: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ResourceTemplateData<'r> { pub struct ResourceTemplateData<'r> {
/// The resource's metadata. /// The resource's metadata.
#[serde(flatten)] #[serde(flatten)]
pub resource: &'r ResourceMetadata, pub resource: &'r FrontMatterRequired<ResourceMetadata>,
/// The resource's ID. /// The resource's ID.
pub id: String, pub id: String,
/// The resource's timestamp. Duplicated to change serialization method. /// The resource's timestamp. Duplicated to change serialization method.
@ -129,7 +125,7 @@ struct ResourceListTemplateData<'r> {
} }
/// Config for the resource builder. /// Config for the resource builder.
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourceBuilderConfig { pub struct ResourceBuilderConfig {
/// Path to where the resources should be loaded from. /// Path to where the resources should be loaded from.
pub source_path: String, pub source_path: String,
@ -165,7 +161,7 @@ pub struct ResourceBuilder {
/// The builder's config. /// The builder's config.
pub config: ResourceBuilderConfig, pub config: ResourceBuilderConfig,
/// The currently loaded resource metadata. /// The currently loaded resource metadata.
pub loaded_metadata: Vec<(String, ResourceMetadata)>, pub loaded_metadata: Vec<(String, FrontMatterRequired<ResourceMetadata>)>,
} }
impl ResourceBuilder { impl ResourceBuilder {
@ -187,26 +183,27 @@ impl ResourceBuilder {
} }
/// Loads resource metadata from the given path. /// Loads resource metadata from the given path.
fn load(builder: &SiteBuilder, path: &Path) -> eyre::Result<(String, ResourceMetadata)> { fn load(
builder: &SiteBuilder,
path: &Path,
) -> eyre::Result<(String, FrontMatterRequired<ResourceMetadata>)> {
let id = Self::get_id(path); let id = Self::get_id(path);
let input = std::fs::read_to_string(path)?; let input = std::fs::read_to_string(path)?;
let page = FrontMatter::<ResourceMetadata>::parse(input) let mut page = FrontMatterRequired::<ResourceMetadata>::parse(input)
.wrap_err_with(|| eyre::eyre!("Failed to parse resource front matter"))?; .wrap_err_with(|| eyre::eyre!("Failed to parse resource front matter"))?;
let parser = Parser::new_ext(&page.content, Options::all()); let parser = Parser::new_ext(&page.content, Options::all());
let mut html = String::new(); let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, parser); pulldown_cmark::html::push_html(&mut html, parser);
*page.content_mut() = html;
let mut data = page let data = page.data_mut();
.data if let Some(cdn_file) = &data.cdn_file {
.ok_or_else(|| eyre::eyre!("missing front matter for file at {path:?}"))?; data.cdn_file = Some(builder.site.config.cdn_url(cdn_file)?.to_string());
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)) Ok((id, page))
} }
/// Loads all resource metadata from the given config. /// Loads all resource metadata from the given config.
@ -216,20 +213,26 @@ impl ResourceBuilder {
for e in builder for e in builder
.site .site
.site_path .site_path
.join(RESOURCES_PATH) .join(crate::RESOURCES_PATH)
.join(&self.config.source_path) .join(&self.config.source_path)
.read_dir()? .read_dir()?
{ {
let p = e?.path(); let p = e?.path();
if let Some("md") = p.extension().and_then(|e| e.to_str()) { if let Some("md") = p.extension().and_then(|e| e.to_str()) {
let (id, metadata) = Self::load(builder, &p)?; let (id, metadata) = Self::load(builder, &p)?;
if cfg!(not(debug_assertions)) && metadata.draft { if !builder.serving && metadata.data.as_ref().expect("should never fail").draft {
continue; continue;
} }
lmd.push((id, metadata)); lmd.push((id, metadata));
} }
} }
lmd.sort_by(|a, b| b.1.timestamp.cmp(&a.1.timestamp)); lmd.sort_by(|a, b| {
b.1.data
.as_ref()
.expect("should never fail")
.timestamp
.cmp(&a.1.data.as_ref().expect("should never fail").timestamp)
});
Ok(()) Ok(())
} }
@ -246,19 +249,20 @@ impl ResourceBuilder {
&self, &self,
builder: &SiteBuilder, builder: &SiteBuilder,
id: String, id: String,
resource: &ResourceMetadata, resource: &FrontMatterRequired<ResourceMetadata>,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
let out_path = self.build_path(&builder.build_path, &id); let out_path = self.build_path(&builder.build_path, &id);
let data = resource.data();
let out = builder.build_page_raw( let out = builder.build_page_raw(
PageMetadata { PageMetadata {
template: Some(self.config.resource_template.clone()), template: Some(self.config.resource_template.clone()),
title: Some(resource.title.clone()), title: Some(data.title.clone()),
embed: Some(EmbedMetadata { embed: Some(EmbedMetadata {
title: resource.title.clone(), title: data.title.clone(),
site_name: builder.site.config.title.clone(), site_name: builder.site.config.title.clone(),
description: resource.desc.clone(), description: data.desc.clone(),
image: if let Some(cdn_file) = &resource.cdn_file { image: if let Some(cdn_file) = &data.cdn_file {
Some(builder.site.config.cdn_url(cdn_file)?.to_string()) Some(builder.site.config.cdn_url(cdn_file)?.to_string())
} else { } else {
None None
@ -273,7 +277,7 @@ impl ResourceBuilder {
ResourceTemplateData { ResourceTemplateData {
resource, resource,
id, id,
timestamp: resource.timestamp, timestamp: resource.data.as_ref().expect("should never fail").timestamp,
}, },
)?; )?;
std::fs::write(out_path, out)?; std::fs::write(out_path, out)?;
@ -303,7 +307,7 @@ impl ResourceBuilder {
data.push(ResourceTemplateData { data.push(ResourceTemplateData {
resource, resource,
id: id.clone(), id: id.clone(),
timestamp: resource.timestamp, timestamp: resource.data().timestamp,
}); });
} }
@ -368,7 +372,7 @@ impl ResourceBuilder {
// Build resource lists by tag // Build resource lists by tag
let mut tags: BTreeMap<String, Vec<&ResourceTemplateData>> = BTreeMap::new(); let mut tags: BTreeMap<String, Vec<&ResourceTemplateData>> = BTreeMap::new();
for resource in &data { for resource in &data {
for tag in resource.resource.tags.iter().cloned() { for tag in resource.resource.data().tags.iter().cloned() {
tags.entry(tag).or_default().push(resource); tags.entry(tag).or_default().push(resource);
} }
} }
@ -416,7 +420,7 @@ impl ResourceBuilder {
for resource in data { for resource in data {
items.push( items.push(
ItemBuilder::default() ItemBuilder::default()
.title(Some(resource.resource.title.to_owned())) .title(Some(resource.resource.data().title.to_owned()))
.link(Some( .link(Some(
builder builder
.site .site
@ -428,7 +432,7 @@ impl ResourceBuilder {
))? ))?
.to_string(), .to_string(),
)) ))
.description(resource.resource.desc.clone()) .description(resource.resource.data().desc.clone())
.pub_date(Some(resource.timestamp.format(&Rfc2822)?)) .pub_date(Some(resource.timestamp.format(&Rfc2822)?))
.content(Some(builder.tera.render( .content(Some(builder.tera.render(
&self.config.rss_template, &self.config.rss_template,

View file

@ -19,7 +19,7 @@ use warp::{
}; };
use crate::{ use crate::{
resource::RESOURCES_PATH, Site, SiteBuilder, PAGES_PATH, ROOT_PATH, SASS_PATH, TEMPLATES_PATH, Site, SiteBuilder, SiteConfig, PAGES_PATH, RESOURCES_PATH, ROOT_PATH, SASS_PATH, TEMPLATES_PATH,
}; };
/// Helper to get the "name" of a path. /// Helper to get the "name" of a path.
@ -94,7 +94,7 @@ fn create(
builder.site.build_all_pages(builder)?; builder.site.build_all_pages(builder)?;
builder.build_all_resources()?; builder.build_all_resources()?;
} }
} else if relative_path.display().to_string() == "config.yaml" { } else if relative_path.display().to_string() == SiteConfig::FILENAME {
let new_config = serde_yml::from_str(&std::fs::read_to_string(path)?)?; let new_config = serde_yml::from_str(&std::fs::read_to_string(path)?)?;
builder.site.config = new_config; builder.site.config = new_config;
builder.reload()?; builder.reload()?;
@ -277,8 +277,9 @@ impl Site {
.expect("Failed to decode URL"); .expect("Failed to decode URL");
if p == "_dev.js" { if p == "_dev.js" {
let res = let res = Response::new(
Response::new(include_str!("./js/refresh_websocket.js").into()); include_str!("./embedded/js/refresh_websocket.js").into(),
);
return Ok(res); return Ok(res);
} }