From 399a0c1b875a6e1b47231c3580d3f637e59f5380 Mon Sep 17 00:00:00 2001 From: zyl Date: Fri, 8 Nov 2024 18:16:23 -0800 Subject: [PATCH] introduce webdog cli tool --- .github/workflows/main.yml | 2 +- .gitignore | 1 + Cargo.lock | 40 +++ Cargo.toml | 2 + site/config.yaml | 6 +- .../{blog-post.tera => blog/blog.tera} | 0 .../{blog-list.tera => blog/list.tera} | 0 .../{rss/blog-post.tera => blog/rss.tera} | 0 src/builder.rs | 4 +- src/embedded/default_site/.gitignore | 1 + src/embedded/default_site/pages/404.md | 7 + src/embedded/default_site/pages/index.md | 1 + src/embedded/default_site/sass/index.scss | 4 + src/embedded/default_site/templates/base.tera | 29 ++ .../templates/basic-link-list.tera | 9 + src/{ => embedded}/js/refresh_websocket.js | 0 src/{ => embedded}/js/webdog.js | 0 src/embedded/resource-template/list.tera | 23 ++ src/embedded/resource-template/resource.tera | 21 ++ src/embedded/resource-template/rss.tera | 1 + src/extras.rs | 2 +- src/frontmatter.rs | 50 ++- src/lib.rs | 47 ++- src/main.rs | 309 +++++++++++++++--- src/resource.rs | 68 ++-- src/serving.rs | 9 +- 26 files changed, 528 insertions(+), 108 deletions(-) rename site/templates/{blog-post.tera => blog/blog.tera} (100%) rename site/templates/{blog-list.tera => blog/list.tera} (100%) rename site/templates/{rss/blog-post.tera => blog/rss.tera} (100%) create mode 100644 src/embedded/default_site/.gitignore create mode 100644 src/embedded/default_site/pages/404.md create mode 100644 src/embedded/default_site/pages/index.md create mode 100644 src/embedded/default_site/sass/index.scss create mode 100644 src/embedded/default_site/templates/base.tera create mode 100644 src/embedded/default_site/templates/basic-link-list.tera rename src/{ => embedded}/js/refresh_websocket.js (100%) rename src/{ => embedded}/js/webdog.js (100%) create mode 100644 src/embedded/resource-template/list.tera create mode 100644 src/embedded/resource-template/resource.tera create mode 100644 src/embedded/resource-template/rss.tera diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4229fb5..39c39f9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,7 +40,7 @@ jobs: # Runs a single command using the runners shell - name: Build site run: | - cargo run --no-default-features --release + cargo run --no-default-features --release -- build --site-path site - name: Setup Pages uses: actions/configure-pages@v5 diff --git a/.gitignore b/.gitignore index 8ff3bd8..a322e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /site/build .DS_Store +/sitetest diff --git a/Cargo.lock b/Cargo.lock index 6456aa8..e1375f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,6 +280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -294,6 +295,18 @@ dependencies = [ "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]] name = "clap_lex" version = "0.7.2" @@ -937,6 +950,12 @@ dependencies = [ "http 0.2.12", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -1216,6 +1235,25 @@ dependencies = [ "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]] name = "indenter" version = "0.3.3" @@ -2838,6 +2876,7 @@ checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" name = "webdog" version = "0.1.0" dependencies = [ + "clap", "color-eyre", "extract-frontmatter", "eyre", @@ -2845,6 +2884,7 @@ dependencies = [ "futures", "grass", "hotwatch", + "include_dir", "itertools", "lol_html", "minifier", diff --git a/Cargo.toml b/Cargo.toml index 7d40939..5dff600 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ name = "webdog" version = "0.1.0" [dependencies] +clap = { version = "4", features = ["derive"] } color-eyre = {version = "0.6", optional = true} extract-frontmatter = "4" eyre = "0.6" @@ -11,6 +12,7 @@ fs_extra = "1.2" futures = {version = "0.3", optional = true} grass = {version = "0.13", default-features = false} hotwatch = {version = "0.5", optional = true} +include_dir = "0.7" itertools = "0.13" lol_html = "2" minifier = {version = "0.3", features = ["html"]} diff --git a/site/config.yaml b/site/config.yaml index cd39031..1592f7a 100644 --- a/site/config.yaml +++ b/site/config.yaml @@ -10,10 +10,10 @@ resources: source_path: blog output_path_short: blog output_path_long: blog - resource_template: blog-post.tera - resource_list_template: blog-list.tera + resource_template: blog/blog.tera + resource_list_template: blog/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_description: feed of recent webdog blog posts. list_title: blog diff --git a/site/templates/blog-post.tera b/site/templates/blog/blog.tera similarity index 100% rename from site/templates/blog-post.tera rename to site/templates/blog/blog.tera diff --git a/site/templates/blog-list.tera b/site/templates/blog/list.tera similarity index 100% rename from site/templates/blog-list.tera rename to site/templates/blog/list.tera diff --git a/site/templates/rss/blog-post.tera b/site/templates/blog/rss.tera similarity index 100% rename from site/templates/rss/blog-post.tera rename to site/templates/blog/rss.tera diff --git a/src/builder.rs b/src/builder.rs index 29094f0..7b78a52 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -46,7 +46,7 @@ pub struct SiteBuilder { /// 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, + pub serving: bool, /// The resource builders available to the builder. pub resource_builders: HashMap, @@ -105,7 +105,7 @@ impl SiteBuilder { std::fs::create_dir(&webdog_path)?; std::fs::write( 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); diff --git a/src/embedded/default_site/.gitignore b/src/embedded/default_site/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/src/embedded/default_site/.gitignore @@ -0,0 +1 @@ +/build diff --git a/src/embedded/default_site/pages/404.md b/src/embedded/default_site/pages/404.md new file mode 100644 index 0000000..45ef01a --- /dev/null +++ b/src/embedded/default_site/pages/404.md @@ -0,0 +1,7 @@ +--- +title: Not Found +--- + +# 404 Not Found + +The page or resource requested does not exist. [Back to home](/) diff --git a/src/embedded/default_site/pages/index.md b/src/embedded/default_site/pages/index.md new file mode 100644 index 0000000..f35e030 --- /dev/null +++ b/src/embedded/default_site/pages/index.md @@ -0,0 +1 @@ +hello from webdog! awruff!! diff --git a/src/embedded/default_site/sass/index.scss b/src/embedded/default_site/sass/index.scss new file mode 100644 index 0000000..2b85ea3 --- /dev/null +++ b/src/embedded/default_site/sass/index.scss @@ -0,0 +1,4 @@ +.page { + background-color: #bbb; + padding: 8px; +} diff --git a/src/embedded/default_site/templates/base.tera b/src/embedded/default_site/templates/base.tera new file mode 100644 index 0000000..917bd4e --- /dev/null +++ b/src/embedded/default_site/templates/base.tera @@ -0,0 +1,29 @@ + + + + + + + + + {{ title }} + {# header information from webdog #} + {{ head | safe }} + {# include scripts defined in the page frontmatter #} + {% for script in scripts %} + + {% endfor %} + {# include styles defined in the page frontmatter #} + {% for style in styles %} + + {% endfor %} + + + +

webdog site

+
+ {% block content %}{{ page | safe }}{% endblock content %} +
+ + + diff --git a/src/embedded/default_site/templates/basic-link-list.tera b/src/embedded/default_site/templates/basic-link-list.tera new file mode 100644 index 0000000..fa685ff --- /dev/null +++ b/src/embedded/default_site/templates/basic-link-list.tera @@ -0,0 +1,9 @@ +{% extends "base.tera" %} +{% block content %} +

{{ title }}

+ +{% endblock content %} diff --git a/src/js/refresh_websocket.js b/src/embedded/js/refresh_websocket.js similarity index 100% rename from src/js/refresh_websocket.js rename to src/embedded/js/refresh_websocket.js diff --git a/src/js/webdog.js b/src/embedded/js/webdog.js similarity index 100% rename from src/js/webdog.js rename to src/embedded/js/webdog.js diff --git a/src/embedded/resource-template/list.tera b/src/embedded/resource-template/list.tera new file mode 100644 index 0000000..422072e --- /dev/null +++ b/src/embedded/resource-template/list.tera @@ -0,0 +1,23 @@ +{% extends "base.tera" %} +{% block content %} +{% if tag %} +

!!RESOURCE_NAME_PLURAL!! tagged {{ tag }}

+

View all !!RESOURCE_NAME_PLURAL_LOWERCASE!!

+{% else %} +

!!RESOURCE_NAME_PLURAL!!

+

view !!RESOURCE_NAME!! tags

+

rss feed

+{% endif %} +

Page {{ page }}/{{ page_max }}

+{% if previous %} +previous page +{% endif %} +{% if next %} +next page +{% endif %} +
+ {% for resource in resources %} +

{{ resource.title }}

+ {% endfor %} +
+{% endblock content %} diff --git a/src/embedded/resource-template/resource.tera b/src/embedded/resource-template/resource.tera new file mode 100644 index 0000000..4243a4e --- /dev/null +++ b/src/embedded/resource-template/resource.tera @@ -0,0 +1,21 @@ +{% extends "base.tera" %} +{% block content %} +
+

{{ title }}

+ published {{ timestamp }} + {% if draft %} +

DRAFT

+ {% endif %} +

{{ desc }}

+
+ {{ content | safe }} +
+
+

tags

+
+ {% for tag in tags %} + {{ tag }}{% if not loop.last %},{% endif %} + {% endfor %} +
+
+{% endblock content %} diff --git a/src/embedded/resource-template/rss.tera b/src/embedded/resource-template/rss.tera new file mode 100644 index 0000000..f82a8fc --- /dev/null +++ b/src/embedded/resource-template/rss.tera @@ -0,0 +1 @@ +
{{ desc | safe }}
diff --git a/src/extras.rs b/src/extras.rs index 93ac9e6..9b87a59 100644 --- a/src/extras.rs +++ b/src/extras.rs @@ -104,7 +104,7 @@ fn resource_list_outside( .map(|(id, v)| ResourceTemplateData { resource: v, id: id.clone(), - timestamp: v.timestamp, + timestamp: v.data().timestamp, }) .collect(), })?, diff --git a/src/frontmatter.rs b/src/frontmatter.rs index 5cf7a44..5816eb0 100644 --- a/src/frontmatter.rs +++ b/src/frontmatter.rs @@ -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. -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct FrontMatter { /// The content past the front matter. pub content: String, /// The front matter found, if any. + #[serde(flatten)] pub data: Option, } @@ -54,3 +57,46 @@ where Ok(output) } } + +/// Wrapper around `FrontMatter` to only function when the data is present. +#[derive(Debug, Serialize, Deserialize)] +pub struct FrontMatterRequired(FrontMatter); + +impl FrontMatterRequired { + /// 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 FrontMatterRequired +where + T: DeserializeOwned, +{ + /// Parses the given input for front matter, failing if missing. + pub fn parse(input: String) -> eyre::Result { + let fm = FrontMatter::parse(input)?; + if fm.data.is_none() { + eyre::bail!("missing frontmatter!"); + } + Ok(Self(fm)) + } +} + +impl Deref for FrontMatterRequired { + type Target = FrontMatter; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/src/lib.rs b/src/lib.rs index e9c2620..3be16eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ mod builder; mod extras; -mod frontmatter; +pub mod frontmatter; mod link_list; -mod resource; +pub mod resource; #[cfg(feature = "serve")] pub mod serving; mod util; @@ -16,19 +16,25 @@ use extras::ExtraData; use eyre::Context; use rayon::prelude::*; use resource::{EmbedMetadata, ResourceBuilderConfig}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use url::Url; 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"; +/// Source base path for normal site pages. +pub const PAGES_PATH: &str = "pages"; +/// Source base path for site templates. +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. -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct SiteConfig { /// The location the site is at. pub base_url: Url, @@ -53,16 +59,18 @@ pub struct SiteConfig { } impl SiteConfig { + /// The filename for site config files. + pub const FILENAME: &str = "config.yaml"; + /// Creates a new site config from the given title. - pub fn new(title: String) -> Self { - let url: Url = "/".parse().expect("should never fail"); + pub fn new(base_url: Url, cdn_url: Url, title: String) -> Self { Self { - base_url: url.clone(), + base_url, title, description: Default::default(), build: None, sass_styles: vec!["index.scss".into()], - cdn_url: url, + cdn_url, code_theme: "base16-ocean.dark".to_string(), resources: Default::default(), } @@ -83,6 +91,15 @@ impl SiteConfig { .ok_or_else(|| eyre::eyre!("missing code theme: {}", self.code_theme))?; Ok(()) } + + /// Helper to read the site config from the given path. + pub fn read(site_path: &Path) -> eyre::Result { + 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) @@ -124,11 +141,7 @@ pub struct Site { impl Site { /// Creates a new site from the given path. pub fn new(site_path: &Path) -> eyre::Result { - 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 config = SiteConfig::read(site_path)?; let mut page_index = HashMap::new(); let pages_path = site_path.join(PAGES_PATH); diff --git a/src/main.rs b/src/main.rs index 44fb1b0..be64753 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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")] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum Mode { - Build, - Serve, - Now, +/// The default project to use when creating a new one, embedded into the binary. +static DEFAULT_PROJECT: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/embedded/default_site"); +/// The default resource template, embedded into the binary. +static DEFAULT_RESOURCE_TEMPLATES: Dir = + include_dir!("$CARGO_MANIFEST_DIR/src/embedded/resource-template"); + +#[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, + }, + /// 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, + /// The resource's description. + #[arg(short, long)] + description: Option, + /// 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] 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 cli = Cli::parse(); - 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; - } - } + let site = || -> eyre::Result { Site::new(&Path::new(&cli.site_path).canonicalize()?) }; + + match cli.command { + Commands::Create { + base_url, + cdn_url, + title, + } => { + 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!( - "{}", - time.format(&Rfc3339) - .expect("failed to format the current time") + "Base site created at {:?}! Ready for editing, woof!", + cli.site_path ); + + 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(()) -} - -#[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()); +/// Creates a new resource from the given metadata. +fn create_resource(resource_path: &Path, metadata: &ResourceMetadata) -> eyre::Result<()> { + std::fs::write( + resource_path, + FrontMatter { + content: "hello world :)".to_string(), + data: Some(metadata), + } + .format()?, + )?; Ok(()) } diff --git a/src/resource.rs b/src/resource.rs index 4ece979..e9a8474 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -10,10 +10,9 @@ 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"; +use crate::{ + builder::SiteBuilder, frontmatter::FrontMatterRequired, link_list::Link, PageMetadata, +}; /// Metadata for resources. #[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. #[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, + pub resource: &'r FrontMatterRequired, /// The resource's ID. pub id: String, /// The resource's timestamp. Duplicated to change serialization method. @@ -129,7 +125,7 @@ struct ResourceListTemplateData<'r> { } /// Config for the resource builder. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ResourceBuilderConfig { /// Path to where the resources should be loaded from. pub source_path: String, @@ -165,7 +161,7 @@ pub struct ResourceBuilder { /// The builder's config. pub config: ResourceBuilderConfig, /// The currently loaded resource metadata. - pub loaded_metadata: Vec<(String, ResourceMetadata)>, + pub loaded_metadata: Vec<(String, FrontMatterRequired)>, } impl ResourceBuilder { @@ -187,26 +183,27 @@ impl ResourceBuilder { } /// 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)> { let id = Self::get_id(path); let input = std::fs::read_to_string(path)?; - let page = FrontMatter::::parse(input) + let mut page = FrontMatterRequired::::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); + *page.content_mut() = html; - 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()); + let data = page.data_mut(); + 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. @@ -216,20 +213,26 @@ impl ResourceBuilder { for e in builder .site .site_path - .join(RESOURCES_PATH) + .join(crate::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 { + if !builder.serving && metadata.data.as_ref().expect("should never fail").draft { continue; } 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(()) } @@ -246,19 +249,20 @@ impl ResourceBuilder { &self, builder: &SiteBuilder, id: String, - resource: &ResourceMetadata, + resource: &FrontMatterRequired, ) -> eyre::Result<()> { let out_path = self.build_path(&builder.build_path, &id); + let data = resource.data(); let out = builder.build_page_raw( PageMetadata { template: Some(self.config.resource_template.clone()), - title: Some(resource.title.clone()), + title: Some(data.title.clone()), embed: Some(EmbedMetadata { - title: resource.title.clone(), + title: data.title.clone(), site_name: builder.site.config.title.clone(), - description: resource.desc.clone(), - image: if let Some(cdn_file) = &resource.cdn_file { + description: data.desc.clone(), + image: if let Some(cdn_file) = &data.cdn_file { Some(builder.site.config.cdn_url(cdn_file)?.to_string()) } else { None @@ -273,7 +277,7 @@ impl ResourceBuilder { ResourceTemplateData { resource, id, - timestamp: resource.timestamp, + timestamp: resource.data.as_ref().expect("should never fail").timestamp, }, )?; std::fs::write(out_path, out)?; @@ -303,7 +307,7 @@ impl ResourceBuilder { data.push(ResourceTemplateData { resource, id: id.clone(), - timestamp: resource.timestamp, + timestamp: resource.data().timestamp, }); } @@ -368,7 +372,7 @@ impl ResourceBuilder { // Build resource lists by tag let mut tags: BTreeMap> = BTreeMap::new(); 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); } } @@ -416,7 +420,7 @@ impl ResourceBuilder { for resource in data { items.push( ItemBuilder::default() - .title(Some(resource.resource.title.to_owned())) + .title(Some(resource.resource.data().title.to_owned())) .link(Some( builder .site @@ -428,7 +432,7 @@ impl ResourceBuilder { ))? .to_string(), )) - .description(resource.resource.desc.clone()) + .description(resource.resource.data().desc.clone()) .pub_date(Some(resource.timestamp.format(&Rfc2822)?)) .content(Some(builder.tera.render( &self.config.rss_template, diff --git a/src/serving.rs b/src/serving.rs index 2734d55..d0c867a 100644 --- a/src/serving.rs +++ b/src/serving.rs @@ -19,7 +19,7 @@ use warp::{ }; 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. @@ -94,7 +94,7 @@ fn create( builder.site.build_all_pages(builder)?; 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)?)?; builder.site.config = new_config; builder.reload()?; @@ -277,8 +277,9 @@ impl Site { .expect("Failed to decode URL"); if p == "_dev.js" { - let res = - Response::new(include_str!("./js/refresh_websocket.js").into()); + let res = Response::new( + include_str!("./embedded/js/refresh_websocket.js").into(), + ); return Ok(res); }