This commit is contained in:
nora 2025-06-10 21:30:13 +02:00
parent dded93cc3a
commit 9488cf6e83
8 changed files with 1636 additions and 69 deletions

1250
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,18 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
askama = "0.14.0"
bs58 = "0.5.1"
clap = { version = "4.5.40", features = ["derive"] }
color-eyre = "0.6.5" color-eyre = "0.6.5"
image = "0.25.6"
pulldown-cmark = "0.13.0" pulldown-cmark = "0.13.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_norway = "0.9.42" serde_norway = "0.9.42"
sha2 = "0.10.9"
[profile.dev.package.image]
opt-level = 3
[profile.dev.package.rav1e]
opt-level = 3

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,25 @@
---
title: I have an image
date: "2025-06-06"
---
meow.
![cat](Noratrieb.png)
hello i exist
## woah?
yeah meow i am a cat.
or somethign. i don't know.
yeah meow i am a cat.
or somethign. i don't know.yeah meow i am a cat.
or somethign. i don't know.yeah meow i am a cat.
or somethign. i don't know.
## another one
### it's raining headings
so many headings. this is a paragraph.

View file

@ -1,76 +1,165 @@
use askama::Template;
use color_eyre::{ use color_eyre::{
Result, Result,
eyre::{OptionExt, WrapErr, bail, ensure}, eyre::{OptionExt, WrapErr, bail, ensure},
}; };
use pulldown_cmark::Options; use pulldown_cmark::{Event, Options, Tag, TagEnd};
use sha2::Digest;
use std::{ use std::{
collections::HashMap,
fs::DirEntry, fs::DirEntry,
io,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use crate::context::Context; #[derive(clap::Parser)]
pub struct Opts {
#[clap(long)]
optimize: bool,
#[clap(long, short)]
input: PathBuf,
#[clap(long, short)]
output: PathBuf,
}
mod context { pub struct Context {
use std::collections::BTreeMap; opts: Opts,
static_files: HashMap<String, Vec<u8>>,
theme_css_path: String,
}
#[derive(Default)] struct PictureImages {
pub struct Context { sources: Vec<PictureSource>,
output: OutputDirectory, fallback_path: String,
height: u32,
width: u32,
}
struct PictureSource {
path: String,
media_type: String,
}
impl Context {
fn add_static_file(&mut self, name: &str, ext: &str, content: Vec<u8>) -> Result<String> {
let name = format!("{name}-{}{ext}", create_hash_string(&content));
let _ = self.static_files.insert(name.clone(), content);
Ok(format!("/static/{name}"))
} }
#[derive(Default)] fn add_image(&mut self, path: &Path) -> Result<PictureImages> {
struct OutputDirectory { let image = image::ImageReader::open(path)
entries: BTreeMap<String, OutputFile> .wrap_err("reading image")?
} .decode()
.wrap_err("decoding image")?;
enum OutputFile { let name = path
Dir(OutputDirectory), .file_stem()
BinaryFile(Vec<u8>), .ok_or_eyre("image does not have name")?
StringFile(String), .to_str()
} .unwrap();
impl Context { let optimize = self.opts.optimize;
let mut encode = |format, ext| -> Result<_> {
let mut bytes = vec![];
image.write_to(&mut io::Cursor::new(&mut bytes), format)?;
self.add_static_file(name, ext, bytes)
};
let fallback_path = encode(image::ImageFormat::Jpeg, ".jpg")?;
let sources = if optimize {
let avif_path = encode(image::ImageFormat::Avif, ".avif")?;
let webp_path = encode(image::ImageFormat::WebP, ".webp")?;
vec![
PictureSource {
path: avif_path,
media_type: "image/avif".to_owned(),
},
PictureSource {
path: webp_path,
media_type: "image/webp".to_owned(),
},
]
} else {
vec![]
};
Ok(PictureImages {
sources,
fallback_path,
height: image.height(),
width: image.width(),
})
} }
} }
fn create_hash_string(bytes: &[u8]) -> String {
let digest = sha2::Sha256::digest(bytes);
bs58::encode(&digest[..16]).into_string()
}
struct Post {
name: String,
relative_to: PathBuf,
frontmatter: Frontmatter,
body_md: String,
}
mod write { mod write {
use color_eyre::{Result, eyre::Context}; use color_eyre::{Result, eyre::Context};
use std::path::Path; use std::path::Path;
pub fn initialize(base: &Path) -> Result<()> { pub fn initialize(base: &Path) -> Result<()> {
std::fs::remove_dir_all(base).wrap_err("deleting previous output")?; let _ = std::fs::remove_dir_all(base).wrap_err("deleting previous output");
Ok(std::fs::create_dir_all(base).wrap_err("creating output")?) Ok(std::fs::create_dir_all(base).wrap_err("creating output")?)
} }
pub fn write_file(base: &Path, name: &str, content: &str) -> Result<()> {
Ok(std::fs::write(base.join(name), content)?)
}
} }
pub fn generate(out_base: PathBuf, root: &Path) -> Result<()> { pub fn generate(opts: Opts) -> Result<()> {
let mut ctx = Context::default(); let mut ctx = Context {
opts,
static_files: Default::default(),
theme_css_path: String::new(),
};
collect_posts(&mut ctx, &root.join("posts")) ctx.theme_css_path = ctx
.wrap_err_with(|| format!("reading posts from {}", root.display()))?; .add_static_file(
"theme",
".css",
include_bytes!("../templates/theme.css")
.as_slice()
.to_owned(),
)
.wrap_err("adding theme.css")?;
write::initialize(&out_base).wrap_err("initializing output")?; let posts = collect_posts(&ctx.opts.input.join("posts"))
for output in ctx.outputs { .wrap_err_with(|| format!("reading posts from {}", ctx.opts.input.display()))?;
match output {
OutputFile::Post { write::initialize(&ctx.opts.output).wrap_err("initializing output")?;
name,
frontmatter, for post in posts {
html_body, let dir = ctx.opts.output.join("blog").join("posts").join(&post.name);
} => { std::fs::create_dir_all(&dir)?;
write::write_file(&out_base, &name, &html_body)?;
} let html = render_post(&mut ctx, &post)?;
}
std::fs::write(dir.join("index.html"), html)?;
}
let static_dir = ctx.opts.output.join("static");
std::fs::create_dir(&static_dir).wrap_err("creating static")?;
for (name, content) in ctx.static_files {
std::fs::write(static_dir.join(name), content).wrap_err("writing static file")?;
} }
Ok(()) Ok(())
} }
fn collect_posts(ctx: &mut Context, path: &Path) -> Result<()> { fn collect_posts(path: &Path) -> Result<Vec<Post>> {
let mut posts = vec![];
let entries = std::fs::read_dir(path)?; let entries = std::fs::read_dir(path)?;
for entry in entries { for entry in entries {
@ -78,27 +167,39 @@ fn collect_posts(ctx: &mut Context, path: &Path) -> Result<()> {
let name = entry.file_name(); let name = entry.file_name();
let name = name.to_str().ok_or_eyre("invalid UTF-8 filename")?; let name = name.to_str().ok_or_eyre("invalid UTF-8 filename")?;
collect_post(ctx, &entry, name).wrap_err_with(|| format!("generating post {name}"))?; let post =
collect_post(&entry, name).wrap_err_with(|| format!("generating post {name}"))?;
posts.push(post);
} }
Ok(()) Ok(posts)
} }
fn collect_post(ctx: &mut Context, entry: &DirEntry, name: &str) -> Result<()> { fn collect_post(entry: &DirEntry, name: &str) -> Result<Post> {
let meta = entry.metadata()?; let meta = entry.metadata()?;
if meta.is_dir() {
todo!("directory post");
}
let Some((name, ext)) = name.split_once('.') else { let (name, content, relative_to) = if meta.is_dir() {
bail!("invalid post filename {name}, must be *.md"); let content_path = entry.path().join("index.md");
let content = std::fs::read_to_string(&content_path)
.wrap_err_with(|| format!("could not read {}", content_path.display()))?;
(name.to_owned(), content, entry.path())
} else {
let Some((name, ext)) = name.split_once('.') else {
bail!("invalid post filename {name}, must be *.md");
};
ensure!(
ext == "md",
"invalid filename {name}, only .md extensions are allowed"
);
let content = std::fs::read_to_string(entry.path()).wrap_err("reading contents")?;
(
name.to_owned(),
content,
entry.path().parent().unwrap().to_owned(),
)
}; };
ensure!(
ext == "md",
"invalid filename {name}, only .md extensions are allowed"
);
let content = std::fs::read_to_string(entry.path()).wrap_err("reading contents")?;
let rest = content let rest = content
.strip_prefix("---\n") .strip_prefix("---\n")
@ -110,15 +211,12 @@ fn collect_post(ctx: &mut Context, entry: &DirEntry, name: &str) -> Result<()> {
let frontmatter = let frontmatter =
serde_norway::from_str::<Frontmatter>(frontmatter).wrap_err("¡nvalid frontmatter")?; serde_norway::from_str::<Frontmatter>(frontmatter).wrap_err("¡nvalid frontmatter")?;
let html_body = parse_post_body(&body).wrap_err("parsing post")?; Ok(Post {
name,
ctx.outputs.push(OutputFile::Post {
name: name.to_owned(),
frontmatter, frontmatter,
html_body, body_md: body.to_owned(),
}); relative_to,
})
Ok(())
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@ -127,12 +225,81 @@ struct Frontmatter {
date: String, date: String,
} }
fn parse_post_body(content: &str) -> Result<String> { fn render_post(ctx: &mut Context, post: &Post) -> Result<String> {
#[derive(askama::Template)]
#[template(path = "../templates/post.html")]
struct PostTemplate<'a> {
title: &'a str,
body: &'a str,
theme_css_path: &'a str,
}
let body = render_body(ctx, &post.relative_to, &post.body_md)?;
PostTemplate {
title: &post.frontmatter.title,
body: &body,
theme_css_path: &ctx.theme_css_path,
}
.render()
.wrap_err("failed to render template")
}
fn render_body(ctx: &mut Context, relative_to: &Path, md: &str) -> Result<String> {
let mut options = pulldown_cmark::Options::empty(); let mut options = pulldown_cmark::Options::empty();
options |= Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH; options |= Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
let parser = pulldown_cmark::Parser::new_ext(content, options); let mut parser = pulldown_cmark::Parser::new_ext(md, options);
let mut output = String::new(); let mut events = vec![];
pulldown_cmark::html::push_html(&mut output, parser);
Ok(output) while let Some(ev) = parser.next() {
dbg!(&ev);
match ev {
Event::Start(Tag::Image {
link_type: _,
dest_url,
title: _,
id: _,
}) => {
let Some(Event::Text(alt)) = parser.next() else {
bail!("No alt text for image tag");
};
let Some(Event::End(TagEnd::Image)) = parser.next() else {
bail!("No end tag for image");
};
let sources = ctx.add_image(&relative_to.join(dest_url.as_ref()))?;
events.extend([
Event::Start(Tag::HtmlBlock),
Event::Html("<picture>".into()),
]);
for source in sources.sources {
events.push(Event::Html(
format!(
r#"<source srcset="{}" type="{}">"#,
source.path, source.media_type
)
.into(),
));
}
events.extend([
Event::Html(
format!(
r#"<img src="{}" alt="{}" height="{}" width="{}">"#,
sources.fallback_path, alt, sources.height, sources.width
)
.into(),
),
Event::Html("</picture>".into()),
Event::End(TagEnd::HtmlBlock),
]);
}
ev => events.push(ev),
}
}
let mut body = String::new();
pulldown_cmark::html::push_html(&mut body, events.into_iter());
Ok(body)
} }

View file

@ -1,5 +1,6 @@
use std::{path::Path}; use clap::Parser;
fn main() -> color_eyre::Result<()> { fn main() -> color_eyre::Result<()> {
blogamer::generate("output".into(), Path::new("example")) let opts = blogamer::Opts::parse();
blogamer::generate(opts)
} }

19
templates/post.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ theme_css_path }}" />
<title>{{ title }}</title>
</head>
<body>
<main class="main-content blog-main-content">
<div class="main-content-inner">
<nav><a href="/">Noratrieb</a></nav>
<h1>{{ title }}</h1>
<hr />
<div>{{ body | safe }}</div>
</div>
</main>
</body>
</html>

96
templates/theme.css Normal file
View file

@ -0,0 +1,96 @@
html {
--accent-color: #e5a5c2;
}
body {
font-family: Verdana, sans-serif;
}
.main-content {
display: flex;
flex-direction: column;
align-items: center;
}
@media (min-width: 700px) {
.main-content-inner {
width: 70vw;
}
}
@media (min-width: 1300px) {
.main-content-inner {
width: 50vw;
}
}
.call-to-action {
width: 250px;
height: 50px;
background-color: var(--accent-color);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
text-decoration: none;
}
@media (min-width: 1000px) {
.call-to-action {
width: 400px;
}
}
.call-to-action:hover {
background-color: #ac78b8;
}
.call-to-action * {
color: black;
}
.columns-2 {
display: flex;
flex-direction: row;
}
@media (max-width: 1000px) {
.columns-2 {
display: block;
margin-bottom: 10px;
}
}
@media (prefers-color-scheme: light) {
body {
--background-color: #e6dae9;
--foreground-color: #1b191c;
--black-or-white: black;
}
}
@media (prefers-color-scheme: dark) {
body {
--background-color: #1b191c;
--foreground-color: #e6dae9;
--black-or-white: white;
}
a {
color: var(--accent-color);
}
}
body {
background-color: var(--background-color);
color: var(--foreground-color);
}
.center {
display: flex;
justify-content: center;
}
a {
text-decoration: underline;
}