1
Fork 0

Templating system

Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
prescientmoon 2024-10-31 16:30:32 +01:00
parent 9ffac14e2b
commit 10769956cf
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
7 changed files with 443 additions and 161 deletions

View file

@ -1,3 +1,7 @@
``` =toml
created-at: 2024-10-31 15:28
```
# Why I love arcaea # Why I love arcaea
## What is arcaea ## What is arcaea
@ -32,19 +36,12 @@ Let's write the score formula in a nice, closed form!
Let $`m`, $`p`, $`f` and $`l` denote the amount of MAX PURE, PURE, FAR and LOST notes respectively. The final score can then be computed as Let $`m`, $`p`, $`f` and $`l` denote the amount of MAX PURE, PURE, FAR and LOST notes respectively. The final score can then be computed as
$$`\left\lfloor (2(m + p) + f) \frac{10'000'000}{2(m + p + f)} \right\rfloor` $$`m + \left\lfloor (2(m + p) + f) \frac{10'000'000}{2(m + p + f + l)} \right\rfloor`
::: :::
{.label="Lagrange's extras: ζ-scoring"} {title="Lagrange's extras: ζ-scoring" character="lagrange"}
:::: in-depth ::: aside
::: in-depth-header 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](https://en.wikipedia.org/wiki/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.
![lagrange](../assets/icons/lagrange.webp){.in-depth-icon}
{.in-depth-heading}
### EX scoring
:::
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](https://en.wikipedia.org/wiki/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.
Calling this "EX-scoring" in the context of Arcaea would be confusing (EX being an in-game grade and all), hence I decided to call the Arcaea equivalent of the system "ζ-scoring". In particular, we can compute the ζ-score by only knowing the base score and the number of notes in the chart (call it $`n`) as follows: Calling this "EX-scoring" in the context of Arcaea would be confusing (EX being an in-game grade and all), hence I decided to call the Arcaea equivalent of the system "ζ-scoring". In particular, we can compute the ζ-score by only knowing the base score and the number of notes in the chart (call it $`n`) as follows:
@ -53,7 +50,7 @@ Calling this "EX-scoring" in the context of Arcaea would be confusing (EX being
3. Double the quotient from step 2 and add the remainder to form the final expression of $`5m + 4p + 2f`, which can then be scaled up so $`10,000,000` is the maximum score again. 3. Double the quotient from step 2 and add the remainder to form the final expression of $`5m + 4p + 2f`, which can then be scaled up so $`10,000,000` is the maximum score again.
With a bit of care put into working around the floor function in the actual scoring formula, and performing the computations in a manner that avoids any risks of floating point arithmetic errors, one can implement a very reliable score converter. For instance, the score tracking Arcaea discord bot I'm developing has ζ-scoring well-integrated into everything! With a bit of care put into working around the floor function in the actual scoring formula, and performing the computations in a manner that avoids any risks of floating point arithmetic errors, one can implement a very reliable score converter. For instance, the score tracking Arcaea discord bot I'm developing has ζ-scoring well-integrated into everything!
::::: :::
{% ]]] %} {% ]]] %}
#### Why arcaea's scoring rocks #### Why arcaea's scoring rocks

View file

@ -2,67 +2,75 @@ html {
font: 100%/1.5 sans-serif; font: 100%/1.5 sans-serif;
} }
#page-content {
max-width: 70ch;
margin-left: auto;
margin-right: auto;
}
blockquote { blockquote {
padding-left: 1.25rem; padding-left: 1.25rem;
border-left: 3px solid; border-left: 3px solid;
} }
.in-depth {
margin: 1.5rem 0;
}
ul, ul,
ol { ol {
padding-left: 1rem; padding-left: 1rem;
} }
.in-depth-header { /* {{{ Asides*/
.aside {
margin: 1.5rem 0;
}
.aside-header {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.in-depth-header > .in-depth-heading { .aside-summary > .aside-title {
text-decoration: underline; text-decoration: underline;
} }
.in-depth-header > * { .aside-summary > * {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
img.in-depth-icon { img.aside-icon {
height: 1.75rem; height: 1.75rem;
margin-right: 0.5rem; margin-right: 0.5rem;
transform: translateY(-2px); transform: translateY(-2px);
} }
details { .aside {
background: #ead3ed; background: #ead3ed;
border-radius: 3px; border-radius: 3px;
padding: 0.5rem 0.5rem; padding: 0.5rem 0.5rem;
box-sizing: border-box; box-sizing: border-box;
} }
summary { .aside-summary {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
summary::marker { .aside-summary::marker {
display: none; display: none;
} }
summary:before { .aside-summary:before {
content: "▶"; content: "▶";
font-size: 0.75rem; font-size: 0.75rem;
padding: 0 0.75rem 0 0.25rem; padding: 0 0.75rem 0 0.25rem;
box-sizing: border-box; box-sizing: border-box;
} }
details[open] summary:before { .aside[open] .aside_summary:before {
content: "▼"; content: "▼";
} }
details > .in-depth { .aside > .aside-content {
padding-left: 1rem; padding-left: 1rem;
} }
/* }}}*/

View file

@ -2,82 +2,99 @@
use std::collections::HashMap; use std::collections::HashMap;
use anyhow::anyhow;
use jotdown::Alignment; use jotdown::Alignment;
use jotdown::Container; use jotdown::Container;
use jotdown::Event; use jotdown::Event;
use jotdown::LinkType; use jotdown::LinkType;
use jotdown::ListKind; use jotdown::ListKind;
use jotdown::OrderedListNumbering::*; use jotdown::OrderedListNumbering::*;
use jotdown::Render;
use jotdown::RenderRef;
use jotdown::SpanLinkType; use jotdown::SpanLinkType;
use crate::template;
use crate::template::Template;
use crate::template::TemplateRenderer;
// {{{ Renderer // {{{ Renderer
/// Render events into a string. /// Render events into a string.
pub fn render_to_string<'s, I>(events: I) -> String pub fn render_to_string<'s, I>(events: I) -> anyhow::Result<String>
where where
I: Iterator<Item = Event<'s>>, I: Iterator<Item = Event<'s>>,
{ {
let mut s = String::new(); let mut s = String::new();
Renderer::default().push(events, &mut s).unwrap(); Renderer::new()?.push(events, &mut s)?;
s Ok(s)
} }
/// [`Render`] implementor that writes HTML output. /// [`Render`] implementor that writes HTML output.
#[derive(Clone, Default)] #[derive(Clone, Debug)]
pub struct Renderer {} pub struct Renderer {
templates: BuiltinTempaltes,
impl Render for Renderer {
fn push<'s, I, W>(&self, mut events: I, mut out: W) -> std::fmt::Result
where
I: Iterator<Item = Event<'s>>,
W: std::fmt::Write,
{
let mut w = Writer::new();
events.try_for_each(|e| w.render_event(&e, &mut out))?;
w.render_epilogue(&mut out)
}
} }
impl RenderRef for Renderer { impl Renderer {
fn push_ref<'s, E, I, W>(&self, mut events: I, mut out: W) -> std::fmt::Result pub fn new() -> anyhow::Result<Self> {
where Ok(Self {
E: AsRef<Event<'s>>, templates: BuiltinTempaltes::new()?,
I: Iterator<Item = E>, })
W: std::fmt::Write, }
{
let mut w = Writer::new(); pub fn push<'s>(
events.try_for_each(|e| w.render_event(e.as_ref(), &mut out))?; &self,
w.render_epilogue(&mut out) mut events: impl Iterator<Item = Event<'s>>,
mut out: impl std::fmt::Write,
) -> anyhow::Result<()> {
let mut w = Writer::new(self);
events.try_for_each(|e| w.render_event(&e, &mut out))?;
w.render_epilogue(&mut out)?;
Ok(())
}
}
// }}}
// {{{ Bring in templates
#[derive(Clone, Debug)]
struct BuiltinTempaltes {
aside_template: Template,
}
impl BuiltinTempaltes {
fn new() -> anyhow::Result<Self> {
Ok(BuiltinTempaltes {
aside_template: template!("./templates/aside.html")?,
})
} }
} }
// }}} // }}}
struct Writer<'s> { struct Writer<'s> {
list_tightness: Vec<bool>, list_tightness: Vec<bool>,
states: Vec<State>, states: Vec<State<'s>>,
footnotes: Footnotes<'s>, footnotes: Footnotes<'s>,
renderer: &'s Renderer,
} }
#[derive(PartialEq, Eq, Debug, Clone, Copy)] #[derive(Debug, Clone)]
enum State { enum State<'s> {
TextOnly, TextOnly,
Ignore, Ignore,
Raw, Raw,
Math(bool), Math(bool),
Aside(TemplateRenderer<'s>),
} }
impl<'s> Writer<'s> { impl<'s> Writer<'s> {
fn new() -> Self { fn new(renderer: &'s Renderer) -> Self {
Self { Self {
list_tightness: Vec::new(), list_tightness: Vec::new(),
states: Vec::new(), states: Vec::new(),
footnotes: Footnotes::default(), footnotes: Footnotes::default(),
renderer,
} }
} }
#[allow(clippy::single_match)] #[allow(clippy::single_match)]
fn render_event<W>(&mut self, e: &Event<'s>, mut out: W) -> std::fmt::Result fn render_event<W>(&mut self, e: &Event<'s>, mut out: W) -> anyhow::Result<()>
where where
W: std::fmt::Write, W: std::fmt::Write,
{ {
@ -101,7 +118,7 @@ impl<'s> Writer<'s> {
return Ok(()); return Ok(());
} }
Event::End(Container::LinkDefinition { .. }) => { Event::End(Container::LinkDefinition { .. }) => {
assert_eq!(self.states.last(), Some(&State::Ignore)); assert!(matches!(self.states.last(), Some(State::Ignore)));
self.states.pop(); self.states.pop();
} }
@ -116,9 +133,9 @@ impl<'s> Writer<'s> {
} }
Event::End(Container::RawBlock { format } | Container::RawInline { format }) => { Event::End(Container::RawBlock { format } | Container::RawInline { format }) => {
if format == &"html" { if format == &"html" {
assert_eq!(self.states.last(), Some(&State::Raw)); assert!(matches!(self.states.last(), Some(State::Raw)));
} else { } else {
assert_eq!(self.states.last(), Some(&State::Ignore)); assert!(matches!(self.states.last(), Some(State::Ignore)));
}; };
self.states.pop(); self.states.pop();
@ -128,14 +145,14 @@ impl<'s> Writer<'s> {
} }
// }}} // }}}
if self.states.last() == Some(&State::Ignore) { if matches!(self.states.last(), Some(State::Ignore)) {
return Ok(()); return Ok(());
} }
match e { match e {
// {{{ Container start // {{{ Container start
Event::Start(c, attrs) => { Event::Start(c, attrs) => {
if self.states.last() == Some(&State::TextOnly) { if matches!(self.states.last(), Some(&State::TextOnly)) {
return Ok(()); return Ok(());
} }
@ -200,6 +217,35 @@ impl<'s> Writer<'s> {
Container::Table => out.write_str("<table")?, Container::Table => out.write_str("<table")?,
Container::TableRow { .. } => out.write_str("<tr")?, Container::TableRow { .. } => out.write_str("<tr")?,
Container::Section { .. } => out.write_str("<section")?, Container::Section { .. } => out.write_str("<section")?,
Container::Div { class: "aside" } => {
let title = attrs.get_value("title").ok_or_else(|| {
anyhow!("Cannot find `title` attribute on `aside` element")
})?;
let character = attrs.get_value("character").ok_or_else(|| {
anyhow!("Cannot find `character` attribute on `aside` element")
})?;
let mut renderer =
TemplateRenderer::new(&self.renderer.templates.aside_template);
while let Some(label) = renderer.current(&mut out)? {
if label == "character" {
character
.parts()
.try_for_each(|part| write_attr(part, &mut out))?;
renderer.next(&mut out)?;
} else if label == "title" {
title
.parts()
.try_for_each(|part| write_attr(part, &mut out))?;
renderer.next(&mut out)?;
} else {
break;
}
}
self.states.push(State::Aside(renderer));
}
Container::Div { .. } => out.write_str("<div")?, Container::Div { .. } => out.write_str("<div")?,
Container::Heading { level, .. } => write!(out, "<h{}", level)?, Container::Heading { level, .. } => write!(out, "<h{}", level)?,
Container::TableCell { head: false, .. } => out.write_str("<td")?, Container::TableCell { head: false, .. } => out.write_str("<td")?,
@ -220,85 +266,93 @@ impl<'s> Writer<'s> {
Container::LinkDefinition { .. } => return Ok(()), Container::LinkDefinition { .. } => return Ok(()),
} }
// {{{ Write attributes let mut write_attribs = true;
let mut id_written = false; if matches!(c, Container::Div { class: "aside" }) {
let mut class_written = false; write_attribs = false;
}
for (a, v) in attrs.unique_pairs() { if write_attribs {
write!(out, r#" {}=""#, a)?; // {{{ Write attributes
v.parts().try_for_each(|part| write_attr(part, &mut out))?; let mut id_written = false;
match a { let mut class_written = false;
"class" => {
class_written = true; if write_attribs {
write_class(c, true, &mut out)?; for (a, v) in attrs.unique_pairs() {
let is_class = a == "class";
let is_id = a == "id";
if (!is_id || !id_written) && (!is_class || !class_written) {
write!(out, r#" {}=""#, a)?;
v.parts().try_for_each(|part| write_attr(part, &mut out))?;
out.write_char('"')?;
id_written |= is_id;
class_written |= is_class;
};
} }
"id" => id_written = true,
_ => {}
} }
out.write_char('"')?; // }}}
} // {{{ Write default ids/classes
// }}} if let Container::Heading {
// {{{ Write default ids/classes id,
if let Container::Heading { has_section: false,
id, ..
has_section: false, }
.. | Container::Section { id } = &c
} {
| Container::Section { id } = &c if !id_written {
{ out.write_str(r#" id=""#)?;
if !id_written { write_attr(id, &mut out)?;
out.write_str(r#" id=""#)?; out.write_char('"')?;
write_attr(id, &mut out)?; }
// TODO: do I not want this to add onto the provided class?
} else if (matches!(c, Container::Div { class } if !class.is_empty())
|| matches!(
c,
Container::Math { .. }
| Container::List {
kind: ListKind::Task(..),
..
} | Container::TaskListItem { .. }
)) && !class_written
{
out.write_str(r#" class=""#)?;
write_class(c, false, &mut out)?;
out.write_char('"')?; out.write_char('"')?;
} }
// TODO: do I not want this to add onto the provided class? // }}}
} else if (matches!(c, Container::Div { class } if !class.is_empty())
|| matches!(
c,
Container::Math { .. }
| Container::List {
kind: ListKind::Task(..),
..
} | Container::TaskListItem { .. }
)) && !class_written
{
out.write_str(r#" class=""#)?;
write_class(c, false, &mut out)?;
out.write_char('"')?;
}
// }}}
match c { match c {
// {{{ Write css for aligning table cell text // {{{ Write css for aligning table cell text
Container::TableCell { alignment, .. } Container::TableCell { alignment, .. }
if !matches!(alignment, Alignment::Unspecified) => if !matches!(alignment, Alignment::Unspecified) =>
{ {
let a = match alignment { let a = match alignment {
Alignment::Unspecified => unreachable!(), Alignment::Unspecified => unreachable!(),
Alignment::Left => "left", Alignment::Left => "left",
Alignment::Center => "center", Alignment::Center => "center",
Alignment::Right => "right", Alignment::Right => "right",
}; };
write!(out, r#" style="text-align: {};">"#, a)?; write!(out, r#" style="text-align: {};">"#, a)?;
}
// }}}
// {{{ Write language for codeblock
Container::CodeBlock { language } => {
if language.is_empty() {
out.write_str("><code>")?;
} else {
out.write_str(r#"><code class="language-"#)?;
write_attr(language, &mut out)?;
out.write_str(r#"">"#)?;
} }
// }}}
// {{{ Write language for codeblock
Container::CodeBlock { language } => {
if language.is_empty() {
out.write_str("><code>")?;
} else {
out.write_str(r#"><code class="language-"#)?;
write_attr(language, &mut out)?;
out.write_str(r#"">"#)?;
}
}
// }}}
Container::Image(..) => out.write_str(r#" alt=""#)?,
Container::Math { display } => {
out.write_str(r#">"#)?;
self.states.push(State::Math(*display));
}
_ => out.write_char('>')?,
} }
// }}}
Container::Image(..) => out.write_str(r#" alt=""#)?,
Container::Math { display } => {
out.write_str(r#">"#)?;
self.states.push(State::Math(*display));
}
_ => out.write_char('>')?,
} }
match &c { match &c {
@ -313,13 +367,13 @@ impl<'s> Writer<'s> {
Event::End(c) => { Event::End(c) => {
match &c { match &c {
Container::Image(..) => { Container::Image(..) => {
assert_eq!(self.states.last(), Some(&State::TextOnly)); assert!(matches!(self.states.last(), Some(State::TextOnly)));
self.states.pop(); self.states.pop();
} }
_ => {} _ => {}
} }
if self.states.last() == Some(&State::TextOnly) { if matches!(self.states.last(), Some(State::TextOnly)) {
return Ok(()); return Ok(());
} }
@ -374,6 +428,15 @@ impl<'s> Writer<'s> {
Container::Table => out.write_str("</table>")?, Container::Table => out.write_str("</table>")?,
Container::TableRow { .. } => out.write_str("</tr>")?, Container::TableRow { .. } => out.write_str("</tr>")?,
Container::Section { .. } => out.write_str("</section>")?, Container::Section { .. } => out.write_str("</section>")?,
Container::Div { class: "aside" } => {
let state = self.states.pop().unwrap();
let State::Aside(mut renderer) = state else {
panic!("Finished `aside` element without being in the `Aside` state.")
};
assert_eq!(renderer.current(&mut out)?, Some("content"));
renderer.finish(&mut out)?;
}
Container::Div { .. } => out.write_str("</div>")?, Container::Div { .. } => out.write_str("</div>")?,
Container::Heading { level, .. } => write!(out, "</h{}>", level)?, Container::Heading { level, .. } => write!(out, "</h{}>", level)?,
Container::TableCell { head: false, .. } => out.write_str("</td>")?, Container::TableCell { head: false, .. } => out.write_str("</td>")?,
@ -428,7 +491,7 @@ impl<'s> Writer<'s> {
// {{{ Footnote reference // {{{ Footnote reference
Event::FootnoteReference(label) => { Event::FootnoteReference(label) => {
let number = self.footnotes.reference(label); let number = self.footnotes.reference(label);
if self.states.last() != Some(&State::TextOnly) { if !matches!(self.states.last(), Some(State::TextOnly)) {
write!( write!(
out, out,
r##"<a id="fnref{}" href="#fn{}" role="doc-noteref"><sup>{}</sup></a>"##, r##"<a id="fnref{}" href="#fn{}" role="doc-noteref"><sup>{}</sup></a>"##,
@ -468,10 +531,7 @@ impl<'s> Writer<'s> {
} }
// {{{ Render epilogue // {{{ Render epilogue
fn render_epilogue<W>(&mut self, mut out: W) -> std::fmt::Result fn render_epilogue(&mut self, mut out: impl std::fmt::Write) -> anyhow::Result<()> {
where
W: std::fmt::Write,
{
if self.footnotes.reference_encountered() { if self.footnotes.reference_encountered() {
out.write_str("<section role=\"doc-endnotes\">")?; out.write_str("<section role=\"doc-endnotes\">")?;
out.write_str("<hr>")?; out.write_str("<hr>")?;
@ -545,24 +605,21 @@ where
Ok(()) Ok(())
} }
fn write_text<W>(s: &str, out: W) -> std::fmt::Result #[inline]
where fn write_text(s: &str, out: impl std::fmt::Write) -> std::fmt::Result {
W: std::fmt::Write,
{
write_escape(s, false, out) write_escape(s, false, out)
} }
fn write_attr<W>(s: &str, out: W) -> std::fmt::Result #[inline]
where fn write_attr(s: &str, out: impl std::fmt::Write) -> std::fmt::Result {
W: std::fmt::Write,
{
write_escape(s, true, out) write_escape(s, true, out)
} }
fn write_escape<W>(mut s: &str, escape_quotes: bool, mut out: W) -> std::fmt::Result fn write_escape(
where mut s: &str,
W: std::fmt::Write, escape_quotes: bool,
{ mut out: impl std::fmt::Write,
) -> std::fmt::Result {
let mut ent = ""; let mut ent = "";
while let Some(i) = s.find(|c| { while let Some(i) = s.find(|c| {
match c { match c {

View file

@ -1,8 +1,60 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::FromStr;
use anyhow::Context;
use html::Renderer;
use template::TemplateRenderer;
mod html; mod html;
mod template;
mod tex; mod tex;
fn main() {
let djot_input = std::fs::read_to_string("content/arcaea.dj").unwrap(); fn copy_recursively(from: &Path, to: &Path) -> anyhow::Result<()> {
let events = jotdown::Parser::new(&djot_input); Command::new("cp").arg("-r").arg(from).arg(to).output()?;
let html = crate::html::render_to_string(events);
println!("{html}"); Ok(())
}
fn main() -> anyhow::Result<()> {
let dist_path = PathBuf::from_str("dist")?;
let public_path = PathBuf::from_str("public")?;
if dist_path.exists() {
std::fs::remove_dir_all(&dist_path).with_context(|| "Cannot delete `dist` directory")?;
}
std::fs::create_dir(&dist_path).with_context(|| "Cannot create `dist` directory")?;
for p in std::fs::read_dir(public_path)? {
copy_recursively(&p?.path(), &dist_path)
.with_context(|| "Cannot copy `public` -> `dist`")?;
}
// {{{ Generate contents
let djot_input = std::fs::read_to_string("content/arcaea.dj").unwrap();
let mut out = String::new();
let page_template = template!("templates/page.html")?;
let mut page_renderer = TemplateRenderer::new(&page_template);
while let Some(label) = page_renderer.next(&mut out)? {
if label == "content" {
let events = jotdown::Parser::new(&djot_input);
let html = Renderer::new()?;
html.push(events, &mut out)?;
} else {
break;
}
}
page_renderer.finish(&mut out)?;
// }}}
let posts_dir = dist_path.join("posts");
std::fs::create_dir(&posts_dir).with_context(|| "Cannot create `dist/posts` directory")?;
std::fs::write(posts_dir.join("arcaea.html"), out)
.with_context(|| "Failed to write `arcaea.html` post")?;
Ok(())
} }

157
src/template.rs Normal file
View file

@ -0,0 +1,157 @@
use std::fmt::Write;
use anyhow::bail;
// {{{ Templates & stops
#[derive(Clone, Debug)]
struct Stop {
label: String,
start: usize,
length: usize,
}
#[derive(Clone, Debug)]
pub struct Template {
text: String,
stops: Vec<Stop>,
}
impl Template {
// {{{ Parse template
#[allow(clippy::iter_nth_zero)]
pub fn parse(text: String) -> anyhow::Result<Template> {
let mut stops = Vec::new();
let open_stop = "{{";
let close_stop = "}}";
let mut current_stop: Option<Stop> = None;
let mut prev_ix = None;
for (ix, c) in text.char_indices() {
if let Some(prev) = prev_ix {
// This char, together with the previous one
let last_two = &text[prev..=ix];
if close_stop == last_two {
if let Some(mut stop) = current_stop.take() {
stop.label.pop().unwrap();
// I think this is safe, as } is ascii
stop.length = ix + 1 - stop.start;
stops.push(stop);
}
} else if open_stop == last_two && current_stop.is_none() {
current_stop = Some(Stop {
label: String::new(),
start: prev,
length: 0,
});
} else if let Some(stop) = current_stop.as_mut() {
stop.label.write_char(c)?;
}
}
prev_ix = Some(ix);
}
Ok(Template { text, stops })
}
// }}}
}
// }}}
// {{{ Template rendering
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
enum RendererState {
Started,
InStop(usize),
Finished,
}
#[derive(Clone, Debug)]
pub struct TemplateRenderer<'a> {
template: &'a Template,
state: RendererState,
}
impl<'a> TemplateRenderer<'a> {
#[inline]
pub fn new(template: &'a Template) -> Self {
Self {
template,
state: RendererState::Started,
}
}
/// Get the current placeholder label
pub fn current(&mut self, w: impl std::fmt::Write) -> anyhow::Result<Option<&'a str>> {
let current_label = match self.state {
RendererState::Started => self.next(w)?,
RendererState::InStop(ix) => Some(self.template.stops[ix].label.as_str()),
RendererState::Finished => None,
};
Ok(current_label)
}
/// Attempt to finish rendering.
pub fn finish(mut self, w: impl std::fmt::Write) -> anyhow::Result<()> {
if let Some(label) = self.next(w)? {
bail!("Attempting to finish template rendering before label `{label}` was handled");
}
Ok(())
}
// {{{ Advance to the next placeholder
/// Move onto the next placeholder
pub fn next(&mut self, mut w: impl std::fmt::Write) -> anyhow::Result<Option<&'a str>> {
if self.state == RendererState::Finished {
return Ok(None);
}
let (_, current_pos) = self.current_stop_range();
let next_stop_ix = match self.state {
RendererState::Started => 0,
RendererState::InStop(stop_ix) => stop_ix + 1,
RendererState::Finished => unreachable!(),
};
self.state = if next_stop_ix < self.template.stops.len() {
RendererState::InStop(next_stop_ix)
} else {
RendererState::Finished
};
let (next_pos, _) = self.current_stop_range();
w.write_str(&self.template.text[current_pos..next_pos])?;
let current_label = match self.state {
RendererState::InStop(ix) => Some(self.template.stops[ix].label.as_str()),
_ => None,
};
Ok(current_label)
}
fn current_stop_range(&self) -> (usize, usize) {
match self.state {
RendererState::Started => (0, 0),
RendererState::InStop(stop_ix) => {
let stop = &self.template.stops[stop_ix];
(stop.start, stop.start + stop.length)
}
RendererState::Finished => (self.template.text.len(), self.template.text.len()),
}
}
// }}}
}
// }}}
// {{{ Macro
#[macro_export]
macro_rules! template {
($path:literal) => {{
static TEMPLATE_TEXT: &str = include_str!($path);
$crate::template::Template::parse(TEMPLATE_TEXT.to_owned())
}};
}
// }}}

11
src/templates/aside.html Normal file
View file

@ -0,0 +1,11 @@
<details class="aside">
<summary class="aside-summary">
<img
class="aside-icon"
alt="{{character}}"
src="/assets/icons/{{character}}.webp"
/>
<span class="aside-title">{{title}}</span>
</summary>
<div class="aside-content">{{content}}</div>
</details>

View file

@ -9,7 +9,7 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<title>Moonythm</title> <title>Moonythm</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="/styles.css" />
<!-- MathML --> <!-- MathML -->
<link <link
@ -25,6 +25,6 @@
/> />
</head> </head>
<body> <body>
$CONTENT <div id="page-content">{{content}}</div>
</body> </body>
</html> </html>