about summary refs log tree commit diff
path: root/markup/src/lineup
diff options
context:
space:
mode:
authorgennyble <gen@nyble.dev>2026-09-21 14:23:07 -0500
committergennyble <gen@nyble.dev>2026-09-21 14:23:07 -0500
commitbe872aa0cd61ea16b95eddcd68c6a72f0fe3af96 (patch)
tree8935da5f9639b939a0e5ecd2bba3072d71d602bc /markup/src/lineup
parent178ca3c066a1ef920b112da741625099b6dc07da (diff)
massive reorg
Diffstat (limited to 'markup/src/lineup')
-rw-r--r--markup/src/lineup/interlinksolver.rs17
-rw-r--r--markup/src/lineup/mod.rs742
-rw-r--r--markup/src/lineup/token.rs85
-rw-r--r--markup/src/lineup/tree.rs147
4 files changed, 991 insertions, 0 deletions
diff --git a/markup/src/lineup/interlinksolver.rs b/markup/src/lineup/interlinksolver.rs
new file mode 100644
index 0000000..074fadf
--- /dev/null
+++ b/markup/src/lineup/interlinksolver.rs
@@ -0,0 +1,17 @@
+use std::borrow::Cow;
+
+pub trait InterlinkSolver {
+	fn link_title<'n>(&self, name: &'n str) -> Cow<'n, str>;
+	fn link_location<'n>(&self, name: &'n str) -> Cow<'n, str>;
+}
+
+pub struct EchoInterlinkSolver;
+impl InterlinkSolver for EchoInterlinkSolver {
+	fn link_title<'n>(&self, name: &'n str) -> Cow<'n, str> {
+		Cow::Borrowed(name)
+	}
+
+	fn link_location<'n>(&self, name: &'n str) -> Cow<'n, str> {
+		Cow::Borrowed(name)
+	}
+}
diff --git a/markup/src/lineup/mod.rs b/markup/src/lineup/mod.rs
new file mode 100644
index 0000000..0e1ecce
--- /dev/null
+++ b/markup/src/lineup/mod.rs
@@ -0,0 +1,742 @@
+mod interlinksolver;
+mod token;
+mod tree;
+
+use std::{iter::Enumerate, rc::Rc, str::Chars};
+
+pub use interlinksolver::{EchoInterlinkSolver, InterlinkSolver};
+use token::{IndexedToken, Token};
+pub use tree::{Document, LinkKind, NodeStyle, TreeNode};
+
+pub fn process<'raw>(raw: &'raw str, solver: Rc<dyn InterlinkSolver>) -> Document<'raw> {
+	let mut r1 = round1(raw);
+	round2(raw, &mut r1);
+	let mut tree = round3(raw, &r1);
+
+	if tree.len() == 1 {
+		let node = tree.pop().unwrap();
+		if let TreeNode::Paragraph { children } = node {
+			return Document {
+				nodes: children,
+				solver,
+			};
+		} else {
+			tree.push(node);
+		}
+	}
+
+	Document {
+		nodes: tree,
+		solver,
+	}
+}
+
+//MARK: ✨ Passes ✨
+
+/// Round 1 tokenizes to `Token` but does not produce `Bold` or properly
+/// run `Token::Escape`. round2() is the next step
+fn round1<'raw>(raw: &'raw str) -> Vec<IndexedToken<'raw>> {
+	let mut chars = CollectingCharIter::from(raw);
+
+	let mut toks = vec![];
+	loop {
+		let (idx, ch) = match chars.next() {
+			Some(pair) => pair,
+			None => {
+				chars.append_slice(None, &mut toks);
+				break;
+			}
+		};
+
+		match ch {
+			'\\' => {
+				chars.append_slice(Some(idx), &mut toks);
+				toks.push(IndexedToken::new(idx, Token::Escape));
+			}
+			'`' => {
+				chars.append_slice(Some(idx), &mut toks);
+				toks.push(IndexedToken::new(idx, Token::Code));
+			}
+			'*' => {
+				chars.append_slice(Some(idx), &mut toks);
+				toks.push(IndexedToken::new(idx, Token::Italic));
+			}
+			'|' => {
+				chars.append_slice(Some(idx), &mut toks);
+				toks.push(IndexedToken::new(idx, Token::Pipe));
+			}
+			'[' => {
+				chars.append_slice(Some(idx), &mut toks);
+				toks.push(IndexedToken::new(idx, Token::LinkOpen));
+			}
+			']' => {
+				chars.append_slice(Some(idx), &mut toks);
+				toks.push(IndexedToken::new(idx, Token::LinkClose));
+			}
+			'\r' => {
+				chars.append_slice(Some(idx), &mut toks);
+				// We are ignoring carriage returns here
+			}
+			'\n' => {
+				chars.append_slice(Some(idx), &mut toks);
+				toks.push(IndexedToken::new(idx, Token::Break));
+			}
+			_ch => {
+				chars.set_raw_if_empty();
+			}
+		}
+	}
+
+	toks
+}
+
+/// Folds two subsequent `Token::Itlalic` into `Token::Bold` and correctly maps
+/// `Token::Escape`. Additionally, combines successive `Token::Text` into
+/// single slices.
+fn round2<'raw>(raw: &'raw str, toks: &mut Vec<IndexedToken<'raw>>) {
+	let mut idx = 0;
+	loop {
+		// We alawys want there to be a token after our idx
+		if idx >= toks.len() - 1 {
+			break;
+		}
+
+		let indicies: [usize; 2] = [toks[idx].idx, toks[idx + 1].idx];
+		let pattern: [Token<'raw>; 2] = [toks[idx].tok, toks[idx + 1].tok];
+
+		match pattern {
+			[Token::Italic, Token::Italic] => {
+				toks[idx] = IndexedToken {
+					idx: indicies[0],
+					tok: Token::Bold,
+				};
+				toks.remove(idx + 1);
+				continue;
+			}
+			[Token::LinkOpen, Token::LinkOpen] => {
+				toks[idx] = IndexedToken {
+					idx: indicies[0],
+					tok: Token::InterlinkOpen,
+				};
+				toks.remove(idx + 1);
+				continue;
+			}
+			[Token::LinkClose, Token::LinkClose] => {
+				toks[idx] = IndexedToken {
+					idx: indicies[0],
+					tok: Token::InterlinkClose,
+				};
+				toks.remove(idx + 1);
+				continue;
+			}
+			_ => (),
+		}
+
+		idx += 1;
+	}
+
+	// Collapse subsequent Token::Text and remove Token::Escape
+	idx = 0;
+	loop {
+		if idx >= toks.len() - 1 {
+			break;
+		}
+
+		let (pair_idx, pair) = split_token_pair([toks[idx], toks[idx + 1]]);
+
+		if pair[0] == Token::Escape {
+			if pair[1].is_text() {
+				// Erroneous escape, remove it and leave the backslash in the text
+				let tok_idx = pair_idx[0];
+				let len = 1 + pair[1].len();
+				let tok = Token::Text(&raw[tok_idx..tok_idx + len]);
+				toks[idx + 1] = IndexedToken::new(tok_idx, tok);
+				toks.remove(idx);
+				idx = idx.saturating_sub(1);
+				continue;
+			} else {
+				toks[idx + 1] = IndexedToken {
+					idx: pair_idx[1],
+					tok: Token::Text(&raw[pair_idx[1]..pair_idx[1] + pair[1].len()]),
+				};
+			}
+		}
+
+		if let [Token::Text(text1), Token::Text(text2)] = pair {
+			let first_index = toks[idx].idx;
+			let length = text1.len() + text2.len();
+			toks[idx] = IndexedToken {
+				idx: first_index,
+				tok: Token::Text(&raw[first_index..first_index + length]),
+			};
+
+			// Remove the second text, as it's been combined into the first
+			toks.remove(idx + 1);
+
+			// Continue here so we use the same index and compare the modified
+			// Token::Text with the next token
+			continue;
+		}
+
+		if let [Token::Break, Token::Break] = pair {
+			toks.remove(idx + 1);
+			continue;
+		}
+
+		idx += 1;
+	}
+
+	idx = 0;
+	loop {
+		if toks[idx].tok == Token::Escape {
+			toks.remove(idx);
+
+			// Don't increment idx, we removed an element so the vec slid down
+			continue;
+		}
+
+		idx += 1;
+		if idx == toks.len() {
+			break;
+		}
+	}
+}
+
+fn round3<'raw, 'tok>(raw: &'raw str, toks: &'tok [IndexedToken<'raw>]) -> Vec<TreeNode<'raw>> {
+	let mut bigdoc = vec![];
+	for tok_slice in toks.split(|i| i.tok == Token::Break) {
+		bigdoc.push(TreeNode::Paragraph {
+			children: round3_intralevel(raw, tok_slice),
+		});
+	}
+
+	bigdoc
+}
+
+fn round3_intralevel<'raw, 'tok>(
+	raw: &'raw str,
+	toks: &'tok [IndexedToken<'raw>],
+) -> Vec<TreeNode<'raw>> {
+	let mut doc = vec![];
+
+	let mut curr = &toks[..];
+	loop {
+		if curr.is_empty() {
+			break doc;
+		}
+
+		let tok = curr[0];
+		macro_rules! decay {
+			() => {{
+				doc.push(TreeNode::Text(tok.tok.as_str()));
+				curr = &curr[1..];
+				continue;
+			}};
+		}
+
+		if let Token::Text(str) = tok.tok {
+			decay!()
+		}
+
+		match tok.tok {
+			Token::Text(_) | Token::Pipe | Token::LinkClose | Token::InterlinkClose => decay!(),
+			Token::Break | Token::Escape => unreachable!(),
+			Token::InterlinkOpen => match find_close(&curr[1..], Token::InterlinkClose) {
+				None => decay!(),
+				Some(FindCloseRet { group, remaining }) => {
+					let inner = round3_intralevel(raw, group);
+					curr = remaining;
+					doc.push(TreeNode::Interlink { children: inner });
+					continue;
+				}
+			},
+			Token::LinkOpen => match find_close(&curr[1..], Token::LinkClose) {
+				None => decay!(),
+				Some(FindCloseRet { group, remaining }) => {
+					curr = remaining;
+
+					let mut splits = group.splitn(2, |tok| tok.tok == Token::Pipe);
+					let first = splits.next();
+					let second = splits.next();
+
+					match (first, second) {
+						(Some(one), None) => {
+							doc.push(TreeNode::Link {
+								kind: LinkKind::External,
+								name: None,
+								children: round3_intralevel(raw, one),
+							});
+						}
+						(Some(one), Some(two)) => {
+							doc.push(TreeNode::Link {
+								kind: LinkKind::External,
+								name: Some(round3_intralevel(raw, one)),
+								children: round3_intralevel(raw, two),
+							});
+						}
+						_ => unreachable!(),
+					}
+
+					continue;
+				}
+			},
+			Token::Bold | Token::Code | Token::Italic => match find_close(&curr[1..], tok.tok) {
+				None => decay!(),
+				Some(FindCloseRet { group, remaining }) => {
+					let inner = round3_intralevel(raw, group);
+					curr = remaining;
+
+					let style = match tok.tok {
+						Token::Bold => NodeStyle::Bold,
+						Token::Code => NodeStyle::Code,
+						Token::Italic => NodeStyle::Italic,
+						_ => unreachable!(),
+					};
+
+					doc.push(TreeNode::StyledText {
+						style,
+						children: inner,
+					});
+					continue;
+				}
+			},
+		}
+	}
+}
+
+fn split_token_pair<'raw>(it: [IndexedToken<'raw>; 2]) -> ([usize; 2], [Token<'raw>; 2]) {
+	([it[0].idx, it[1].idx], [it[0].tok, it[1].tok])
+}
+
+fn find_close<'raw, 'tok>(
+	toks: &'tok [IndexedToken<'raw>],
+	close: Token<'raw>,
+) -> Option<FindCloseRet<'raw, 'tok>> {
+	match toks.iter().position(|tok| tok.tok.eq(&close)) {
+		None => None,
+		Some(idx) => {
+			let group = &toks[..idx];
+			let remaining = &toks[idx + 1..];
+
+			Some(FindCloseRet { group, remaining })
+		}
+	}
+}
+
+struct FindCloseRet<'raw, 'tok> {
+	group: &'tok [IndexedToken<'raw>],
+	remaining: &'tok [IndexedToken<'raw>],
+}
+
+//MARK: CollectingCharIter
+struct CollectingCharIter<'raw> {
+	raw: &'raw str,
+	chars: Enumerate<Chars<'raw>>,
+	raw_slice_start: Option<usize>,
+
+	last: Option<(usize, char)>,
+	current: Option<(usize, char)>,
+}
+
+impl<'raw> CollectingCharIter<'raw> {
+	pub fn take_slice(&mut self, end: Option<usize>) -> Option<IndexedToken<'raw>> {
+		let end = if let Some(end_idx) = end {
+			end_idx
+		} else {
+			self.raw.len()
+		};
+
+		if let Some(start) = self.raw_slice_start.take() {
+			Some(IndexedToken {
+				idx: start,
+				tok: Token::Text(&self.raw[start..end]),
+			})
+		} else {
+			None
+		}
+	}
+
+	pub fn append_slice(&mut self, end: Option<usize>, tokens: &mut Vec<IndexedToken<'raw>>) {
+		if let Some(slice) = self.take_slice(end) {
+			tokens.push(slice);
+		}
+	}
+
+	pub fn set_raw_if_empty(&mut self) {
+		if self.raw_slice_start.is_none() {
+			if let Some((start, _)) = self.current {
+				self.raw_slice_start = Some(start);
+			}
+		}
+	}
+
+	pub fn last(&self) -> Option<(usize, char)> {
+		self.last
+	}
+}
+
+impl<'raw> From<&'raw str> for CollectingCharIter<'raw> {
+	fn from(raw: &'raw str) -> Self {
+		Self {
+			raw: raw,
+			chars: raw.chars().enumerate(),
+			raw_slice_start: None,
+
+			last: None,
+			current: None,
+		}
+	}
+}
+
+impl<'raw> Iterator for CollectingCharIter<'raw> {
+	type Item = (usize, char);
+
+	fn next(&mut self) -> Option<Self::Item> {
+		let next = self.chars.next();
+		self.last = self.current.take();
+		self.current = next;
+		next
+	}
+}
+
+//MARK: Test
+#[cfg(test)]
+mod test {
+	use std::{borrow::Cow, rc::Rc};
+
+	use crate::lineup::{
+		EchoInterlinkSolver, IndexedToken, InterlinkSolver, NodeStyle, Token, TreeNode, process,
+		round1, round2, round3, tree::LinkKind,
+	};
+
+	#[test]
+	fn round1_textonly() {
+		let raw = "Raw Text!";
+		let r1 = round1(raw);
+
+		assert_eq!(r1[0].tok, Token::Text("Raw Text!"))
+	}
+
+	#[test]
+	fn round1_single_asterisk() {
+		let raw = "Raw*Text!";
+		let r1 = round1(raw);
+
+		assert_eq!(r1[0].tok, Token::Text("Raw"));
+		assert_eq!(r1[0].idx, 0);
+
+		assert_eq!(r1[1].tok, Token::Italic);
+		assert_eq!(r1[1].idx, 3);
+
+		assert_eq!(r1[2].tok, Token::Text("Text!"));
+		assert_eq!(r1[2].idx, 4);
+	}
+
+	#[test]
+	fn round2_collapse_texts() {
+		let raw = "RawText!";
+		let mut r1 = vec![
+			IndexedToken::new(0, Token::Text(&raw[..3])),
+			IndexedToken::new(3, Token::Text(&raw[3..])),
+		];
+
+		round2(raw, &mut r1);
+
+		assert_eq!(r1, vec![IndexedToken::new(0, Token::Text(raw))]);
+	}
+
+	#[test]
+	fn round2_finds_bold() {
+		let raw = "**";
+		let mut r1 = vec![
+			IndexedToken::new(0, Token::Italic),
+			IndexedToken::new(1, Token::Italic),
+		];
+
+		round2(raw, &mut r1);
+
+		assert_eq!(r1, vec![IndexedToken::new(0, Token::Bold)]);
+	}
+
+	#[test]
+	fn round2_finds_escape() {
+		//          012 345
+		let raw = "\\* \\**";
+		let mut r1 = vec![
+			IndexedToken::new(0, Token::Escape),
+			IndexedToken::new(1, Token::Italic),
+			IndexedToken::new(2, Token::Text(&raw[2..3])),
+			IndexedToken::new(3, Token::Escape),
+			IndexedToken::new(4, Token::Italic),
+			IndexedToken::new(5, Token::Italic),
+		];
+
+		round2(raw, &mut r1);
+
+		assert_eq!(
+			r1,
+			vec![
+				IndexedToken::new(1, Token::Text("* ")),
+				IndexedToken::new(4, Token::Text("**"))
+			]
+		)
+	}
+
+	#[test]
+	fn round2_erroneous_escape() {
+		let raw = "\\wrong escape";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+
+		assert_eq!(r1[0].tok, Token::Text("\\wrong escape"))
+	}
+
+	#[test]
+	fn round2_middle_erreoneous_escape() {
+		let raw = "wrong \\escape";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+
+		assert_eq!(
+			r1,
+			vec![IndexedToken::new(0, Token::Text("wrong \\escape"))]
+		)
+	}
+
+	#[test]
+	fn round2_complex() {
+		//         0         1          2
+		//         01234567890123456789 0123456789
+		let raw = "Start **bold** and \\* escape";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+
+		assert_eq!(
+			r1,
+			vec![
+				IndexedToken::new(0, Token::Text("Start ")),
+				IndexedToken::new(6, Token::Bold),
+				IndexedToken::new(8, Token::Text("bold")),
+				IndexedToken::new(12, Token::Bold),
+				IndexedToken::new(14, Token::Text(" and ")),
+				IndexedToken::new(20, Token::Text("* escape"))
+			]
+		)
+	}
+
+	macro_rules! wrap_p {
+		($some:expr) => {
+			TreeNode::Paragraph {
+				children: vec![$some],
+			}
+		};
+	}
+
+	#[test]
+	fn round3_textonly() {
+		let raw = "Raw text!";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+
+		let tree = round3(raw, &r1);
+
+		assert_eq!(tree, vec![wrap_p!(TreeNode::Text(raw))]);
+	}
+
+	#[test]
+	fn round3_italics() {
+		let raw = "*italisized!*";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+
+		let tree = round3(raw, &r1);
+
+		assert_eq!(
+			tree,
+			vec![wrap_p!(TreeNode::StyledText {
+				style: NodeStyle::Italic,
+				children: vec![TreeNode::Text("italisized!")]
+			})]
+		)
+	}
+
+	#[test]
+	fn round3_bold_in_italics() {
+		let raw = "*italics **bold** after*";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+
+		let tree = round3(raw, &r1);
+
+		assert_eq!(
+			tree,
+			vec![wrap_p!(TreeNode::StyledText {
+				style: NodeStyle::Italic,
+				children: vec![
+					TreeNode::Text("italics "),
+					TreeNode::StyledText {
+						style: NodeStyle::Bold,
+						children: vec![TreeNode::Text("bold")]
+					},
+					TreeNode::Text(" after")
+				]
+			})]
+		)
+	}
+
+	#[test]
+	fn round3_interlink() {
+		let raw = "[[link]]";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+		let tree = round3(raw, &r1);
+
+		assert_eq!(
+			tree,
+			vec![wrap_p!(TreeNode::Interlink {
+				children: vec![TreeNode::Text("link")]
+			})]
+		)
+	}
+
+	#[test]
+	fn round3_link() {
+		let raw = "[named|link]";
+		let mut r1 = round1(raw);
+		round2(raw, &mut r1);
+		let tree = round3(raw, &r1);
+
+		assert_eq!(
+			tree,
+			vec![wrap_p!(TreeNode::Link {
+				kind: LinkKind::External,
+				name: Some(vec![TreeNode::Text("named")]),
+				children: vec![TreeNode::Text("link")]
+			})]
+		)
+	}
+
+	#[test]
+	fn treenode_html_text() {
+		let node = TreeNode::Text("Raw Text!");
+		let rcsolve = Rc::new(EchoInterlinkSolver);
+		let actual = node.html(rcsolve);
+		let expected = "Raw Text!";
+
+		assert_eq!(actual, expected)
+	}
+
+	#[test]
+	fn treenode_html_paragraph() {
+		let node = TreeNode::Paragraph {
+			children: vec![TreeNode::Text("Para 1"), TreeNode::Text(" Para 2")],
+		};
+		let rcsolve = Rc::new(EchoInterlinkSolver);
+		let actual = node.html(rcsolve);
+		let expected = "<p>Para 1 Para 2</p>";
+
+		assert_eq!(actual, expected)
+	}
+
+	#[test]
+	fn treenode_html_italics() {
+		let node = TreeNode::StyledText {
+			style: NodeStyle::Italic,
+			children: vec![TreeNode::Text("italics!")],
+		};
+		let rcsolve = Rc::new(EchoInterlinkSolver);
+		let actual = node.html(rcsolve);
+		let expected = "<i>italics!</i>";
+
+		assert_eq!(actual, expected)
+	}
+
+	#[test]
+	fn treenode_html_interlink() {
+		let node = TreeNode::Interlink {
+			children: vec![TreeNode::Text("link!")],
+		};
+		let rcsolve = Rc::new(EchoInterlinkSolver);
+		let actual = node.html(rcsolve);
+		let expected = "<a href=\"link!\">link!</a>";
+
+		assert_eq!(actual, expected)
+	}
+
+	struct Solver;
+	impl InterlinkSolver for Solver {
+		fn link_title<'n>(&self, name: &'n str) -> Cow<'n, str> {
+			Cow::Owned(name.to_uppercase())
+		}
+
+		fn link_location<'n>(&self, name: &'n str) -> Cow<'n, str> {
+			Cow::Owned(format!("/quark/{name}"))
+		}
+	}
+
+	#[test]
+	fn treenode_html_interlink_with_solver() {
+		let node = TreeNode::Interlink {
+			children: vec![TreeNode::Text("link!")],
+		};
+
+		let solver = Rc::new(Solver);
+		let actual = node.html(solver);
+		let expected = "<a href=\"/quark/link!\">LINK!</a>";
+
+		assert_eq!(actual, expected)
+	}
+
+	#[test]
+	fn treenode_html_link() {
+		let node = TreeNode::Link {
+			kind: LinkKind::External,
+			name: Some(vec![TreeNode::Text("dreamy.place")]),
+			children: vec![TreeNode::Text("https://dreamy.place")],
+		};
+
+		let actual = node.html(Rc::new(Solver));
+		let expected = "<a href=\"https://dreamy.place\">dreamy.place</a>";
+
+		assert_eq!(actual, expected)
+	}
+
+	#[test]
+	fn document_html_complex() {
+		let raw = "*italics **bold** after*";
+		let expected = "<i>italics <b>bold</b> after</i>";
+		let rcsolve = Rc::new(EchoInterlinkSolver);
+		let doc = process(raw, rcsolve);
+		let html = doc.html();
+
+		assert_eq!(html, expected)
+	}
+
+	#[test]
+	fn multiparagraph() {
+		let raw = "One\n\nTwo";
+
+		let expected = vec![
+			wrap_p!(TreeNode::Text("One")),
+			wrap_p!(TreeNode::Text("Two")),
+		];
+
+		let doc = process(raw, Rc::new(EchoInterlinkSolver));
+
+		assert_eq!(doc.nodes, expected)
+	}
+
+	#[test]
+	fn multiparagraph_carriagereturn() {
+		let raw = "One\r\n\r\nTwo";
+
+		let expected = vec![
+			wrap_p!(TreeNode::Text("One")),
+			wrap_p!(TreeNode::Text("Two")),
+		];
+
+		let doc = process(raw, Rc::new(EchoInterlinkSolver));
+
+		assert_eq!(doc.nodes, expected)
+	}
+}
diff --git a/markup/src/lineup/token.rs b/markup/src/lineup/token.rs
new file mode 100644
index 0000000..5b61597
--- /dev/null
+++ b/markup/src/lineup/token.rs
@@ -0,0 +1,85 @@
+use core::fmt;
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub(crate) struct IndexedToken<'raw> {
+	pub idx: usize,
+	pub tok: Token<'raw>,
+}
+
+impl<'raw> IndexedToken<'raw> {
+	pub fn new(idx: usize, tok: Token<'raw>) -> Self {
+		Self { idx, tok }
+	}
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum Token<'raw> {
+	/// Raw, no markup
+	Text(&'raw str),
+	/// Linebreak
+	Break,
+	/// backtick
+	Code,
+	/// asterisk
+	Italic,
+	/// Double-asterisk
+	Bold,
+	/// backslash
+	Escape,
+	/// Pipe character |
+	Pipe,
+	/// Link Opening [
+	LinkOpen,
+	/// Link Closing ]
+	LinkClose,
+	/// Interlink Open [[
+	InterlinkOpen,
+	/// Interlink CLose ]]
+	InterlinkClose,
+}
+
+impl<'raw> Token<'raw> {
+	pub fn is_text(&self) -> bool {
+		if let Self::Text(_) = self {
+			true
+		} else {
+			false
+		}
+	}
+
+	pub fn len(&self) -> usize {
+		match self {
+			Self::Text(txt) => txt.len(),
+			Self::Break
+			| Self::Code
+			| Self::Italic
+			| Self::Escape
+			| Self::Pipe
+			| Self::LinkOpen
+			| Self::LinkClose => 1,
+			Self::Bold | Self::InterlinkOpen | Self::InterlinkClose => 2,
+		}
+	}
+
+	pub fn as_str(&self) -> &'raw str {
+		match self {
+			Token::Bold => "**",
+			Token::Break => "\n",
+			Token::Code => "`",
+			Token::Escape => "\\",
+			Token::Pipe => "|",
+			Token::LinkClose => "]",
+			Token::LinkOpen => "[",
+			Token::InterlinkOpen => "[[",
+			Token::InterlinkClose => "]]",
+			Token::Italic => "*",
+			Token::Text(str) => str,
+		}
+	}
+}
+
+impl<'raw> fmt::Display for Token<'raw> {
+	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+		write!(f, "{}", self.as_str())
+	}
+}
diff --git a/markup/src/lineup/tree.rs b/markup/src/lineup/tree.rs
new file mode 100644
index 0000000..dcb1cb4
--- /dev/null
+++ b/markup/src/lineup/tree.rs
@@ -0,0 +1,147 @@
+use std::{borrow::Cow, rc::Rc};
+
+use crate::InterlinkSolver;
+
+pub struct Document<'raw> {
+	pub(crate) nodes: Vec<TreeNode<'raw>>,
+	pub(crate) solver: Rc<dyn InterlinkSolver>,
+}
+
+impl<'raw> Document<'raw> {
+	pub fn set_interlink_solver(&mut self, solver: Rc<dyn InterlinkSolver>) {
+		self.solver = solver;
+	}
+
+	pub fn html(self) -> String {
+		TreeNode::html_nodes(&self.nodes, Rc::clone(&self.solver))
+	}
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum TreeNode<'raw> {
+	Text(&'raw str),
+	Paragraph {
+		children: Vec<TreeNode<'raw>>,
+	},
+	Interlink {
+		children: Vec<TreeNode<'raw>>,
+	},
+	Link {
+		kind: LinkKind,
+		name: Option<Vec<TreeNode<'raw>>>,
+		children: Vec<TreeNode<'raw>>,
+	},
+	StyledText {
+		style: NodeStyle,
+		children: Vec<TreeNode<'raw>>,
+	},
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum LinkKind {
+	External,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum NodeStyle {
+	Bold,
+	Code,
+	Italic,
+}
+
+impl<'raw> TreeNode<'raw> {
+	pub fn html(&self, solver: Rc<dyn InterlinkSolver>) -> Cow<'raw, str> {
+		match self {
+			TreeNode::Text(str) => Cow::Borrowed(str),
+			TreeNode::Paragraph { children } => {
+				let inner = Self::html_nodes(children.as_slice(), solver);
+				Cow::Owned(format!("<p>{inner}</p>"))
+			}
+			TreeNode::Interlink { children } => {
+				let txt = Self::text_nodes(&children, Rc::clone(&solver));
+
+				let title = solver.link_title(&txt);
+				let location = solver.link_location(&txt);
+
+				Cow::Owned(format!("<a href=\"{location}\">{title}</a>"))
+			}
+			TreeNode::Link {
+				kind,
+				name,
+				children,
+			} => {
+				let txt = Self::text_nodes(&children, Rc::clone(&solver));
+
+				let title = match name.as_deref() {
+					None => Cow::Borrowed(txt.as_str()),
+					Some(name) => {
+						let txt = Self::text_nodes(name, Rc::clone(&solver));
+						Cow::Owned(txt)
+					}
+				};
+				let location = txt.as_str();
+
+				Cow::Owned(format!("<a href=\"{location}\">{title}</a>"))
+			}
+			TreeNode::StyledText { style, children } => {
+				let (open, close) = match style {
+					NodeStyle::Bold => ("<b>", "</b>"),
+					NodeStyle::Code => ("<code>", "</code>"),
+					NodeStyle::Italic => ("<i>", "</i>"),
+				};
+
+				let inner = Self::html_nodes(children.as_slice(), solver);
+				Cow::Owned(format!("{open}{inner}{close}"))
+			}
+		}
+	}
+
+	pub fn text(&self, solver: Rc<dyn InterlinkSolver>) -> Cow<'raw, str> {
+		match self {
+			TreeNode::Text(str) => Cow::Borrowed(str),
+			TreeNode::Paragraph { children } => Cow::Owned(Self::text_nodes(children, solver)),
+			TreeNode::Interlink { children } => {
+				let text = Self::text_nodes(children, Rc::clone(&solver));
+				Cow::Owned(solver.link_title(&text).into_owned())
+			}
+			TreeNode::Link {
+				kind: _,
+				name,
+				children,
+			} => match name.as_deref() {
+				None => {
+					let text = Self::text_nodes(children, Rc::clone(&solver));
+					Cow::Owned(solver.link_title(&text).into_owned())
+				}
+				Some(name) => {
+					let text = Self::text_nodes(name, Rc::clone(&solver));
+					Cow::Owned(solver.link_title(&text).into_owned())
+				}
+			},
+			TreeNode::StyledText { style: _, children } => {
+				Cow::Owned(Self::text_nodes(children, solver))
+			}
+		}
+	}
+
+	fn text_nodes(nodes: &[TreeNode<'raw>], solver: Rc<dyn InterlinkSolver>) -> String {
+		let mut ret = String::new();
+		for node in nodes {
+			let node_txt = &node.text(Rc::clone(&solver));
+			if !node_txt.is_empty() {
+				ret.push_str(node_txt);
+			}
+		}
+
+		ret
+	}
+
+	fn html_nodes(nodes: &[TreeNode<'raw>], solver: Rc<dyn InterlinkSolver>) -> String {
+		let mut ret = String::new();
+		for node in nodes {
+			ret.push_str(&node.html(Rc::clone(&solver)));
+		}
+
+		ret
+	}
+}