Compare commits

...

5 commits

Author SHA1 Message Date
zyl
1d502881f6
add support for custom partials
Some checks are pending
Build Site / build (push) Waiting to run
Build Site / deploy (push) Blocked by required conditions
2025-01-14 13:30:34 -08:00
zyl
fd40a5d31a
create parent directories when creating a page in subdirectories 2025-01-14 10:51:28 -08:00
zyl
9608925572
fix bug when serving on a host or port different from default 2025-01-14 10:41:01 -08:00
zyl
4265317edd
add new functions for front matter 2024-11-23 15:42:37 -08:00
zyl
bba26401ba
update dependencies 2024-11-22 17:07:44 -08:00
10 changed files with 735 additions and 376 deletions

754
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ name = "webdog"
version = "0.1.0"
[dependencies]
clap = { version = "4", features = ["derive"] }
clap = {version = "4", features = ["derive"]}
color-eyre = {version = "0.6", optional = true}
extract-frontmatter = "4"
eyre = "0.6"
@ -12,8 +12,10 @@ fs_extra = "1.2"
futures = {version = "0.3", optional = true}
grass = {version = "0.13", default-features = false}
hotwatch = {version = "0.5", optional = true}
html5ever = "0.29"
include_dir = "0.7"
itertools = "0.13"
itertools = "0.14"
kuchikiki = "0.8.6-speedreader"
lol_html = "2"
minifier = {version = "0.3", features = ["html"]}
percent-encoding = {version = "2", optional = true}
@ -28,7 +30,7 @@ serde_yml = "0.0.12"
syntect = "5"
tera = "1"
time = {version = "0.3", features = ["serde-human-readable"]}
tokio = {version = "1.10", features = [
tokio = {version = "1", features = [
"macros",
"rt-multi-thread",
], optional = true}

View file

@ -20,6 +20,10 @@ learn about standard webdog pages here
learn how to use webdog templates
## <a href="webdog-html">webdog html</a>
learn about webdog html extensions
## <a href="styling">styling</a>
learn about webdog styling

View file

@ -0,0 +1,34 @@
---
title: webdog html
template: docs.tera
---
# webdog html
webdog adds some extensions to html to make building your site easier.
## `wd-partial`
any template can be used as a partial in another template or page.
in a template like this:
```html
<p>this is a partial.</p>
<p>the hi argument is {{ userdata.hi }}</p>
<p>the hello argument is {{ userdata.hello }}</p>
<div>
<p>and here's the inner content:
{{ page | safe }}
</div>
```
simply include the `wd-partial` html tag like so:
```html
<wd-partial t="template-to-use.tera" hello="hi" hi="hello">
hiiiiiiiiiii~
</wd-partial>
```
a `wd-partial` tag consists of the `t` attribute to determine the template and any number of additional arguments, which are passed on to the partial template.

View file

@ -12,6 +12,7 @@
{{ self::docLink(text="configuration", href="config") }}
{{ self::docLink(text="pages", href="pages") }}
{{ self::docLink(text="templates", href="templates") }}
{{ self::docLink(text="webdog html", href="webdog-html") }}
{{ self::docLink(text="styling", href="styling") }}
{{ self::docLink(text="resources", href="resources") }}
{{ self::docLink(text="webdog assumptions", href="wd-assumptions") }}

View file

@ -163,106 +163,164 @@ impl SiteBuilder {
head: &Option<String>,
scripts: &[String],
styles: &[String],
is_partial: bool,
) -> eyre::Result<String> {
let mut output = Vec::new();
let mut rewriter = HtmlRewriter::new(
Settings {
element_content_handlers: vec![
element!("body", |el| {
if self.serving {
el.set_attribute("class", "debug")?;
}
Ok(())
}),
element!("head", |el| {
el.prepend(r#"<meta charset="utf-8">"#, ContentType::Html);
el.append(&format!("<title>{title}</title>"), ContentType::Html);
if let Some(head) = head {
el.append(head, ContentType::Html);
}
for script in scripts {
el.append(
&format!(
r#"<script type="text/javascript" src="{script}" defer></script>"#
),
ContentType::Html,
);
}
for style in styles {
el.append(
&format!(r#"<link rel="stylesheet" href="/styles/{style}">"#),
ContentType::Html,
);
}
el.append(
r#"<script type="text/javascript" src="/webdog/webdog.js" defer></script>"#,
ContentType::Html,
);
if self.serving {
el.append(r#"<script src="/_dev.js"></script>"#, ContentType::Html);
}
use kuchikiki::traits::*;
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" => {
let html = {
let document = kuchikiki::parse_html().one(html.clone()).document_node;
let mut needs_reserialized = false;
while let Ok(el) = document.select_first("wd-partial") {
needs_reserialized = true;
let attr_map = el.attributes.borrow();
let template = attr_map
.get("t")
.ok_or_eyre("missing t attribute on wd-partial")?;
let attr_map: HashMap<_, _> = attr_map
.map
.iter()
.map(|(k, v)| (k.local.to_string(), &v.value))
.collect();
let mut html_buf = Vec::new();
for child in el.as_node().children() {
child.serialize(&mut html_buf)?;
}
let html = String::from_utf8(html_buf)?;
let new_html = self.build_page_raw(
PageMetadata {
template: Some(template.to_string()),
userdata: serde_yml::to_value(attr_map)?,
is_partial: true,
..Default::default()
},
&html,
(),
)?;
let new_doc = kuchikiki::parse_html()
.one(new_html)
.document_node
.select_first("body")
.map(|b| b.as_node().children())
.expect("should never fail");
for child in new_doc {
el.as_node().insert_before(child);
}
el.as_node().detach();
}
if needs_reserialized {
let mut html = Vec::new();
document.serialize(&mut html)?;
String::from_utf8(html)?
} else {
html
}
};
let output = if is_partial {
html
} else {
let mut output = Vec::new();
let mut rewriter = HtmlRewriter::new(
Settings {
element_content_handlers: vec![
element!("body", |el| {
if self.serving {
el.set_attribute("class", "debug")?;
}
Ok(())
}),
element!("head", |el| {
el.prepend(r#"<meta charset="utf-8">"#, ContentType::Html);
el.append(&format!("<title>{title}</title>"), ContentType::Html);
if let Some(head) = head {
el.append(head, ContentType::Html);
}
for script in scripts {
el.append(
&format!(
r#"<script type="text/javascript" src="{script}" defer></script>"#
),
ContentType::Html,
);
}
for style in styles {
el.append(
&format!(r#"<link rel="stylesheet" href="/styles/{style}">"#),
ContentType::Html,
);
}
el.append(
r#"<script type="text/javascript" src="/webdog/webdog.js" defer></script>"#,
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() + " me"),
(el.get_attribute("rel").unwrap_or_default()
+ " noopener noreferrer")
.trim(),
)?;
}
_ => {
new_href = &href;
el.set_attribute("target", "_blank")?;
}
}
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();
}
}
}
Ok(())
}),
element!("md", |el| {
el.remove();
let class = el.get_attribute("class");
let class = format!("image {}", class.unwrap_or_default());
let content = el
.get_attribute("content")
.ok_or_eyre("missing content attribute")?;
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#"
el.replace(
&format!(
r#"
<div class="{class}">
<a href="{src}">
<img src="{src}">
@ -270,26 +328,29 @@ impl SiteBuilder {
<span>{content}</span>
</div>
"#
),
ContentType::Html,
);
} else {
return Err(eyre!("unknown markdown tag type: {md_type}").into());
}
),
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),
);
Ok(())
}),
],
strict: true,
..Default::default()
},
|c: &[u8]| output.extend_from_slice(c),
);
rewriter.write(html.as_bytes())?;
rewriter.end()?;
rewriter.write(html.as_bytes())?;
rewriter.end()?;
Ok(String::from_utf8(output)?)
String::from_utf8(output)?
};
Ok(output)
}
/// Helper to build a page without writing it to disk.
@ -334,6 +395,7 @@ impl SiteBuilder {
&head,
&page_metadata.scripts,
&page_metadata.styles,
page_metadata.is_partial,
)?;
if let Some(data) = extra {

View file

@ -6,7 +6,7 @@
let socket;
function start(reload) {
socket = new WebSocket("ws://127.0.0.1:8080");
socket = new WebSocket(`ws://${location.host}`);
let reloading = false;
socket.onmessage = function (ev) {
if (ev.data === "reload") {

View file

@ -12,6 +12,18 @@ pub struct FrontMatter<T> {
pub data: Option<T>,
}
impl<T> FrontMatter<T> {
/// Creates a new front matter.
pub fn new(data: Option<T>, content: String) -> Self {
Self { data, content }
}
/// Creates a new front matter without content.
pub fn new_empty(data: Option<T>) -> Self {
Self::new(data, String::new())
}
}
impl<T> FrontMatter<T>
where
T: DeserializeOwned,
@ -63,6 +75,16 @@ where
pub struct FrontMatterRequired<T>(FrontMatter<T>);
impl<T> FrontMatterRequired<T> {
/// Creates a new front matter.
pub fn new(data: T, content: String) -> Self {
Self(FrontMatter::new(Some(data), content))
}
/// Creates a new front matter without content.
pub fn new_empty(data: T) -> Self {
Self(FrontMatter::new_empty(Some(data)))
}
/// Gets a reference to the front matter's data.
pub fn data(&self) -> &T {
self.0.data.as_ref().expect("missing front matter data")

View file

@ -130,6 +130,9 @@ pub struct PageMetadata {
/// Custom values passed to the base template.
#[serde(default)]
pub userdata: serde_yml::Value,
/// Whether this page being rendered is a partial. Set by the builder, not your page metadata.
#[serde(skip)]
pub is_partial: bool,
}
/// Struct containing information about the site.

View file

@ -256,11 +256,8 @@ fn main() -> eyre::Result<()> {
title,
template,
} => {
let page_path = cli
.site
.join(webdog::PAGES_PATH)
.join(&id)
.with_extension("md");
let page_path = cli.site.join(webdog::PAGES_PATH).join(&id).with_extension("md");
let dir = page_path.parent().expect("should never fail");
if page_path.exists() {
eprintln!("page already exists!");
return Ok(());
@ -273,7 +270,7 @@ fn main() -> eyre::Result<()> {
..Default::default()
}),
};
std::fs::create_dir_all(page_path.parent().expect("should never fail"))?;
std::fs::create_dir_all(dir)?;
std::fs::write(&page_path, fm.format()?)?;
println!("Page created! Edit at {:?}.", page_path);