1
Fork 0

404 page, sitemap, and some linters

This commit is contained in:
prescientmoon 2024-11-07 08:50:05 +01:00
parent 5d65037b26
commit d604ca637d
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
25 changed files with 2358 additions and 172 deletions

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ target
dist
oldicons
result
node_modules
tmp

79
Cargo.lock generated
View file

@ -38,6 +38,21 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.16.0"
@ -80,12 +95,51 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "hashbrown"
version = "0.15.0"
@ -169,11 +223,13 @@ name = "moonythm"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"jotdown",
"once_cell",
"pulldown-latex",
"serde",
"sha2",
"toml",
"tree-sitter",
"tree-sitter-highlight",
@ -280,6 +336,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
@ -399,12 +466,24 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "unicode-ident"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.95"

View file

@ -17,3 +17,5 @@ chrono = { version = "0.4.38", features = ["serde"] }
tree-sitter = "0.24.3"
tree-sitter-rust = "0.23.0"
tree-sitter-highlight = "0.24.3"
sha2 = "0.10.8"
base64 = "0.22.1"

10
content/404.dj Normal file
View file

@ -0,0 +1,10 @@
{ role=config }
``` =toml
sitemap_exclude = true
```
# Ooooops
This page does not seem to exist. If this page was here before, please [contact me][Page containing my contact info].
[Page containing my contact info]: /

View file

@ -23,7 +23,7 @@ A discussion on pretty much every aspect of the mobile rhythm game "Arcaea".
{% [[[ %}
The game judges the way the player hits (or fails to do so) every note by quantizing the inputs into [a few judgements](https://arcaea.fandom.com/wiki/Scoring): a PURE judgement is awarded within a 50ms timing window, the more lenient FAR judgement has twice as much (100ms), with all the other notes being marked as LOST. Moreover, an optional tighter (25ms) timing window awards the MAX PURE (usually referred to as "shiny pure" by the community) judgement.
{ title="Lagrange's extras: Ghost tapping" character="lagrange" }
{ title="Lagrange's extras: Ghost tapping" character="lagrange" id="ghost-tapping" }
::: char-aside
The game only awards judgements for notes in the chart. That is, tapping while no notes are nearby will not influence the score in any way! This turns out to be quite useful, leading to what is referred to as "ghost tapping", which can be a useful way to keep the rhythm or have fun with the chart. An example of this in action can be seen in [this clip][A clip of @gba553 PMing ℵ~0~] of [@gba553](https://www.youtube.com/@Gba553) timing the final pair of tricky notes in [ℵ~0~][The ℵ~0~ chart].
@ -37,7 +37,7 @@ The score is then calculated using a 2:1 PURE to FAR ratio (that is, every PURE
A play with no LOST judgements is called a "full recall" (also known as a "full combo" in other games). Furthermore, a play with no FAR judgements is called a "pure memory" (also known as an "all perfect" in other games, and commonly abbreviated as a "PM"). Finally, a play where every note was hit perfectly (that is, one where everything is a MAX PURE) is called a "max pure memory" (commonly abbreviated as "MPM" by the community).
{title="Lagrange's extras: The scoring formula" character="lagrange"}
{title="Lagrange's extras: The scoring formula" character="lagrange" id="scoring-formula" }
::: char-aside
Let's write the score formula in a nice, closed form!
@ -48,7 +48,7 @@ $$`m + \left\lfloor (2(m + p) + f) \frac{10'000'000}{2(m + p + f + l)} \right\rf
While full recalls and max-PMs are certainly celebrated by players and the larger community alike, PMs are usually the sweet spot between accurate play and fun which many Arcaea players strive for. This is usually the case because the game hardly rewards (if acknowledge at all) plays that are better than a PM (this will become obvious when we discuss the game's rating system).
{title="Lagrange's extras: ζ-scoring" character="lagrange"}
{title="Lagrange's extras: ζ-scoring" character="lagrange" id="ex-scoring"}
::: long-aside
UWAAA, but what if we wanted MAX PURE notes to have a more major contribution to the score? For one, we could start by giving them their own place in the scoring ratio. What would a good ratio look like? A naive approach idea would be to keep the same rate of growth and go with a ratio of 4\:2\:1 for MAX PURE to PURE to FAR. Sadly, issues arise because this can lead to PMs possibly producing terrible scores — it's too big of a departure from the original formula. It turns out the aforementioned [Sound Voltex][] has already figured out a solution with their optional "EX-scoring" system, which uses a ratio of 5\:4\:2, thus awarding 1.25x the normal points for a MAX PURE.

View file

@ -68,10 +68,10 @@ tiers:
</div>
</a>
<div class="tier-elements">
<a href="#arcaea"><img src="icons/arcaea.png" /></a>
<a href="#noita"><img src="icons/noita.png" /></a>
<a href="#rainworld"><img src="icons/rainworld.png" /></a>
<a href="#factorio"><img src="icons/factorio.png" /></a>
<a href="#arcaea"><img alt="ooo" src="icons/arcaea.png" /></a>
<a href="#noita"><img alt="ooo" src="icons/noita.png" /></a>
<a href="#rainworld"><img alt="ooo" src="icons/rainworld.png" /></a>
<a href="#factorio"><img alt="ooo" src="icons/factorio.png" /></a>
</div>
</div>
<div class="tier">
@ -81,9 +81,9 @@ tiers:
</div>
</a>
<div class="tier-elements">
<a href="#tunic"><img src="icons/tunic.ico" /></a>
<a href="#ultrakill"><img src="icons/ultrakill.png" /></a>
<a href="#talos"><img src="icons/thetalosprinciple.jpg" /></a>
<a href="#tunic"><img alt="ooo" src="icons/tunic.ico" /></a>
<a href="#ultrakill"><img alt="ooo" src="icons/ultrakill.png" /></a>
<a href="#talos"><img alt="ooo" src="icons/thetalosprinciple.jpg" /></a>
</div>
</div>
<div class="tier">
@ -93,13 +93,13 @@ tiers:
</div>
</a>
<div class="tier-elements">
<a href="#celeste"><img src="icons/celeste.png" /></a>
<a href="#downpour"><img src="icons/downpour.jpg" /></a>
<a href="#hollow-knight"><img src="icons/hollow-knight.png" /></a>
<a href="#slay-the-spire"><img src="icons/slaythespire.png" /></a>
<a href="#hades"><img src="icons/hades.ico" /></a>
<a href="#portal2"><img src="icons/portal2.png" /></a>
<a href="#baba"><img src="icons/babaisyou.png" /></a>
<a href="#celeste"><img alt="ooo" src="icons/celeste.png" /></a>
<a href="#downpour"><img alt="ooo" src="icons/downpour.jpg" /></a>
<a href="#hollow-knight"><img alt="ooo" src="icons/hollow-knight.png" /></a>
<a href="#slay-the-spire"><img alt="ooo" src="icons/slaythespire.png" /></a>
<a href="#hades"><img alt="ooo" src="icons/hades.ico" /></a>
<a href="#portal2"><img alt="ooo" src="icons/portal2.png" /></a>
<a href="#baba"><img alt="ooo" src="icons/babaisyou.png" /></a>
</div>
</div>
<div class="tier">
@ -163,15 +163,27 @@ The developers have a (sometimes weekly) [blog](https://www.factorio.com/blog/)
I highly recommend not to have your first experience with this game be a co-op session with someone who's played before. Even if the other person is trying their best to let you figure stuff out, they _will_ slip out details or tehniques, if for no other reason than practices feeling _wrong_ to someone who's seen "the light". That's not even mentioning the subset of players whose preferred playstyles involves placing down other people's blueprints. While there's nothing inherently wrong with that, it can take a lot away from one's initial sense of discovery with the game. I'm saying this because I initially bounced away from the game after trying to play with such a friend. In the end, it's up to you.
{ #arcaea }
## Arcaea
### Arcaea
I love rhythm games, and in my humble opinion, [Arcaea](https://arcaea.lowiro.com/) is one of the best ones out there. I'm working on a huge article covering this game extensively, so for now this placeholder paragraph will have to do.
{ #noita }
### Noita
{ #rainworld }
### Rain world
{ #masterpiece }
## Masterpiece
This tier contains games that are truly special, although haven't implanted themselves in my head for as long (nor as deeply) as the games in the previous tier.
{ #talos }
### The Talos Principle
{ #tunic }
### Tunic
{ #ultrakill }
### Ultrakill
@ -180,3 +192,27 @@ Ultrakill is a game all about doing cool shit. There's so many instances where y
After each level, the game rates you on three axes: kills, time, and style. Improving until you can chain together stylish combos in order to P-rank (i.e. perfect) a hard level is super fun. Ultrakill has some of my favourite boss fights in all of gaming, all to the tune of a soundtrack that goes incredibly hard.
The game has some technical issues (it often forgets the resolution you configure in the settings after alt-tabbing around, but this seems to be an issue a lot of Unity games have, so I'll add it to the ever-growing list of justicications for me hating engines). The community seems to be a bit predisposed to spoiling people on gameplay elements, so if you want to go in blind, I recommend staying away from any kind of content related to the game.^
{ #amazing }
## Amazing
{ #hollow-knight }
### Hollow Knight
{ #celeste }
### Celeste
{ #downpour }
### Downpour
{ #slay-the-spire }
### Slay the Spire
{ #hades }
### Hades
{ #portal2 }
### Portal 2
{ #baba }
### Baba is you

View file

@ -1,3 +1,9 @@
{ role=config }
``` =toml
sitemap_changefreq = "weekly"
sitemap_priority = 0.7
```
# Echoes
> _"Remnants of the One who once dwelled within the Silver of the Sky. They now wander endlessly through the mists of the heavens, at times drawn to lost vessels who seek to hear them. Thence, thou with this knowledge are yet to attain the full knowledge of the Plan, but worry not — the World shall linger much longer still."_

View file

@ -11,13 +11,24 @@
{
devShells.default = pkgs.mkShell rec {
nativeBuildInputs = with pkgs; [
# Rust tooling
pkgs.cargo
pkgs.rustc
pkgs.clippy
pkgs.rust-analyzer
pkgs.rustfmt
# Dev tooling
pkgs.imagemagick
pkgs.http-server
# Build / test pipeline
pkgs.nodejs
pkgs.just
pkgs.terser
pkgs.libxml2
pkgs.validator-nu
pkgs.htmltest
];
buildInputs = with pkgs; [ ];

41
justfile Normal file
View file

@ -0,0 +1,41 @@
minify-sitemap:
xmllint --noblanks dist/sitemap.xml --output dist/sitemap.xml
# {{{ Linting
lint: lint-vnu lint-css lint-htmltest lint-htmlvalidate
lint-htmltest:
htmltest -c tooling/htmltest.yml dist
lint-htmlvalidate:
#!/usr/bin/env bash
shopt -s globstar
shopt -s extglob
npx --prefix tooling \
html-validate -c tooling/htmlvalidate.json dist/**/*.html
lint-vnu:
#!/usr/bin/env bash
shopt -s globstar
shopt -s extglob
output=$(
vnu --also-check-svg --no-langdetect \
--stdout --exit-zero-always \
dist/**/*.{html,svg} 2>&1 \
| grep -v "Trailing slash on void elements"
)
if [ -n "$output" ]; then
echo "$output"
exit 1
else
echo "VNU checks passed succesfully"
exit 0
fi
lint-css:
npx --prefix tooling stylelint dist/**/*.css \
--config ./tooling/stylelintrc.json \
--rd --rdd # All disables must come with an explanation and must be necessary
# }}}

View file

@ -5,22 +5,7 @@ body {
padding: 1em;
}
.article-content {
max-width: 40em;
margin: auto;
}
.article-list {
max-width: 36em;
margin: auto;
list-style-type: none;
ul {
list-style-type: disc;
}
}
/* {{{ General element tweaks*/
/* {{{ General element tweaks */
blockquote {
padding-left: 1.25rem;
border-left: 3px solid;
@ -49,7 +34,23 @@ h6 {
math[display="block"] {
margin: 1.5em 0;
}
/* }}}*/
/* }}} */
/* {{{ General article styling */
.article-content {
max-width: 40em;
margin: auto;
}
.article-list {
max-width: 36em;
margin: auto;
list-style-type: none;
ul {
list-style-type: disc;
}
}
/* }}} */
/* {{{ Asides */
/* TODO: remove aside-header altogether */
.aside-header {
@ -86,13 +87,13 @@ math[display="block"] {
}
}
/* {{{ Override marker*/
/* {{{ Override marker */
.aside > summary {
&::marker {
display: none;
}
&:before {
&::before {
content: "▶";
font-size: 0.75rem;
padding: 0 0.75rem 0 0.25rem;
@ -100,11 +101,11 @@ math[display="block"] {
}
}
.aside[open] > summary:before {
.aside[open] > summary::before {
content: "▼";
}
/* }}}*/
/* }}}*/
/* }}} */
/* }}} */
/* {{{ Tier lists */
.tier-list {
background: #444;
@ -161,7 +162,7 @@ math[display="block"] {
}
}
/* {{{ Tier colors*/
/* {{{ Tier colors */
.tier:nth-child(1) > .tier-heading {
background: #b39ddb;
}
@ -202,9 +203,9 @@ math[display="block"] {
#e2baff
);
}
/* }}}*/
/* }}} */
}
/* }}}*/
/* }}} */
/* {{{ Light theme */
@media (prefers-color-scheme: light) {
@ -217,11 +218,7 @@ math[display="block"] {
padding: 1rem;
}
/* {{{ Syntax highlighting*/
span.variable {
color: #4c4f69;
}
/* {{{ Syntax highlighting */
span.attribute,
span.constant {
color: #fe640b;
@ -307,6 +304,6 @@ math[display="block"] {
span.variable-parameter {
color: #e64553;
}
/* }}}*/
/* }}} */
}
/* }}} */

View file

@ -1,8 +1,8 @@
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::FromStr;
use crate::html::render_html;
use crate::metadata::PageMetadata;
use crate::template;
use anyhow::{bail, Context};
@ -13,13 +13,26 @@ pub fn copy_recursively(from: &Path, to: &Path) -> anyhow::Result<()> {
Ok(())
}
#[derive(Debug, Default)]
pub struct Pages<'a> {
pages: Vec<PageMetadata<'a>>,
assets: Vec<PathBuf>,
content_root: PathBuf,
out_root: PathBuf,
base_url: &'a str,
}
impl<'a> Pages<'a> {
pub fn new(content_root: PathBuf, out_root: PathBuf) -> Self {
Self {
pages: Vec::new(),
assets: Vec::new(),
content_root,
out_root,
base_url: "http://localhost:3000",
}
}
// {{{ Collect simple pages
pub fn add_page(&mut self, path: &Path) -> anyhow::Result<()> {
let content_path = PathBuf::from_str("content")?.join(path);
@ -63,9 +76,9 @@ impl<'a> Pages<'a> {
}
// }}}
// {{{ Generate
pub fn generate(&self, out_root: PathBuf) -> anyhow::Result<()> {
pub fn generate(&self) -> anyhow::Result<()> {
for page in &self.pages {
let out_dir = out_root.join(page.route.to_path());
let out_dir = self.out_root.join(page.route.to_path());
std::fs::create_dir_all(&out_dir)
.with_context(|| format!("Failed to generate {out_dir:?} directory"))?;
@ -75,8 +88,13 @@ impl<'a> Pages<'a> {
page_renderer.feed(&mut out, |label, out| {
match label {
"content" => {
let events = jotdown::Parser::new(page.source);
render_html(page, &self.pages, events, out)?;
let mut w = crate::html::Writer::new(page, &self.pages, self.base_url);
for event in jotdown::Parser::new(page.source) {
w.render_event(&event, out)?;
}
w.render_epilogue(out)?;
}
_ => bail!("Unknown label {label} in page template"),
}
@ -88,15 +106,62 @@ impl<'a> Pages<'a> {
.with_context(|| format!("Failed to write {out_dir:?} post"))?;
}
let content_root = PathBuf::from_str("content")?;
for path in &self.assets {
std::fs::create_dir_all(out_root.join(path).parent().unwrap())
std::fs::create_dir_all(self.out_root.join(path).parent().unwrap())
.with_context(|| format!("Failed to create parent dir for asset at {path:?}"))?;
std::fs::copy(content_root.join(path), out_root.join(path))
std::fs::copy(self.content_root.join(path), self.out_root.join(path))
.with_context(|| format!("Failed to copy asset at {path:?}"))?;
}
self.generate_sitemap()?;
Ok(())
}
// }}}
// {{{ Generate sitemap
fn generate_sitemap(&self) -> anyhow::Result<()> {
let mut out = String::new();
write!(
out,
r#"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
"#
)?;
for page in &self.pages {
if page.config.sitemap_exclude {
continue;
}
write!(
out,
"<url>
<loc>https://moonythm.dev/{}</loc>
<lastmod>{}</lastmod>
",
page.route.to_path().to_str().unwrap(),
page.last_modified.to_rfc3339()
)?;
if let Some(priority) = page.config.sitemap_priority {
write!(out, "<priority>{priority}</priority>")?;
}
if let Some(changefreq) = &page.config.sitemap_changefreq {
write!(out, "<changefreq>{changefreq}</changefreq>")?;
}
write!(out, "</url>")?;
}
write!(out, "</urlset>")?;
std::fs::write(self.out_root.join("sitemap.xml"), out.trim())
.with_context(|| "Failed to write sitemap")?;
Ok(())
}
// }}}

View file

@ -27,28 +27,13 @@ use crate::metadata::PageRoute;
use crate::template;
use crate::template::TemplateRenderer;
// {{{ Renderer
/// Render djot content as HTML.
pub fn render_html<'s>(
metadata: &'s PageMetadata,
pages: &'s [PageMetadata],
mut events: impl Iterator<Item = Event<'s>>,
out: &mut impl std::fmt::Write,
) -> anyhow::Result<()> {
let mut w = Writer::new(Some(metadata), pages);
events.try_for_each(|e| w.render_event(&e, out))?;
w.render_epilogue(out)?;
Ok(())
}
// }}}
pub struct Writer<'s> {
list_tightness: Vec<bool>,
states: Vec<State<'s>>,
footnotes: Footnotes<'s>,
metadata: Option<&'s PageMetadata<'s>>,
metadata: &'s PageMetadata<'s>,
pages: &'s [PageMetadata<'s>],
base_url: &'s str,
}
#[derive(Debug, Clone)]
@ -66,13 +51,14 @@ enum State<'s> {
}
impl<'s> Writer<'s> {
pub fn new(metadata: Option<&'s PageMetadata>, pages: &'s [PageMetadata]) -> Self {
pub fn new(metadata: &'s PageMetadata, pages: &'s [PageMetadata], base_url: &'s str) -> Self {
Self {
list_tightness: Vec::new(),
states: Vec::new(),
footnotes: Footnotes::default(),
metadata,
pages,
base_url,
}
}
@ -133,22 +119,21 @@ impl<'s> Writer<'s> {
Event::Start(c, attrs) => {
match &c {
// {{{ Section
Container::Section { id } => match self.metadata {
Some(meta) if meta.title.id == *id => {
if matches!(meta.route, PageRoute::Post(_)) {
Container::Section { id } => {
if self.metadata.title.id == *id {
if matches!(self.metadata.route, PageRoute::Post(_)) {
let mut renderer = template!("templates/post.html", out)?;
assert_eq!(renderer.current(), Some("attrs"));
write!(out, "{}", Attr("aria-labeledby", id))?;
write!(out, "{}", Attr("aria-labelledby", id))?;
renderer.next(out)?;
self.states.push(State::Article(renderer));
}
} else {
write!(out, "<section {}>", Attr("aria-labelledby", id))?;
}
_ => {
write!(out, "<section {}>", Attr("aria-labeledby", id))?;
}
},
}
// }}}
// {{{ Aside
Container::Div {
@ -164,6 +149,13 @@ impl<'s> Writer<'s> {
while let Some(label) = renderer.current() {
match label {
"id" => {
let id = attrs.get_value("id").ok_or_else(|| {
anyhow!("Cannot find `id` attribute on `aside` element")
})?;
write_attribute(out, &id)?;
}
"character" => {
let character =
attrs.get_value("character").ok_or_else(|| {
@ -304,7 +296,7 @@ impl<'s> Writer<'s> {
}
}
_ => {
if !fill_metadata_label(out, label, post)? {
if !Self::write_metadata_label(out, label, post)? {
bail!("Unknown label {label} in `post-summary` template");
};
}
@ -343,7 +335,6 @@ impl<'s> Writer<'s> {
}
Container::Math { display } => {
self.states.push(State::Math(*display));
out.write_str("<math>")?;
}
Container::Image(_, _) => {
self.states.push(State::TextOnly);
@ -417,12 +408,11 @@ impl<'s> Writer<'s> {
// Sanity check
assert!(matches!(self.states.last(), Some(State::Math(_))));
self.states.pop();
out.write_str(r#"</math>"#)?;
}
// }}}
// {{{ Section
Container::Section { id, .. } => match self.metadata {
Some(meta) if meta.title.id == *id => {
Container::Section { id, .. } => {
if self.metadata.title.id == *id {
if matches!(self.states.last(), Some(State::Article(_))) {
let Some(State::Article(renderer)) = self.states.pop() else {
unreachable!()
@ -430,9 +420,10 @@ impl<'s> Writer<'s> {
renderer.finish(out)?;
}
} else {
out.write_str("</section>")?
}
_ => out.write_str("</section>")?,
},
}
// }}}
// {{{ Aside
Container::Div {
@ -452,11 +443,10 @@ impl<'s> Writer<'s> {
// {{{ Article title
if let Some(State::Article(renderer)) = self.states.last_mut() {
if renderer.current() == Some("title") {
// SAFETY: we can never enter into the `article` state without having
// some metadata on hand.
let meta = self.metadata.unwrap();
while let Some(label) = renderer.next(out)? {
if !fill_metadata_label(out, label, meta)? {
if !Self::write_common_label(out, label, self.base_url)?
&& !Self::write_metadata_label(out, label, self.metadata)?
{
break;
}
}
@ -658,7 +648,7 @@ impl<'s> Writer<'s> {
Event::Hardbreak => out.write_str("<br>")?,
Event::Softbreak => out.write_char('\n')?,
// }}}
Event::ThematicBreak(_) => out.write_str("<hr>")?,
Event::ThematicBreak(_) => out.write_str("<hr />")?,
Event::Escape | Event::Blankline | Event::Attributes(_) => {}
}
@ -666,10 +656,10 @@ impl<'s> Writer<'s> {
}
// {{{ Render epilogue
fn render_epilogue(&mut self, out: &mut impl std::fmt::Write) -> anyhow::Result<()> {
pub fn render_epilogue(&mut self, out: &mut impl std::fmt::Write) -> anyhow::Result<()> {
if self.footnotes.reference_encountered() {
// TODO: rewrite this using a template
out.write_str("<section role=\"doc-endnotes\"><hr><ol>")?;
out.write_str("<section role=\"doc-endnotes\"><hr/><ol>")?;
while let Some((number, events)) = self.footnotes.next() {
write!(out, r#"<li id="fn{number}">"#)?;
@ -694,6 +684,72 @@ impl<'s> Writer<'s> {
Ok(())
}
// }}}
// {{{ Fill in metadata labels
fn write_common_label(
out: &mut impl std::fmt::Write,
label: &str,
base_url: &str,
) -> anyhow::Result<bool> {
if label == "base_url" {
write!(out, "{}", base_url)?;
} else {
return Ok(false);
}
Ok(true)
}
fn write_metadata_label(
out: &mut impl std::fmt::Write,
label: &str,
meta: &PageMetadata,
) -> anyhow::Result<bool> {
if label == "posted_on" {
if let Some(d) = meta.config.created_at {
write!(out, "Posted on ")?;
write_datetime(out, &d)?;
} else {
write!(out, "Being conjured by ")?;
}
} else if label == "updated_on" {
write_datetime(out, &meta.last_modified)?;
} else if label == "word_count" {
let wc = meta.word_count;
if wc < 400 {
write!(out, "{}", wc)?;
} else if wc < 1000 {
write!(out, "{}", wc / 10 * 10)?;
} else if wc < 2000 {
write!(out, "{}", wc / 100 * 100)?;
} else {
write!(out, "{} thousand", wc / 1000)?;
}
} else if label == "reading_duration" {
let minutes = meta.word_count / 200;
if minutes == 0 {
let seconds = meta.word_count * 60 / 200;
write!(out, "very short {seconds} second")?;
} else if minutes < 10 {
write!(out, "short {minutes} minute")?;
} else if minutes < 20 {
write!(out, "somewhat short {minutes} minute")?;
} else if minutes < 30 {
write!(out, "somewhat long {minutes}")?;
} else if minutes < 60 {
write!(out, "long {minutes}")?;
} else {
let hours = minutes / 60;
let minutes = minutes % 60;
write!(out, "very long {hours} hour and {minutes} minute")?;
}
} else {
return Ok(false);
}
Ok(true)
}
// }}}
}
// {{{ HTMl escaper
@ -812,55 +868,3 @@ impl<'s> Iterator for Footnotes<'s> {
}
}
// }}}
// {{{ Fill in metadata labels
fn fill_metadata_label(
out: &mut impl std::fmt::Write,
label: &str,
meta: &PageMetadata<'_>,
) -> anyhow::Result<bool> {
if label == "posted_on" {
if let Some(d) = meta.config.created_at {
write!(out, "Posted on ")?;
write_datetime(out, &d)?;
} else {
write!(out, "Being conjured by ")?;
}
} else if label == "updated_on" {
write_datetime(out, &meta.last_modified)?;
} else if label == "word_count" {
let wc = meta.word_count;
if wc < 400 {
write!(out, "{}", wc)?;
} else if wc < 1000 {
write!(out, "{}", wc / 10 * 10)?;
} else if wc < 2000 {
write!(out, "{}", wc / 100 * 100)?;
} else {
write!(out, "{} thousand", wc / 1000)?;
}
} else if label == "reading_duration" {
let minutes = meta.word_count / 200;
if minutes == 0 {
let seconds = meta.word_count * 60 / 200;
write!(out, "very short {seconds} second")?;
} else if minutes < 10 {
write!(out, "short {minutes} minute")?;
} else if minutes < 20 {
write!(out, "somewhat short {minutes} minute")?;
} else if minutes < 30 {
write!(out, "somewhat long {minutes}")?;
} else if minutes < 60 {
write!(out, "long {minutes}")?;
} else {
let hours = minutes / 60;
let minutes = minutes % 60;
write!(out, "very long {hours} hour and {minutes} minute")?;
}
} else {
return Ok(false);
}
Ok(true)
}
// }}}

View file

@ -18,10 +18,14 @@ fn main() -> anyhow::Result<()> {
std::fs::create_dir(&dist_path).with_context(|| "Cannot create `dist` directory")?;
let mut page = generate::Pages::default();
let mut page = generate::Pages::new(
PathBuf::from_str("content").unwrap(),
PathBuf::from_str("dist").unwrap(),
);
page.add_dir(&PathBuf::from_str("")?)
.with_context(|| "Failed to collect directories")?;
page.generate(PathBuf::from_str("dist")?)
page.generate()
.with_context(|| "Failed to generate markup")?;
for p in std::fs::read_dir(public_path)? {

View file

@ -13,30 +13,65 @@ use serde::Deserialize;
#[derive(Deserialize, Debug, Default)]
pub struct PageConfig {
pub created_at: Option<DateTime<FixedOffset>>,
pub sitemap_priority: Option<f32>,
pub sitemap_changefreq: Option<String>,
#[serde(default)]
pub sitemap_exclude: bool,
}
impl PageConfig {
/// Merge another config into the current one. Might error out on duplicate values.
fn merge(&mut self, other: PageConfig) -> anyhow::Result<()> {
match &self.created_at {
None => self.created_at = other.created_at,
Some(first_created) => {
if let Some(second_created) = other.created_at {
if second_created != *first_created {
bail!("Conflicting values for `created_at` page attribute: {first_created} and {second_created}");
// {{{ Merge a single property. Errors out on duplicate values.
fn merge_prop<A: PartialEq + std::fmt::Debug>(
label: &str,
first: Option<A>,
second: Option<A>,
) -> anyhow::Result<Option<A>> {
match first {
None => Ok(second),
Some(first) => {
if let Some(second) = second {
if second != first {
bail!(
"Conflicting values for `{label}` page attribute: {first:?} and {second:?}"
);
}
}
Ok(Some(first))
}
};
}
}
// }}}
// {{{ Config merging
/// Merge another config into the current one. Might error out on duplicate values.
fn merge(&mut self, other: PageConfig) -> anyhow::Result<()> {
self.created_at = Self::merge_prop("created_at", self.created_at, other.created_at)?;
self.sitemap_priority = Self::merge_prop(
"sitemap_priority",
self.sitemap_priority,
other.sitemap_priority,
)?;
self.sitemap_changefreq = Self::merge_prop(
"sitemap_changefreq",
self.sitemap_changefreq.take(),
other.sitemap_changefreq,
)?;
self.sitemap_exclude |= other.sitemap_exclude;
Ok(())
}
// }}}
}
// }}}
// {{{ Routing
#[derive(Debug)]
pub enum PageRoute {
Home,
NotFound,
Posts,
Post(String),
}
@ -50,9 +85,11 @@ impl PageRoute {
let result = if first == OsStr::new("index.dj") {
Self::Home
} else if first == OsStr::new("404.dj") {
Self::NotFound
} else if first == OsStr::new("echoes") {
let Some(Component::Normal(second)) = path.components().nth(2) else {
bail!("Cannot convert path '{:?}' to page route", path);
bail!("Cannot convert path '{:?}' to echo route", path);
};
let mut slice = second.to_str().unwrap();
if slice.ends_with(".dj") {
@ -75,7 +112,8 @@ impl PageRoute {
#[inline]
pub fn to_path(&self) -> PathBuf {
match self {
Self::Home => PathBuf::from_str(".").unwrap(),
Self::Home => PathBuf::from_str("").unwrap(),
Self::NotFound => PathBuf::from_str("404").unwrap(),
Self::Posts => PathBuf::from_str("echoes").unwrap(),
Self::Post(id) => PathBuf::from_str(&format!("echoes/{id}")).unwrap(),
}

View file

@ -1 +1 @@
<aside class="aside">{{content}}</aside>
<div class="aside">{{content}}</div>

View file

@ -1,10 +1,10 @@
<aside class="aside">
<aside class="aside" aria-labelledby="{{id}}">
<div class="aside-header">
<img
alt="{{character}}"
src="/assets/icons/characters/{{character}}.webp"
/>
<h3>{{title}}</h3>
<h3 id="{{id}}">{{title}}</h3>
</div>
{{content}}

View file

@ -1,11 +1,11 @@
<aside>
<aside aria-labelledby="{{id}}">
<details class="aside aside-long">
<summary class="aside-header">
<img
alt="{{character}}"
src="/assets/icons/characters/{{character}}.webp"
/>
<h3>{{title}}</h3>
<h3 id="{{id}}">{{title}}</h3>
</summary>
{{content}}
</details>

View file

@ -29,7 +29,7 @@
<header>
<nav>
<a href="/"><code>~</code></a> /
<a href="/echoes"><code>echoes</code></a>
<a href="/echoes/"><code>echoes</code></a>
</nav>
</header>
<main>{{content}}</main>

View file

@ -1,7 +1,7 @@
<li>
<article>
<h2>
<a href="/echoes/{{id}}" rel="bookmark">{{title}}</a>
<a href="/echoes/{{id}}/" rel="bookmark">{{title}}</a>
</h2>
<ul>

View file

@ -5,7 +5,7 @@
<ul>
<li>
{{posted_on}} by <a href="about:blank">prescientmoon</a> on their
<a href="https://moonythm.dev">website</a>
<a href="{{base_url}}">website</a>
</li>
<li>
Last updated on {{updated_on}}. <a href="about:blank">Source</a> |
@ -17,5 +17,5 @@
<hr />
</header>
<section class="article-content">{{content}}</section>
<div class="article-content">{{content}}</div>
</article>

10
tooling/htmltest.yml Normal file
View file

@ -0,0 +1,10 @@
CacheExpires: "168h" # one week
CheckFavicon: true
EnforceHTML5: true
EnforceHTTPS: true
ExternalTimeout: 20
HTTPConcurrencyLimit: 128
IgnoreAltEmpty: true
IgnoreHTTPS:
- "http://localhost:"
IgnoreURLs:

View file

@ -0,0 +1,9 @@
{
"extends": [
"html-validate:recommended",
"html-validate:standard",
"html-validate:a11y",
"html-validate:document",
"html-validate:prettier"
]
}

1857
tooling/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

7
tooling/package.json Normal file
View file

@ -0,0 +1,7 @@
{
"dependencies": {
"html-validate": "^8.24.2",
"stylelint": "^16.10.0",
"stylelint-config-standard": "^36.0.1"
}
}

8
tooling/stylelintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"extends": "stylelint-config-standard",
"rules": {
"comment-empty-line-before": null,
"no-descending-specificity": null,
"declaration-empty-line-before": null
}
}