Add modding tutorials

This commit is contained in:
Shadowfacts 2025-01-13 16:45:35 -05:00
parent 49feefaedc
commit 22cbe75dc2
18 changed files with 407 additions and 40 deletions

View File

@ -88,7 +88,16 @@ impl<O: 'static, S: Synchronicity> GraphBuilder<O, S> {
///
/// Returns an [`Input`] representing the newly-added node, which can be used to construct rules.
pub fn add_value<V: NodeValue>(&mut self, value: V) -> Input<V> {
return self.add_node(ConstNode::new(value));
return self.add_node(ConstNode::new(value, None));
}
/// Adds a constant node with the given value to the graph.
///
/// The node's label will be the given string.
///
/// Returns an [`Input`] representing the newly-added node, which can be used to construct rules.
pub fn add_named_value<V: NodeValue>(&mut self, value: V, label: String) -> Input<V> {
return self.add_node(ConstNode::new(value, Some(label)));
}
/// Adds an invalidatable node with the given value to the graph.

View File

@ -151,13 +151,15 @@ impl<T: PartialEq + 'static> NodeValue for T {
pub(crate) struct ConstNode<V, S> {
value: Rc<RefCell<Option<V>>>,
label: Option<String>,
synchronicity: std::marker::PhantomData<S>,
}
impl<V, S> ConstNode<V, S> {
pub(crate) fn new(value: V) -> Self {
pub(crate) fn new(value: V, label: Option<String>) -> Self {
Self {
value: Rc::new(RefCell::new(Some(value))),
label,
synchronicity: std::marker::PhantomData,
}
}
@ -183,7 +185,11 @@ impl<V: NodeValue, S: Synchronicity> Node<V, S> for ConstNode<V, S> {
impl<V, S> std::fmt::Debug for ConstNode<V, S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ConstNode<{}>", pretty_type_name::<V>())
if let Some(ref label) = self.label {
write!(f, "ConstNode<{}>({})", pretty_type_name::<V>(), label)
} else {
write!(f, "ConstNode<{}>", pretty_type_name::<V>())
}
}
}

View File

@ -34,7 +34,9 @@
<a href="/{{ tag.slug }}/">{{ tag.name }}</a>{% if loop.last %}.{% else %},{% endif %}
</span>
{% endfor %}
{{ latest_post.word_count | reading_time }} minute read.
<span title="{{ latest_post.word_count }} word{% if latest_post.word_count != 1 %}s{% endif %}">
{{ latest_post.word_count | reading_time }} minute read.
</span>
</p>
<div class="body-content">
{% if latest_post.excerpt %}

View File

@ -25,8 +25,6 @@
{% endif %}
{% endblock %}
{% block title %}{{ post.metadata.title }}{% endblock %}
{% block content -%}
<article itemprop="blogPost" itemscope itemtype="https://schema.org/BlogPosting">
@ -49,7 +47,9 @@
<a href="/{{ tag.slug }}/">{{ tag.name }}</a>{% if loop.last %}.{% else %},{% endif %}
</span>
{% endfor %}
{{ word_count | reading_time }} minute read.
<span title="{{ word_count }} word{% if word_count != 1 %}s{% endif %}">
{{ word_count | reading_time }} minute read.
</span>
</p>
<div class="body-content" itemprop="articleBody">
{{ content }}

View File

@ -0,0 +1,27 @@
{% extends "default" %}
{% block titlevariable %}
{% set title = post.metadata.title ~ " | " ~ post.series_name %}
{% endblock %}
{% block content -%}
<article>
<h1 class="headline">{{ post.metadata.title }}</h1>
<p class="article-meta">
Published on
<time datetime="{{ post.metadata.date | iso_datetime }}">
{{ post.metadata.date | pretty_date }},
</time>
in
<a href="/tutorials/{{ post.series_slug }}/">{{ post.series_name }}</a>.
<span title="{{ post.word_count }} word{% if post.word_count != 1 %}s{% endif %}">
{{ post.word_count | reading_time }} minute read.
</span>
</p>
<div class="body-content">
{{ post.content }}
</div>
</article>
{%- endblock %}

View File

@ -0,0 +1,30 @@
{% extends "default" %}
{% block titlevariable %}
{% set title = series_name %}
{% endblock %}
{% block content -%}
<h1 class="headline">{{ series_name }}</h1>
{% for entry in entries %}
<h2 class="headline">
<a href="/tutorials/{{ series_slug }}/{{ entry.slug }}/">
{{ entry.title }}
</a>
</h2>
<p class="article-meta">
Published on
<time datetime="{{ entry.date | iso_datetime }}">
{{ entry.date | pretty_date }}.
</time>
<span title="{{ entry.word_count }} word{% if entry.word_count != 1 %}s{% endif %}">
{{ entry.word_count | reading_time }} minute read.
</span>
</p>
{% endfor %}
{%- endblock %}

31
site_test/tutorials.html Normal file
View File

@ -0,0 +1,31 @@
{% extends "default" %}
{% block titlevariable %}
{% set title = "Modding Tutorials" %}
{% endblock %}
{% block content -%}
<h1 class="headline">Modding Tutorials</h1>
{% for series in entries %}
<h2 class="headline">
<a href="/tutorials/{{ series.slug }}/">
{{ series.name }}
</a>
</h2>
<p class="article-meta">
{{ series.post_count }}
post{% if series.post_count != 1 %}s{% endif %}.
{% if series.last_updated %}
Last updated on
<time datetime="{{ series.last_updated | iso_datetime }}">
{{ series.last_updated | pretty_date }}.
</time>
{% endif %}
</p>
{% endfor %}
{%- endblock %}

View File

@ -48,7 +48,7 @@ pub fn make_graph(
name: "archive",
output_path: "/archive/index.html".into(),
templates: archive_template,
context,
context: context.into(),
})
}

View File

@ -46,7 +46,7 @@ pub fn make_graph(
name: "home",
output_path: "index.html".into(),
templates: home_template,
context,
context: context.into(),
})
}

View File

@ -8,6 +8,7 @@ mod rss;
mod static_files;
mod tags;
mod templates;
mod tutorials;
mod tv;
mod util;
@ -67,6 +68,12 @@ fn make_graph(watcher: Rc<RefCell<FileWatcher>>) -> anyhow::Result<AsyncGraph<()
let tv = tv::make_graph(&mut builder, default_template.clone(), Rc::clone(&watcher));
let tutorials = tutorials::make_graph(
&mut builder,
default_template.clone(),
&mut *watcher.borrow_mut(),
);
let not_found = not_found::make_graph(
&mut builder,
default_template.clone(),
@ -82,6 +89,7 @@ fn make_graph(watcher: Rc<RefCell<FileWatcher>>) -> anyhow::Result<AsyncGraph<()
statics,
rss,
tv,
tutorials,
not_found,
]);
builder.set_existing_output(output);

View File

@ -1,11 +1,14 @@
use compute_graph::{builder::GraphBuilder, input::Input, synchronicity::Asynchronous};
use crate::generator::{
templates::{AddTemplate, BuildTemplateContext, RenderTemplate},
templates::{AddTemplate, RenderTemplate},
util::content_path,
};
use super::{FileWatcher, templates::Templates};
use super::{
FileWatcher,
templates::{Templates, make_template_context},
};
pub fn make_graph(
builder: &mut GraphBuilder<(), Asynchronous>,
@ -17,18 +20,11 @@ pub fn make_graph(
builder.add_invalidatable_rule(AddTemplate::new("404", path.clone(), default_template));
watcher.watch(path, move || invalidate.invalidate());
let void = builder.add_value(());
let context = builder.add_rule(BuildTemplateContext::new(
"/404.html".into(),
void,
|_, _| {},
));
let render = builder.add_rule(RenderTemplate {
name: "404",
output_path: "404.html".into(),
templates,
context,
context: make_template_context(&"/404.html".into()).into(),
});
render

View File

@ -320,7 +320,7 @@ impl DynamicRule for MakeWritePosts {
name: "article",
output_path: output_path.into(),
templates: self.article_template.clone(),
context,
context: context.into(),
})
});
}

View File

@ -27,7 +27,7 @@ pub fn make_graph(
) -> Input<()> {
let by_tags = builder.add_dynamic_rule(MakePostsByTags::new(posts));
let template_path = content_path("tag.html");
let template_path = content_path("layout/tag.html");
let (tag_template, invalidate_template) = builder.add_invalidatable_rule(AddTemplate::new(
"tag",
template_path.clone(),
@ -209,7 +209,7 @@ impl DynamicRule for MakeWriteTagPages {
name: "tag",
output_path: format!("/{slug}/index.html").into(),
templates: self.templates.clone(),
context: template_context,
context: template_context.into(),
})
});
}

View File

@ -119,17 +119,8 @@ impl<T: 'static, F: Fn(&T, &mut Context) -> () + 'static> Rule for BuildTemplate
type Output = Context;
fn evaluate(&mut self) -> Self::Output {
let mut context = Context::new();
let mut context = make_template_context(&self.permalink);
(self.func)(&*self.input.value(), &mut context);
context.insert("_domain", &*DOMAIN);
match &self.permalink {
TemplatePermalink::Constant(s) => context.insert("_permalink", s),
TemplatePermalink::ConstantOwned(s) => context.insert("_permalink", s),
TemplatePermalink::Dynamic(input) => context.insert("_permalink", &*input.value()),
}
context.insert("_stylesheet_cache_buster", &*CB);
context.insert("_development", &cfg!(debug_assertions));
context.insert("_generated_at", &Local::now());
context
}
@ -142,6 +133,20 @@ impl<T: 'static, F: Fn(&T, &mut Context) -> () + 'static> Rule for BuildTemplate
}
}
pub fn make_template_context(permalink: &TemplatePermalink) -> Context {
let mut context = Context::new();
context.insert("_domain", &*DOMAIN);
match permalink {
TemplatePermalink::Constant(s) => context.insert("_permalink", s),
TemplatePermalink::ConstantOwned(s) => context.insert("_permalink", s),
TemplatePermalink::Dynamic(input) => context.insert("_permalink", &*input.value()),
}
context.insert("_stylesheet_cache_buster", &*CB);
context.insert("_development", &cfg!(debug_assertions));
context.insert("_generated_at", &Local::now());
context
}
#[derive(InputVisitable)]
pub enum TemplatePermalink {
Constant(#[skip_visit] &'static str),
@ -170,22 +175,28 @@ pub struct RenderTemplate {
pub name: &'static str,
pub output_path: TemplateOutputPath,
pub templates: Input<Templates>,
pub context: Input<Context>,
pub context: RenderTemplateContext,
}
impl Rule for RenderTemplate {
type Output = ();
fn evaluate(&mut self) -> Self::Output {
let templates = self.templates.value();
assert!(templates.tera.get_template_names().any(|n| n == self.name));
let has_template = templates.tera.get_template_names().any(|n| n == self.name);
if !has_template {
error!("Missing template {:?}", self.name);
return;
}
let path: &Path = match self.output_path {
TemplateOutputPath::Constant(ref p) => p,
TemplateOutputPath::Dynamic(ref input) => &input.value(),
};
let writer = output_writer(path).expect("output writer");
let result = templates
.tera
.render_to(&self.name, &*self.context.value(), writer);
let context = match self.context {
RenderTemplateContext::Constant(ref ctx) => ctx,
RenderTemplateContext::Dynamic(ref input) => &input.value(),
};
let result = templates.tera.render_to(&self.name, context, writer);
if let Err(e) = result {
error!("Error rendering template to {path:?}: {e:?}");
}
@ -224,6 +235,21 @@ impl From<Input<PathBuf>> for TemplateOutputPath {
Self::Dynamic(value)
}
}
#[derive(InputVisitable)]
pub enum RenderTemplateContext {
Constant(#[skip_visit] Context),
Dynamic(Input<Context>),
}
impl From<Context> for RenderTemplateContext {
fn from(value: Context) -> Self {
Self::Constant(value)
}
}
impl From<Input<Context>> for RenderTemplateContext {
fn from(value: Input<Context>) -> Self {
Self::Dynamic(value)
}
}
pub mod filters {
use chrono::{DateTime, Datelike, Local};

232
src/generator/tutorials.rs Normal file
View File

@ -0,0 +1,232 @@
use chrono::{DateTime, FixedOffset};
use compute_graph::input::InputVisitable;
use compute_graph::rule::{DynamicNodeFactory, DynamicRule, DynamicRuleContext};
use compute_graph::{builder::GraphBuilder, input::Input, synchronicity::Asynchronous};
use serde::{Deserialize, Serialize};
use crate::generator::templates::{AddTemplate, RenderTemplate, make_template_context};
use crate::generator::util::{Combine, MapDynamicToVoid, word_count};
use super::markdown;
use super::util::slugify::slugify;
use super::util::{content_path, from_frontmatter};
use super::{FileWatcher, templates::Templates};
pub fn make_graph(
builder: &mut GraphBuilder<(), Asynchronous>,
default_template: Input<Templates>,
watcher: &mut FileWatcher,
) -> Input<()> {
let post_path = content_path("layout/tutorial_post.html");
let (post_template, invalidate) = builder.add_invalidatable_rule(AddTemplate::new(
"tutorial_post",
post_path.clone(),
default_template.clone(),
));
watcher.watch(post_path, move || invalidate.invalidate());
let series_path = content_path("layout/tutorial_series.html");
let (series_template, invalidate) = builder.add_invalidatable_rule(AddTemplate::new(
"tutorial_series",
series_path.clone(),
default_template.clone(),
));
watcher.watch(series_path, move || invalidate.invalidate());
let index_path = content_path("tutorials.html");
let (index_template, invalidate) = builder.add_invalidatable_rule(AddTemplate::new(
"tutorials",
index_path.clone(),
default_template,
));
watcher.watch(index_path, move || invalidate.invalidate());
let serieses = vec![
read_series("forge-modding-1102", "Forge Mods for 1.10.2"),
read_series("forge-modding-1112", "Forge Mods for 1.11.2"),
read_series("forge-modding-112", "Forge Mods for 1.12"),
];
let mut index_entries = vec![];
let mut render_inputs = vec![];
for series in serieses {
index_entries.push(TutorialIndexEntry {
name: series.name,
slug: series.slug,
post_count: series.posts.len(),
last_updated: series.posts.iter().map(|p| p.metadata.date).max(),
});
let mut series_context =
make_template_context(&format!("/tutorials/{}/", series.slug).into());
let entries = series
.posts
.iter()
.map(|post| TutorialSeriesIndexEntry {
title: post.metadata.title.clone(),
slug: post.slug.clone(),
date: post.metadata.date,
word_count: post.word_count,
})
.collect::<Vec<_>>();
series_context.insert("entries", &entries);
series_context.insert("series_name", series.name);
series_context.insert("series_slug", series.slug);
render_inputs.push(builder.add_rule(RenderTemplate {
name: "tutorial_series",
output_path: format!("tutorials/{}/index.html", series.slug).into(),
templates: series_template.clone(),
context: series_context.into(),
}));
let render_dynamic = builder.add_dynamic_rule(MakeRenderTutorials::new(
series.posts,
post_template.clone(),
));
render_inputs.push(builder.add_rule(MapDynamicToVoid(render_dynamic)))
}
let mut index_context = make_template_context(&"/tutorials/".into());
index_context.insert("entries", &index_entries);
render_inputs.push(builder.add_rule(RenderTemplate {
name: "tutorials",
output_path: "tutorials/index.html".into(),
templates: index_template,
context: index_context.into(),
}));
Combine::make(builder, &render_inputs)
}
fn read_series(slug: &'static str, name: &'static str) -> TutorialSeries {
let mut path = content_path("tutorials");
path.push(slug);
let entries = std::fs::read_dir(path).expect("reading tutorial dir");
let posts = entries
.into_iter()
.map(move |ent| {
let ent = ent.expect("tutorial dir ent");
assert!(
ent.file_type()
.expect("getting tutorial post file type")
.is_file()
);
let path = ent.path();
assert!(path.extension().unwrap().eq_ignore_ascii_case("md"));
let str = std::fs::read_to_string(path).expect("reading tutorial post");
TutorialPost::new(&str, slug, name)
})
.collect();
TutorialSeries { name, slug, posts }
}
struct TutorialSeries {
name: &'static str,
slug: &'static str,
posts: Vec<TutorialPost>,
}
#[derive(PartialEq, Clone, Serialize)]
struct TutorialPost {
series_slug: &'static str,
series_name: &'static str,
slug: String,
content: String,
word_count: u32,
metadata: TutorialPostMetadata,
}
impl TutorialPost {
fn new(contents: &str, series_slug: &'static str, series_name: &'static str) -> Self {
let (metadata, rest) =
from_frontmatter::<TutorialPostMetadata>(contents).expect("parsing tutorial metadata");
let slug = metadata
.slug
.clone()
.unwrap_or_else(|| slugify(&metadata.title));
let mut buf = vec![];
markdown::render(rest, &mut buf);
let html = String::from_utf8(buf).unwrap();
let word_count = word_count::markdown(contents);
TutorialPost {
series_slug,
series_name,
slug,
content: html,
word_count,
metadata,
}
}
}
#[derive(PartialEq, Clone, Deserialize, Serialize)]
struct TutorialPostMetadata {
title: String,
date: DateTime<FixedOffset>,
slug: Option<String>,
}
#[derive(InputVisitable)]
struct MakeRenderTutorials {
#[skip_visit]
posts: Vec<TutorialPost>,
#[skip_visit]
templates: Input<Templates>,
#[skip_visit]
evaluated: bool,
render_factory: DynamicNodeFactory<String, ()>,
}
impl MakeRenderTutorials {
fn new(posts: Vec<TutorialPost>, templates: Input<Templates>) -> Self {
Self {
posts,
templates,
evaluated: false,
render_factory: DynamicNodeFactory::new(),
}
}
}
impl DynamicRule for MakeRenderTutorials {
type ChildOutput = ();
fn evaluate(&mut self, ctx: &mut impl DynamicRuleContext) -> Vec<Input<Self::ChildOutput>> {
assert!(!self.evaluated);
self.evaluated = true;
for post in self.posts.drain(..) {
let mut context = make_template_context(
&format!("/tutorials/{}/{}/", post.series_slug, post.slug).into(),
);
context.insert("post", &post);
self.render_factory.add_node(ctx, post.slug.clone(), |ctx| {
ctx.add_rule(RenderTemplate {
name: "tutorial_post",
output_path: format!("tutorials/{}/{}/index.html", post.series_slug, post.slug)
.into(),
templates: self.templates.clone(),
context: context.into(),
})
});
}
self.render_factory.all_nodes(ctx)
}
}
#[derive(Serialize)]
struct TutorialIndexEntry {
name: &'static str,
slug: &'static str,
post_count: usize,
last_updated: Option<DateTime<FixedOffset>>,
}
#[derive(Serialize)]
struct TutorialSeriesIndexEntry {
title: String,
slug: String,
date: DateTime<FixedOffset>,
word_count: u32,
}

View File

@ -42,7 +42,7 @@ pub fn make_graph(
.borrow_mut()
.watch(tv_path, move || invalidate.invalidate());
let show_path = content_path("show.html");
let show_path = content_path("layout/show.html");
let (show_template, invalidate) = builder.add_invalidatable_rule(AddTemplate::new(
"show",
show_path.clone(),
@ -75,7 +75,7 @@ pub fn make_graph(
name: "tv",
output_path: "tv/index.html".into(),
templates: index_template,
context: index_context,
context: index_context.into(),
});
builder.add_rule(Combine(void_outputs, render_index))
@ -309,7 +309,7 @@ impl DynamicRule for MakeRenderShows {
name: "show",
output_path: format!("/tv/{}/index.html", show.slug).into(),
templates: self.templates.clone(),
context,
context: context.into(),
})
});
}