Footnotes/sidenotes

This commit is contained in:
Shadowfacts 2025-01-11 16:53:27 -05:00
parent 1279a1755d
commit 494f2ad367
6 changed files with 440 additions and 204 deletions

View File

@ -1,25 +1,40 @@
@import "normalize.scss"; @import "normalize.scss";
@import "fonts.scss"; @import "fonts.scss";
$page-horizontal-margin: 2rem;
$mobile-breakpoint: 480px;
$container-max-width: 768px;
:root { :root {
--background-color: #f8e7cf; --background-color: #f8e7cf;
--text-color: black; --text-color: black;
--secondary-text-color: #656565;
--link-color: blue; --link-color: blue;
--page-vertical-margin: 3rem; --page-vertical-margin: 3rem;
--page-horizontal-margin: 2rem;
} }
.container { .container {
max-width: 768px; max-width: $container-max-width;
margin: 0 $page-horizontal-margin / 2;
}
@media (min-width: calc($container-max-width + 2 * $page-horizontal-margin)) {
.container {
margin: 0 $page-horizontal-margin;
}
} }
a, a,
a:visited { a:visited {
color: var(--link-color); color: var(--link-color);
text-decoration-thickness: 0.05em; text-decoration-thickness: 2px;
text-underline-offset: 2px;
/* color: white;
text-decoration: none;
background-color: black;
padding: 0 4px; */
} }
a:hover { a:hover {
text-decoration-thickness: 0.1em; text-decoration-thickness: 3px;
} }
a[href^="http://"]::after, a[href^="http://"]::after,
a[href^="https://"]::after a[href^="https://"]::after
@ -37,9 +52,23 @@ a[href^="https://"]::after
pre, pre,
code { code {
font-family: "Berkeley Mono"; font-family: "Berkeley Mono";
font-size: 0.9em;
} }
body { blockquote {
position: relative;
font-style: italic;
&::before {
content: open-quote;
font-size: 2em;
position: absolute;
left: -25px;
top: -10px;
}
}
html {
font-family: "Valkyrie A", Charter, serif; font-family: "Valkyrie A", Charter, serif;
font-size: 16px; font-size: 16px;
/* background-color: #dfd3c3; */ /* background-color: #dfd3c3; */
@ -51,24 +80,43 @@ body {
header { header {
margin-top: var(--page-vertical-margin); margin-top: var(--page-vertical-margin);
font-style: italic; font-style: italic;
h1 { h1 {
font-size: 5rem; font-size: 5rem;
margin: 0; margin: 0;
text-underline-offset: 0.5rem;
a { a {
color: var(--text-color) !important; color: var(--text-color) !important;
transition: text-decoration-thickness 0.1s ease-in-out; transition: text-decoration-thickness 0.1s ease-in-out;
text-decoration-thickness: 4px;
text-underline-offset: 0.5rem;
&:hover {
text-decoration-thickness: 8px;
}
} }
} }
p { p {
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: lighter; font-weight: lighter;
text-wrap: balance;
} }
} }
@media (max-width: $mobile-breakpoint) {
header h1 {
font-size: 4rem;
}
}
.article-title {
// balance the number of words per line
text-wrap: balance;
}
.article-content { .article-content {
font-size: 1.25rem; font-size: 1.25rem;
// Chrome only, but minimizes orphan words
text-wrap: pretty;
} }
.header-anchor { .header-anchor {
@ -77,14 +125,84 @@ header {
vertical-align: middle; vertical-align: middle;
// drag it up so it's more in the middle // drag it up so it's more in the middle
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
color: gray !important; color: var(--secondary-text-color) !important;
background: none;
&:hover { &:hover {
color: var(--link-color) !important; color: var(--link-color) !important;
} }
} }
.footnote-reference > a { .footnote-reference {
text-decoration: none; > a {
text-decoration: none;
&[href^="#fnref-"] {
display: none;
}
}
&:hover + .sidenote {
color: black;
}
}
.sidenote {
float: right;
margin-right: -50%;
width: 40%;
font-size: 1rem;
line-height: 1.25;
color: var(--secondary-text-color);
transition: color 0.2s ease-in-out;
display: none;
.sidenote-p {
margin: 20px 0;
display: block;
}
}
.footnote {
display: flex;
flex-direction: row;
gap: 8px;
font-size: 1rem;
.footnote-marker {
flex-shrink: 0;
width: 34px;
text-align: right;
}
.footnote-backref {
text-decoration: none;
}
> div > p:first-child {
margin-top: 0;
}
> div > p:last-child {
margin-top: 0;
}
}
// 1.5 to account for -50% margin-right on .sidenote
// plus page-horizontal-margin to effectively use that as the margin
@media (min-width: calc(1.5 * $container-max-width + 2 * $page-horizontal-margin)) {
.footnote-reference {
> a[href^="#fn-"] {
display: none;
}
> a[href^="#fnref-"] {
display: inline;
}
}
.sidenote {
display: block;
}
.footnote {
display: none;
}
} }
footer { footer {
@ -97,9 +215,8 @@ footer {
} }
} }
@media (min-width: calc(768px + 2rem)) { @media (max-width: $mobile-breakpoint) {
.container { footer {
margin-left: var(--page-horizontal-margin); font-size: 1rem;
margin-right: var(--page-horizontal-margin);
} }
} }

View File

@ -52,7 +52,7 @@
<li><a href="/archive/">Archive</a></li> <li><a href="/archive/">Archive</a></li>
<li><a href="/elsewhere/">Contact</a></li> <li><a href="/elsewhere/">Contact</a></li>
<!-- TODO: webring --> <!-- TODO: webring -->
<li>Generated on {{ _generated_at | pretty_date }} by <a href="https://git.shadowfacts.net/shadowfacts/v7">v7</a>.</li> <li>Generated on {{ _generated_at | pretty_date }}, by <a href="https://git.shadowfacts.net/shadowfacts/v7">v7</a>.</li>
</ul> </ul>
</div> </div>
</footer> </footer>

View File

@ -1,7 +1,6 @@
mod footnote_backrefs;
mod footnote_defs;
mod heading_anchors; mod heading_anchors;
mod highlight; mod highlight;
mod sidenotes;
use pulldown_cmark::{Event, Options, Parser, html}; use pulldown_cmark::{Event, Options, Parser, html};
use std::io::Write; use std::io::Write;
@ -19,11 +18,8 @@ pub fn parse<'a>(s: &'a str) -> impl Iterator<Item = Event<'a>> {
let parser = Parser::new_ext(s, options); let parser = Parser::new_ext(s, options);
// TODO: revisit which of these stages are necessary, remove unused (and url crate dep) // TODO: revisit which of these stages are necessary, remove unused (and url crate dep)
let heading_anchors = heading_anchors::new(parser); let heading_anchors = heading_anchors::new(parser);
// note backrefs need to come before defs, because the defs stage replaces the let sidenotes = sidenotes::new(heading_anchors);
// Tag::FootnoteDefinition events that the backrefs stage relies on with plain html let highlight = highlight::new(sidenotes);
let footnote_backrefs = footnote_backrefs::new(heading_anchors);
let footnote_defs = footnote_defs::new(footnote_backrefs);
let highlight = highlight::new(footnote_defs);
highlight highlight
} }

View File

@ -1,83 +0,0 @@
use pulldown_cmark::{CowStr, Event, Tag, TagEnd};
use std::iter::Peekable;
pub struct FootnoteBackrefs<'a, I: Iterator<Item = Event<'a>>> {
iter: Peekable<I>,
next: Option<Event<'a>>,
last_footnote_definition_start: Option<CowStr<'a>>,
}
pub fn new<'a, I: Iterator<Item = Event<'a>>>(iter: I) -> FootnoteBackrefs<'a, I> {
FootnoteBackrefs {
iter: iter.peekable(),
next: None,
last_footnote_definition_start: None,
}
}
impl<'a, I: Iterator<Item = Event<'a>>> Iterator for FootnoteBackrefs<'a, I> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(e) = self.next.take() {
return Some(e);
}
match self.iter.next() {
Some(Event::FootnoteReference(label)) => {
let html = format!(
r##"<sup class="footnote-reference" id="fnref{}"><a href="#{}">[{}]</a></sup>"##,
label, label, label
);
Some(Event::Html(CowStr::Boxed(html.into_boxed_str())))
}
Some(Event::Start(Tag::FootnoteDefinition(label))) => {
self.last_footnote_definition_start = Some(label.clone());
Some(Event::Start(Tag::FootnoteDefinition(label)))
}
Some(Event::End(TagEnd::Paragraph)) => {
if let Some(Event::End(TagEnd::FootnoteDefinition)) = self.iter.peek() {
assert!(self.next.is_none());
self.next = Some(Event::End(TagEnd::Paragraph));
let label = self
.last_footnote_definition_start
.take()
.expect("footnote definition must have started before ending");
let html = format!(
r##" <a href="#fnref{}" class="footnote-backref">↩</a>"##,
label
);
Some(Event::Html(CowStr::Boxed(html.into_boxed_str())))
} else {
Some(Event::End(TagEnd::Paragraph))
}
}
e => e,
}
}
}
#[cfg(test)]
mod tests {
use pulldown_cmark::{Options, Parser, html};
fn render(s: &str) -> String {
let mut out = String::new();
let parser = Parser::new_ext(s, Options::ENABLE_FOOTNOTES);
let footnote_backrefs = super::new(parser);
html::push_html(&mut out, footnote_backrefs);
out
}
#[test]
fn test_footnote_backrefs() {
assert_eq!(
render("foo[^1]\n\n[^1]: bar"),
r##"<p>foo<sup class="footnote-reference" id="fnref1"><a href="#1">[1]</a></sup></p>
<div class="footnote-definition" id="1"><sup class="footnote-definition-label">1</sup>
<p>bar <a href="#fnref1" class="footnote-backref"></a></p>
</div>
"##
);
}
}

View File

@ -1,100 +0,0 @@
use pulldown_cmark::{CowStr, Event, Tag, TagEnd};
use std::collections::VecDeque;
pub fn new<'a, I: Iterator<Item = Event<'a>>>(iter: I) -> FootnoteDefs<'a, I> {
FootnoteDefs {
iter,
footnote_events: VecDeque::new(),
has_started_emitting_defs: false,
}
}
pub struct FootnoteDefs<'a, I: Iterator<Item = Event<'a>>> {
iter: I,
footnote_events: VecDeque<Event<'a>>,
has_started_emitting_defs: bool,
}
impl<'a, I: Iterator<Item = Event<'a>>> Iterator for FootnoteDefs<'a, I> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Self::Item> {
match self.iter.next() {
Some(Event::Start(Tag::FootnoteDefinition(ref id))) => {
self.footnote_events.push_back(Event::Html(
format!(
r#"<div id="{}" class="footnote-item"><span class="footnote-marker">{}.</span>"#,
id, id
)
.into(),
));
loop {
match self.iter.next() {
Some(Event::End(TagEnd::FootnoteDefinition)) => {
self.footnote_events.push_back(Event::Html("</div>".into()));
break;
}
Some(e) => {
self.footnote_events.push_back(e);
}
None => {
break;
}
}
}
self.next()
}
Some(e) => Some(e),
None => {
if !self.has_started_emitting_defs && !self.footnote_events.is_empty() {
self.has_started_emitting_defs = true;
let before: CowStr<'a> =
r#"<hr class="footnotes-sep"><section class="footnotes">"#.into();
self.footnote_events.push_front(Event::Html(before));
let after: CowStr<'a> = "</section>".into();
self.footnote_events.push_back(Event::Html(after));
}
self.footnote_events.pop_front()
}
}
}
}
#[cfg(test)]
mod tests {
use pulldown_cmark::{Options, Parser, html};
fn render(s: &str) -> String {
let mut out = String::new();
let parser = Parser::new_ext(s, Options::ENABLE_FOOTNOTES);
let footnote_backrefs = super::new(parser);
html::push_html(&mut out, footnote_backrefs);
out
}
#[test]
fn test_group_footnotes() {
assert_eq!(
render("foo[^1]\n\n[^1]: bar\n\nbaz"),
r##"<p>foo<sup class="footnote-reference"><a href="#1">1</a></sup></p>
<p>baz</p>
<hr class="footnotes-sep"><section class="footnotes"><div id="1" class="footnote-item"><span class="footnote-marker">1.</span>
<p>bar</p>
</div></section>"##
);
}
#[test]
fn test_multiple() {
assert_eq!(
render("foo[^1] bar[^2]\n\n[^1]: foo\n\n[^2]: bar"),
r##"<p>foo<sup class="footnote-reference"><a href="#1">1</a></sup> bar<sup class="footnote-reference"><a href="#2">2</a></sup></p>
<hr class="footnotes-sep"><section class="footnotes"><div id="1" class="footnote-item"><span class="footnote-marker">1.</span>
<p>foo</p>
</div><div id="2" class="footnote-item"><span class="footnote-marker">2.</span>
<p>bar</p>
</div></section>"##
);
}
}

View File

@ -0,0 +1,306 @@
use std::collections::{HashMap, HashSet, VecDeque};
use pulldown_cmark::{CowStr, Event, Tag, TagEnd};
pub fn new<'a>(iter: impl Iterator<Item = Event<'a>>) -> impl Iterator<Item = Event<'a>> {
let adjoined = AdjoinFootnoteDefinitions::new(iter);
let containers = InsertSidenoteContainers::new(adjoined);
// InsertFootnoteDefnMarkers::new(containers)
containers
}
struct AdjoinFootnoteDefinitions<'a> {
state: AdjoinState<'a>,
all_events: std::vec::IntoIter<Event<'a>>,
definition_order: VecDeque<CowStr<'a>>,
definitions: HashMap<CowStr<'a>, Vec<Event<'a>>>,
}
enum AdjoinState<'a> {
Idle,
EmittingDefinition(std::vec::IntoIter<Event<'a>>),
}
impl<'a> AdjoinFootnoteDefinitions<'a> {
fn new(mut inner: impl Iterator<Item = Event<'a>>) -> Self {
let mut all_events = vec![];
let mut definition_order = VecDeque::new();
let mut definitions = HashMap::new();
while let Some(event) = inner.next() {
match event {
Event::Start(Tag::FootnoteDefinition(id)) => {
definition_order.push_back(id.clone());
let mut defn_events = vec![Event::Start(Tag::FootnoteDefinition(id.clone()))];
'defn: while let Some(event) = inner.next() {
match event {
Event::End(TagEnd::FootnoteDefinition) => {
defn_events.push(Event::End(TagEnd::FootnoteDefinition));
break 'defn;
}
_ => defn_events.push(event),
}
}
definitions.insert(id, defn_events);
}
_ => all_events.push(event),
}
}
Self {
state: AdjoinState::Idle,
all_events: all_events.into_iter(),
definition_order,
definitions,
}
}
}
impl<'a> Iterator for AdjoinFootnoteDefinitions<'a> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Self::Item> {
match &mut self.state {
AdjoinState::Idle => match self.all_events.next() {
Some(Event::FootnoteReference(id)) => {
if let Some(defn) = self.definitions.get(&id) {
self.state = AdjoinState::EmittingDefinition(defn.clone().into_iter());
}
Some(Event::FootnoteReference(id))
}
Some(e) => Some(e),
None => match self.definition_order.pop_front() {
Some(id) => {
if let Some(defn) = self.definitions.get(&id) {
self.state = AdjoinState::EmittingDefinition(defn.clone().into_iter());
}
self.next()
}
None => None,
},
},
AdjoinState::EmittingDefinition(defn) => match defn.next() {
Some(e) => Some(e),
None => {
self.state = AdjoinState::Idle;
self.next()
}
},
}
}
}
struct InsertSidenoteContainers<'a, I: Iterator<Item = Event<'a>>> {
inner: I,
state: ReplaceState<'a>,
seen_references: HashSet<CowStr<'a>>,
}
#[derive(Clone)]
enum ReplaceState<'a> {
WaitingForReference,
SawReference(CowStr<'a>),
InsideSidenote,
InsideFootnote(CowStr<'a>),
EmitNext(Event<'a>, Box<ReplaceState<'a>>),
}
impl<'a, I: Iterator<Item = Event<'a>>> InsertSidenoteContainers<'a, I> {
fn new(inner: I) -> Self {
Self {
inner,
state: ReplaceState::WaitingForReference,
seen_references: HashSet::new(),
}
}
}
impl<'a, I: Iterator<Item = Event<'a>>> Iterator for InsertSidenoteContainers<'a, I> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Self::Item> {
use ReplaceState::*;
match self.state {
WaitingForReference => match self.inner.next() {
Some(Event::FootnoteReference(id)) => {
assert!(!self.seen_references.contains(&id));
self.state = SawReference(id.clone());
Some(Event::Html(format!(r##"<sup id="fnref-{id}" class="footnote-reference"><a href="#fn-{id}">[{id}]</a><a href="#fnref-{id}">[{id}]</a></sup>"##).into()))
}
Some(Event::Start(Tag::FootnoteDefinition(id))) => {
self.state = InsideFootnote(id.clone());
Some(Event::Html(
format!(
r#"<div id="fn-{id}" class="footnote"><span class="footnote-marker">{id}. </span><div>"#,
)
.into(),
))
}
maybe_event => maybe_event,
},
SawReference(ref id) => match self.inner.next() {
Some(Event::Start(Tag::FootnoteDefinition(def_id))) => {
assert_eq!(id, &def_id);
self.seen_references.insert(def_id.clone());
self.state = InsideSidenote;
Some(Event::Html(format!(
r#"<span class="sidenote"><span class="footnote-marker">{def_id}. </span>"#,
).into()))
}
e => panic!("should not receive {e:?} while in SawReference state"),
},
InsideSidenote => match self.inner.next() {
// We drop <p> in sidenote defn because, since the sidenote defn appears inside of a normal paragraph,
// the sidenote defn <p> would implicitly close the containing <p> and screw up the hierarchy.
Some(Event::Start(Tag::Paragraph)) => Some(Event::Html(CowStr::Borrowed(""))),
Some(Event::End(TagEnd::Paragraph)) => Some(Event::Html(CowStr::Borrowed(""))),
Some(Event::End(TagEnd::FootnoteDefinition)) => {
self.state = WaitingForReference;
Some(Event::Html(CowStr::Borrowed("</span>")))
}
e => e,
},
InsideFootnote(ref id) => match self.inner.next() {
Some(Event::End(TagEnd::Paragraph)) => {
// unicode variation selector to make the back arrow always render as text (not emoji)
let text_selector = "\u{FE0E}";
let event = Event::Html(
format!(
r##" <a href="#fnref-{id}" class="footnote-backref">↩{text_selector}</a>"##
)
.into(),
);
let close_divs = EmitNext(
Event::Html(CowStr::Borrowed("</div></div>")),
Box::new(InsideFootnote(id.clone())),
);
let end_para = EmitNext(Event::End(TagEnd::Paragraph), Box::new(close_divs));
self.state = end_para;
Some(event)
}
Some(Event::End(TagEnd::FootnoteDefinition)) => {
self.state = WaitingForReference;
self.next()
}
maybe_event => maybe_event,
},
EmitNext(ref event, ref next_state) => {
let event = event.clone();
self.state = next_state.as_ref().clone();
Some(event)
}
}
}
}
#[cfg(test)]
mod tests {
use pulldown_cmark::{CowStr, Event, Event::*, Options, Parser, Tag, TagEnd};
fn render_adjoined(s: &'static str) -> Vec<Event<'static>> {
let mut options = Options::empty();
options.insert(Options::ENABLE_FOOTNOTES);
super::AdjoinFootnoteDefinitions::new(Parser::new_ext(s, options)).collect()
}
fn render_sidenote_containers(s: &'static str) -> Vec<Event<'static>> {
let mut options = Options::empty();
options.insert(Options::ENABLE_FOOTNOTES);
super::InsertSidenoteContainers::new(super::AdjoinFootnoteDefinitions::new(
Parser::new_ext(s, options),
))
.collect()
}
fn render(s: &'static str) -> Vec<Event<'static>> {
let mut options = Options::empty();
options.insert(Options::ENABLE_FOOTNOTES);
super::new(Parser::new_ext(s, options)).collect()
}
#[test]
fn adjoin_simple() {
assert_eq!(render_adjoined("[^1]\n\n[^1]: defn\n\nbar"), vec![
Start(Tag::Paragraph),
FootnoteReference(CowStr::Borrowed("1")),
Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("defn")),
End(TagEnd::Paragraph),
End(TagEnd::FootnoteDefinition),
End(TagEnd::Paragraph),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("bar")),
End(TagEnd::Paragraph),
Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("defn")),
End(TagEnd::Paragraph),
End(TagEnd::FootnoteDefinition)
])
}
#[test]
fn adjoin_out_of_order() {
assert_eq!(
render_adjoined("[^1]\n\n[^2]\n\n[^2]: baz\n\n[^1]: defn\n\nbar"),
vec![
Start(Tag::Paragraph),
FootnoteReference(CowStr::Borrowed("1")),
Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("defn")),
End(TagEnd::Paragraph),
End(TagEnd::FootnoteDefinition),
End(TagEnd::Paragraph),
Start(Tag::Paragraph),
FootnoteReference(CowStr::Borrowed("2")),
Start(Tag::FootnoteDefinition(CowStr::Borrowed("2"))),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("baz")),
End(TagEnd::Paragraph),
End(TagEnd::FootnoteDefinition),
End(TagEnd::Paragraph),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("bar")),
End(TagEnd::Paragraph),
Start(Tag::FootnoteDefinition(CowStr::Borrowed("2"))),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("baz")),
End(TagEnd::Paragraph),
End(TagEnd::FootnoteDefinition),
Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))),
Start(Tag::Paragraph),
Text(CowStr::Borrowed("defn")),
End(TagEnd::Paragraph),
End(TagEnd::FootnoteDefinition),
]
)
}
#[test]
fn sidenote_markers() {
assert_eq!(render("look[^1]\n\n[^1]: blah\n\nweee"), vec![
Start(Tag::Paragraph),
Text("look".into()),
Html(r##"<sup id="fnref-1" class="footnote-reference"><a href="#fn-1">[1]</a><a href="#fnref-1">[1]</a></sup>"##.into()),
Html(r#"<span class="sidenote"><span class="footnote-marker">1. </span>"#.into()),
Html("".into()),
Text("blah".into()),
Html("".into()),
Html("</span>".into()),
End(TagEnd::Paragraph),
Start(Tag::Paragraph),
Text("weee".into()),
End(TagEnd::Paragraph),
Html(
r#"<div id="fn-1" class="footnote"><span class="footnote-marker">1. </span><div>"#
.into()
),
Start(Tag::Paragraph),
Text("blah".into()),
Html(r##" <a href="#fnref-1" class="footnote-backref">↩</a>"##.into()),
End(TagEnd::Paragraph),
Html("</div></div>".into()),
])
}
}