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
## 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
$$`\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"}
:::: in-depth
::: in-depth-header
![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.
{title="Lagrange's extras: ζ-scoring" character="lagrange"}
::: 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](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:
@ -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.
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

View file

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

View file

@ -2,82 +2,99 @@
use std::collections::HashMap;
use anyhow::anyhow;
use jotdown::Alignment;
use jotdown::Container;
use jotdown::Event;
use jotdown::LinkType;
use jotdown::ListKind;
use jotdown::OrderedListNumbering::*;
use jotdown::Render;
use jotdown::RenderRef;
use jotdown::SpanLinkType;
use crate::template;
use crate::template::Template;
use crate::template::TemplateRenderer;
// {{{ Renderer
/// 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
I: Iterator<Item = Event<'s>>,
{
let mut s = String::new();
Renderer::default().push(events, &mut s).unwrap();
s
Renderer::new()?.push(events, &mut s)?;
Ok(s)
}
/// [`Render`] implementor that writes HTML output.
#[derive(Clone, Default)]
pub struct Renderer {}
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)
}
#[derive(Clone, Debug)]
pub struct Renderer {
templates: BuiltinTempaltes,
}
impl RenderRef for Renderer {
fn push_ref<'s, E, I, W>(&self, mut events: I, mut out: W) -> std::fmt::Result
where
E: AsRef<Event<'s>>,
I: Iterator<Item = E>,
W: std::fmt::Write,
{
let mut w = Writer::new();
events.try_for_each(|e| w.render_event(e.as_ref(), &mut out))?;
w.render_epilogue(&mut out)
impl Renderer {
pub fn new() -> anyhow::Result<Self> {
Ok(Self {
templates: BuiltinTempaltes::new()?,
})
}
pub fn push<'s>(
&self,
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> {
list_tightness: Vec<bool>,
states: Vec<State>,
states: Vec<State<'s>>,
footnotes: Footnotes<'s>,
renderer: &'s Renderer,
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
enum State {
#[derive(Debug, Clone)]
enum State<'s> {
TextOnly,
Ignore,
Raw,
Math(bool),
Aside(TemplateRenderer<'s>),
}
impl<'s> Writer<'s> {
fn new() -> Self {
fn new(renderer: &'s Renderer) -> Self {
Self {
list_tightness: Vec::new(),
states: Vec::new(),
footnotes: Footnotes::default(),
renderer,
}
}
#[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
W: std::fmt::Write,
{
@ -101,7 +118,7 @@ impl<'s> Writer<'s> {
return Ok(());
}
Event::End(Container::LinkDefinition { .. }) => {
assert_eq!(self.states.last(), Some(&State::Ignore));
assert!(matches!(self.states.last(), Some(State::Ignore)));
self.states.pop();
}
@ -116,9 +133,9 @@ impl<'s> Writer<'s> {
}
Event::End(Container::RawBlock { format } | Container::RawInline { format }) => {
if format == &"html" {
assert_eq!(self.states.last(), Some(&State::Raw));
assert!(matches!(self.states.last(), Some(State::Raw)));
} else {
assert_eq!(self.states.last(), Some(&State::Ignore));
assert!(matches!(self.states.last(), Some(State::Ignore)));
};
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(());
}
match e {
// {{{ Container start
Event::Start(c, attrs) => {
if self.states.last() == Some(&State::TextOnly) {
if matches!(self.states.last(), Some(&State::TextOnly)) {
return Ok(());
}
@ -200,6 +217,35 @@ impl<'s> Writer<'s> {
Container::Table => out.write_str("<table")?,
Container::TableRow { .. } => out.write_str("<tr")?,
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::Heading { level, .. } => write!(out, "<h{}", level)?,
Container::TableCell { head: false, .. } => out.write_str("<td")?,
@ -220,85 +266,93 @@ impl<'s> Writer<'s> {
Container::LinkDefinition { .. } => return Ok(()),
}
// {{{ Write attributes
let mut id_written = false;
let mut class_written = false;
let mut write_attribs = true;
if matches!(c, Container::Div { class: "aside" }) {
write_attribs = false;
}
for (a, v) in attrs.unique_pairs() {
write!(out, r#" {}=""#, a)?;
v.parts().try_for_each(|part| write_attr(part, &mut out))?;
match a {
"class" => {
class_written = true;
write_class(c, true, &mut out)?;
if write_attribs {
// {{{ Write attributes
let mut id_written = false;
let mut class_written = false;
if write_attribs {
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 {
id,
has_section: false,
..
}
| Container::Section { id } = &c
{
if !id_written {
out.write_str(r#" id=""#)?;
write_attr(id, &mut out)?;
// }}}
// {{{ Write default ids/classes
if let Container::Heading {
id,
has_section: false,
..
}
| Container::Section { id } = &c
{
if !id_written {
out.write_str(r#" id=""#)?;
write_attr(id, &mut out)?;
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('"')?;
}
// 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 {
// {{{ Write css for aligning table cell text
Container::TableCell { alignment, .. }
if !matches!(alignment, Alignment::Unspecified) =>
{
let a = match alignment {
Alignment::Unspecified => unreachable!(),
Alignment::Left => "left",
Alignment::Center => "center",
Alignment::Right => "right",
};
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#"">"#)?;
match c {
// {{{ Write css for aligning table cell text
Container::TableCell { alignment, .. }
if !matches!(alignment, Alignment::Unspecified) =>
{
let a = match alignment {
Alignment::Unspecified => unreachable!(),
Alignment::Left => "left",
Alignment::Center => "center",
Alignment::Right => "right",
};
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#"">"#)?;
}
}
// }}}
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 {
@ -313,13 +367,13 @@ impl<'s> Writer<'s> {
Event::End(c) => {
match &c {
Container::Image(..) => {
assert_eq!(self.states.last(), Some(&State::TextOnly));
assert!(matches!(self.states.last(), Some(State::TextOnly)));
self.states.pop();
}
_ => {}
}
if self.states.last() == Some(&State::TextOnly) {
if matches!(self.states.last(), Some(State::TextOnly)) {
return Ok(());
}
@ -374,6 +428,15 @@ impl<'s> Writer<'s> {
Container::Table => out.write_str("</table>")?,
Container::TableRow { .. } => out.write_str("</tr>")?,
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::Heading { level, .. } => write!(out, "</h{}>", level)?,
Container::TableCell { head: false, .. } => out.write_str("</td>")?,
@ -428,7 +491,7 @@ impl<'s> Writer<'s> {
// {{{ Footnote reference
Event::FootnoteReference(label) => {
let number = self.footnotes.reference(label);
if self.states.last() != Some(&State::TextOnly) {
if !matches!(self.states.last(), Some(State::TextOnly)) {
write!(
out,
r##"<a id="fnref{}" href="#fn{}" role="doc-noteref"><sup>{}</sup></a>"##,
@ -468,10 +531,7 @@ impl<'s> Writer<'s> {
}
// {{{ Render epilogue
fn render_epilogue<W>(&mut self, mut out: W) -> std::fmt::Result
where
W: std::fmt::Write,
{
fn render_epilogue(&mut self, mut out: impl std::fmt::Write) -> anyhow::Result<()> {
if self.footnotes.reference_encountered() {
out.write_str("<section role=\"doc-endnotes\">")?;
out.write_str("<hr>")?;
@ -545,24 +605,21 @@ where
Ok(())
}
fn write_text<W>(s: &str, out: W) -> std::fmt::Result
where
W: std::fmt::Write,
{
#[inline]
fn write_text(s: &str, out: impl std::fmt::Write) -> std::fmt::Result {
write_escape(s, false, out)
}
fn write_attr<W>(s: &str, out: W) -> std::fmt::Result
where
W: std::fmt::Write,
{
#[inline]
fn write_attr(s: &str, out: impl std::fmt::Write) -> std::fmt::Result {
write_escape(s, true, out)
}
fn write_escape<W>(mut s: &str, escape_quotes: bool, mut out: W) -> std::fmt::Result
where
W: std::fmt::Write,
{
fn write_escape(
mut s: &str,
escape_quotes: bool,
mut out: impl std::fmt::Write,
) -> std::fmt::Result {
let mut ent = "";
while let Some(i) = s.find(|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 template;
mod tex;
fn main() {
let djot_input = std::fs::read_to_string("content/arcaea.dj").unwrap();
let events = jotdown::Parser::new(&djot_input);
let html = crate::html::render_to_string(events);
println!("{html}");
fn copy_recursively(from: &Path, to: &Path) -> anyhow::Result<()> {
Command::new("cp").arg("-r").arg(from).arg(to).output()?;
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" />
<title>Moonythm</title>
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="/styles.css" />
<!-- MathML -->
<link
@ -25,6 +25,6 @@
/>
</head>
<body>
$CONTENT
<div id="page-content">{{content}}</div>
</body>
</html>