Skip to content

Commit 88baf75

Browse files
committed
nest headings in outline
1 parent 7ad89e2 commit 88baf75

File tree

7 files changed

+109
-89
lines changed

7 files changed

+109
-89
lines changed

content/posts/haskell-ffi/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: Haskell FFI
2+
title: Calling Rust from Haskell, and vice versa.
33
date: 2025-07-29T18:30:29.523Z
44
tags: [haskell, rust, c, ffi]
55
desc: >

content/posts/prerendering-svelte.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ interactivity" approach gives you the speed benefits of a static site with the
2323
rich user experience of a single-page application.
2424

2525

26-
### Getting Svelte to speak HTML
26+
## Getting Svelte to speak HTML
2727

2828
Before I could think about interactivity, I needed a way to simply turn a Svelte
2929
component into static HTML during my Rust project's build process. This is the
@@ -37,7 +37,7 @@ the `build.rs` script. For the actual JavaScript bundling, I reached for
3737
script orchestrate esbuild to handle the Svelte compilation in two distinct
3838
passes:
3939

40-
#### The server-side build:
40+
### The server-side build:
4141

4242
The first task was to compile `Button.svelte` into code that could run on the
4343
server (in a Node.js environment) and spit out raw HTML. A neat trick here was
@@ -72,7 +72,7 @@ const html = data.out;
7272
// ... then this HTML is saved to Button.html
7373
```
7474

75-
#### The client-side build
75+
### The client-side build
7676

7777
At the same time, a second esbuild process compiled the exact same
7878
`Button.svelte` component, but this time for the browser. This created a
@@ -118,7 +118,7 @@ But, of course, it was just an unresponsive and static button. We had our
118118
island, but it was still lifeless, waiting for that spark of hydration.
119119

120120

121-
### The hydration riddle
121+
## The hydration riddle
122122

123123
With the static HTML already rendered, the next crucial step was to infuse it
124124
with interactivity. I uncommented the commented-out `<script>` tag, and reloaded
@@ -217,7 +217,7 @@ correct. Svelte 5 introduced a top-level `hydrate` function from its main
217217
export.
218218

219219

220-
### The Astro introspection
220+
## The Astro introspection
221221

222222
When faced with a fundamental challenge, the wisest approach is often to study
223223
how other frameworks have already solved it. My investigation turned to
@@ -295,7 +295,7 @@ Svelte's internal state (like `first_child_getter`), allowing it to attach to
295295
the existing DOM without errors.
296296

297297

298-
### Deno detour
298+
## Deno detour
299299

300300
Armed with this critical new understanding, the project embarked on a slight
301301
detour: migrating the build script from Node.js to **Deno**. Deno, a modern and
@@ -377,7 +377,7 @@ issue, and it seemed too brittle anyway, so I decided to try a different
377377
approach.
378378

379379

380-
### More robust architecture
380+
## More robust architecture
381381

382382
Armed with the hard-won lessons from Astro and the battle scars from esbuild
383383
module resolution quirks, the final, elegant, and robust solution began to take
@@ -386,7 +386,7 @@ while adhering to the core architectural needs of Svelte's hydration model.
386386

387387
Here's the detailed anatomy of the final implementation:
388388

389-
#### Simplicity on the surface
389+
### Simplicity on the surface
390390

391391
I've integrated this solution into my static site generator `hauchiwa`. The
392392
complexity of the underlying build process is encapsulated behind a clean,
@@ -457,7 +457,7 @@ where
457457
}
458458
```
459459

460-
#### Rust orchestrates Deno
460+
### Rust orchestrates Deno
461461

462462
The core of the Svelte integration lies in three distinct Deno calls,
463463
meticulously orchestrated by Rust's `Command` API. This pattern allows us to
@@ -678,7 +678,7 @@ fn compile_svelte_init(file: &Utf8Path, hash_class: Hash32) -> anyhow::Result<St
678678
`JSON.parse`s this attribute.
679679

680680

681-
#### Bringing it all together
681+
### Bringing it all together
682682

683683
The `html` closure within the `Svelte<P>` struct is where the final pieces of
684684
the puzzle align. It creates the final piece of HTML, which contains prerendered
@@ -734,7 +734,7 @@ When the browser receives this HTML:
734734
interactive, without re-rendering the entire DOM.
735735

736736

737-
### Some future considerations
737+
## Some future considerations
738738

739739
The journey to a fully hydrated Svelte component in a Rust application is
740740
complete. However, the path of web development is ever-evolving. Here are some
@@ -755,7 +755,7 @@ advanced considerations and future directions for this architecture:
755755
from Rust.
756756

757757

758-
### Conclusion
758+
## Conclusion
759759

760760
My quest to integrate Svelte with a Rust backend, aiming for truly interactive
761761
"islands" on a fast, server-rendered site, led me through some significant

src/main.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ enum Mode {
3636
}
3737

3838
pub struct Bibliography(pub Option<Vec<String>>);
39-
pub struct Outline(pub Vec<(String, String)>);
4039

4140
#[derive(Debug, Clone)]
4241
struct Global {

src/markdown.rs

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use pulldown_cmark::{
1717
};
1818
use regex::Regex;
1919

20-
use crate::{Bibliography, Context, Outline, ts, typst};
20+
use crate::{Bibliography, Context, ts, typst};
2121

2222
const OPTS_MARKDOWN: OptsMarkdown = OptsMarkdown::empty()
2323
.union(OptsMarkdown::ENABLE_MATH)
@@ -125,14 +125,81 @@ pub fn parse(
125125

126126
Ok(Article {
127127
text,
128-
outline: Outline(outline),
128+
outline: outline.into(),
129129
scripts,
130130
bibliography: Bibliography(bibliography),
131131
})
132132
}
133133

134134
// StreamHeading
135135

136+
pub struct Heading(String, String, Vec<Heading>);
137+
pub struct Outline(Vec<Heading>);
138+
139+
impl hypertext::Renderable for Outline {
140+
fn render_to(&self, output: &mut String) {
141+
use hypertext::prelude::*;
142+
143+
fn render_heading_list(headings: &[Heading]) -> impl Renderable {
144+
maud!(
145+
ul {
146+
@for Heading(title, id, children) in headings {
147+
li {
148+
a href=(format!("#{id}")) { (title) }
149+
150+
@if !children.is_empty() {
151+
(render_heading_list(children))
152+
}
153+
}
154+
}
155+
}
156+
)
157+
}
158+
159+
maud!(
160+
aside .outline {
161+
section {
162+
h2 {
163+
a href="#top" { "Outline" }
164+
}
165+
nav #table-of-contents {
166+
(render_heading_list(&self.0))
167+
}
168+
}
169+
}
170+
)
171+
.render_to(output);
172+
}
173+
}
174+
175+
impl From<Vec<(String, String, usize)>> for Outline {
176+
fn from(flat_vec: Vec<(String, String, usize)>) -> Self {
177+
let mut res = Vec::new();
178+
179+
for (title, url, level) in flat_vec {
180+
let mut ptr = &mut res;
181+
let new = Heading(title, url, vec![]);
182+
183+
for _ in 2..level {
184+
if ptr.is_empty() {
185+
break;
186+
}
187+
188+
match ptr.last_mut() {
189+
Some(Heading(_, _, children)) => {
190+
ptr = children;
191+
}
192+
None => unreachable!(),
193+
}
194+
}
195+
196+
ptr.push(new);
197+
}
198+
199+
Outline(res)
200+
}
201+
}
202+
136203
struct StreamHeading<'a, I>
137204
where
138205
I: Iterator<Item = Event<'a>>,
@@ -142,22 +209,20 @@ where
142209
buffer: String,
143210
handle: Option<Tag<'a>>,
144211
events: VecDeque<Event<'a>>,
145-
finish: bool,
146-
out: &'a mut Vec<(String, String)>,
212+
out: &'a mut Vec<(String, String, usize)>,
147213
}
148214

149215
impl<'a, I> StreamHeading<'a, I>
150216
where
151217
I: Iterator<Item = Event<'a>>,
152218
{
153-
pub fn new(iter: I, out: &'a mut Vec<(String, String)>) -> Self {
219+
pub fn new(iter: I, out: &'a mut Vec<(String, String, usize)>) -> Self {
154220
Self {
155221
iter,
156222
counts: HashMap::new(),
157223
buffer: String::new(),
158224
handle: None,
159225
events: VecDeque::new(),
160-
finish: false,
161226
out,
162227
}
163228
}
@@ -170,9 +235,8 @@ where
170235
type Item = Event<'a>;
171236

172237
fn next(&mut self) -> Option<Self::Item> {
173-
match self.finish && !self.events.is_empty() {
174-
true => return self.events.pop_front(),
175-
false => self.finish = false,
238+
if self.handle.is_none() && !self.events.is_empty() {
239+
return self.events.pop_front();
176240
}
177241

178242
for event in self.iter.by_ref() {
@@ -181,37 +245,41 @@ where
181245
debug_assert!(self.handle.is_none());
182246
self.handle = Some(tag);
183247
}
184-
Event::Text(text) if self.handle.is_some() => {
185-
self.buffer.push_str(&text);
186-
self.events.push_back(Event::Text(text));
187-
}
188248
event @ Event::End(TagEnd::Heading(..)) => {
189249
debug_assert!(self.handle.is_some());
190250
self.events.push_back(event);
191251

192-
let txt = std::mem::take(&mut self.buffer);
193-
let mut url = txt.to_lowercase().replace(' ', "-");
252+
let text = std::mem::take(&mut self.buffer);
253+
let mut slug = text.to_lowercase().replace(' ', "-");
194254

195-
match self.counts.get_mut(&url) {
255+
match self.counts.get_mut(&slug) {
196256
Some(count) => {
197257
*count += 1;
198-
url = format!("{url}-{count}");
258+
slug = format!("{slug}-{count}");
199259
}
200260
None => {
201-
self.counts.insert(url.clone(), 0);
261+
self.counts.insert(slug.clone(), 0);
202262
}
203263
}
204264

205265
let mut handle = self.handle.take().unwrap();
206-
match handle {
207-
Tag::Heading { ref mut id, .. } => *id = Some(url.clone().into()),
266+
let level = match &mut handle {
267+
Tag::Heading { id, level, .. } => {
268+
*id = Some(slug.clone().into());
269+
*level as usize
270+
}
208271
_ => unreachable!(),
209-
}
272+
};
210273

211-
self.out.push((txt, url.clone()));
212-
self.finish = true;
274+
self.out.push((text, slug.clone(), level));
213275
return Some(Event::Start(handle));
214276
}
277+
event if self.handle.is_some() => {
278+
if let Event::Text(text) = &event {
279+
self.buffer.push_str(text);
280+
}
281+
self.events.push_back(event);
282+
}
215283
_ => return Some(event),
216284
}
217285
}

src/plugin/about.rs

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use sequoia_openpgp::parse::Parse;
66

77
use crate::markdown::Article;
88
use crate::model::{Post, Pubkey};
9-
use crate::{CONTENT, Context, Global, Outline};
9+
use crate::{CONTENT, Context, Global};
1010

1111
use super::make_page;
1212
use super::posts::render_metadata;
@@ -58,7 +58,7 @@ pub fn render<'ctx>(
5858
let main = maud!(
5959
main {
6060
// Outline (left)
61-
(render_outline(&article.outline))
61+
(&article.outline)
6262
// Article (center)
6363
article .article {
6464
section .paper {
@@ -101,26 +101,3 @@ pub fn render<'ctx>(
101101
Default::default(),
102102
)
103103
}
104-
105-
fn render_outline(outline: &Outline) -> impl Renderable {
106-
maud!(
107-
aside .outline {
108-
section {
109-
h2 {
110-
a href="#top" { "Outline" }
111-
}
112-
nav #table-of-contents {
113-
ul {
114-
@for (title, id) in &outline.0 {
115-
li {
116-
a href=(format!("#{id}")) {
117-
(title)
118-
}
119-
}
120-
}
121-
}
122-
}
123-
}
124-
}
125-
)
126-
}

0 commit comments

Comments
 (0)