feat: serve rendered pages over http, spartan and nex

This commit is contained in:
randogoth 2026-10-04 19:55:21 +03:00
parent 46f12b10da
commit 2f38b8b929
30 changed files with 2896 additions and 90 deletions

14
gemtext/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "itsybitsy-gemtext"
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
description = "gemtext output for itsybitsy"
license = "Apache-2.0"
publish = false
[dependencies]
itsybitsy-core = { path = "../core" }
[lints.rust]
unsafe_code = "forbid"

389
gemtext/src/lib.rs Normal file
View file

@ -0,0 +1,389 @@
//! gemtext output, for Gemini and Spartan.
//!
//! gemtext is line-oriented and has no inline markup at all: a link is a whole
//! line beginning `=>`, so links inside a paragraph have to be lifted out of it.
//! They are emitted directly after the paragraph that held them, which keeps a
//! link next to the sentence that introduced it rather than in one list at the
//! end of the document.
//!
//! Nothing here wraps. Gemini and Spartan clients wrap for themselves, and
//! pre-wrapping would fight whatever width the reader actually has.
use itsybitsy_core::Error;
use itsybitsy_core::ir::{Block, Doc, Inline};
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
pub struct Gemtext;
impl Renderer for Gemtext {
fn id(&self) -> &'static str {
"gemtext"
}
fn media_type(&self) -> &'static str {
"text/gemini; charset=utf-8"
}
fn render(&self, doc: &Doc, _ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
let mut out = Writer::default();
out.blocks(&doc.blocks);
Ok(Rendered::body(out.finish()))
}
}
#[derive(Default)]
struct Writer {
text: String,
}
impl Writer {
fn finish(mut self) -> Vec<u8> {
if !self.text.ends_with('\n') && !self.text.is_empty() {
self.text.push('\n');
}
self.text.into_bytes()
}
fn line(&mut self, line: &str) {
self.text.push_str(line.trim_end());
self.text.push('\n');
}
fn blank(&mut self) {
if !self.text.is_empty() && !self.text.ends_with("\n\n") {
self.text.push('\n');
}
}
fn blocks(&mut self, blocks: &[Block]) {
for block in blocks {
self.block(block);
}
}
fn block(&mut self, block: &Block) {
match block {
Block::Heading { level, inline } => {
self.blank();
// gemtext has three heading levels; deeper ones flatten onto the
// third rather than disappearing.
let hashes = "#".repeat((*level).clamp(1, 3) as usize);
self.line(&format!("{hashes} {}", Doc::plain_text(inline)));
self.links(inline);
}
Block::Paragraph(inline) => {
self.blank();
// A paragraph that is only a link needs no text line of its own.
let text = Doc::plain_text(inline).trim().to_string();
if !is_only_links(inline) && !text.is_empty() {
self.line(&text);
}
self.links(inline);
}
Block::CodeBlock { info, lines } => {
self.blank();
// The info string becomes the fence's alt text, which is where
// gemtext puts a description of a preformatted block.
self.line(&format!("```{}", info.as_deref().unwrap_or("")));
for line in lines {
self.line(line);
}
self.line("```");
}
Block::Art { alt, lines } => {
self.blank();
self.line(&format!("```{alt}"));
for line in lines {
self.line(line);
}
self.line("```");
}
Block::BlockQuote(blocks) => {
self.blank();
for text in quoted(blocks) {
self.line(&format!("> {text}"));
}
}
Block::List { ordered, start, items } => {
self.blank();
for (index, item) in items.iter().enumerate() {
let text = item_text(item);
// gemtext has one list marker, so an ordered list carries its
// number in the text to keep the sequence readable.
if *ordered {
self.line(&format!("* {}. {text}", start + index as u64));
} else {
self.line(&format!("* {text}"));
}
for block in item {
if let Block::Paragraph(inline) | Block::Heading { inline, .. } = block {
self.links(inline);
}
}
// A nested list is flattened one level, since gemtext has no
// nesting; its items keep their own markers.
for block in item {
if let Block::List { .. } = block {
self.block(block);
}
}
}
}
Block::Table { head, rows, .. } => {
self.blank();
self.table(head, rows);
}
// gemtext has no rule, and a blank line alone reads as nothing, so a
// visible divider is spelled out.
Block::Rule => {
self.blank();
self.line("---");
}
// Alignment and pagination have no expression here. Unwrapping keeps
// the content; the presentation is simply not available.
Block::Aligned { block, .. } => self.block(block),
Block::CardBreak { .. } => {}
// Raw markup would be shown literally, which is worse than omitting it.
Block::Html(_) => {}
}
}
/// `=>` lines for every link and image in a run of inlines.
fn links(&mut self, inline: &[Inline]) {
for (href, label) in collect_links(inline) {
let label = label.trim();
// An autolink's label is its own URL, which would print twice.
if label.is_empty() || label == href {
self.line(&format!("=> {href}"));
} else {
self.line(&format!("=> {href} {label}"));
}
}
}
/// A table as a preformatted block, with columns padded to line up. gemtext
/// has no table syntax, and a run of pipes without alignment is unreadable.
fn table(&mut self, head: &[Vec<Inline>], rows: &[Vec<Vec<Inline>>]) {
let mut all: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
if !head.is_empty() {
all.push(head.iter().map(|cell| Doc::plain_text(cell)).collect());
}
for row in rows {
all.push(row.iter().map(|cell| Doc::plain_text(cell)).collect());
}
let columns = all.iter().map(Vec::len).max().unwrap_or(0);
let widths: Vec<usize> = (0..columns)
.map(|index| {
all.iter()
.map(|row| row.get(index).map_or(0, |c| c.chars().count()))
.max()
.unwrap_or(0)
})
.collect();
self.line("```");
for (index, row) in all.iter().enumerate() {
let cells: Vec<String> =
row.iter().zip(&widths).map(|(cell, width)| format!("{cell:<width$}")).collect();
self.line(&cells.join(" "));
// A rule under the header row, matching the padded column widths.
if index == 0 && !head.is_empty() {
let rule: Vec<String> = widths.iter().map(|width| "-".repeat(*width)).collect();
self.line(&rule.join(" "));
}
}
self.line("```");
}
}
/// Whether a paragraph holds links and nothing else worth printing.
fn is_only_links(inline: &[Inline]) -> bool {
let mut saw_link = false;
for item in inline {
match item {
Inline::Link { .. } | Inline::Image { .. } => saw_link = true,
Inline::Text(text) if text.trim().is_empty() => {}
Inline::SoftBreak => {}
_ => return false,
}
}
saw_link
}
/// Links and images in document order, as `(href, label)`.
///
/// Order is taken from the single parsed representation, so gemtext's catalogue
/// and the text formats' numbered references cannot disagree about it.
fn collect_links(inline: &[Inline]) -> Vec<(String, String)> {
let mut out = Vec::new();
walk_links(inline, &mut out);
out
}
fn walk_links(inline: &[Inline], out: &mut Vec<(String, String)>) {
for item in inline {
match item {
Inline::Link { href, label, .. } => {
out.push((href.clone(), Doc::plain_text(label)));
walk_links(label, out);
}
Inline::Image { src, alt, .. } => out.push((src.clone(), Doc::plain_text(alt))),
Inline::Emph(inner) | Inline::Strong(inner) | Inline::Strike(inner) => {
walk_links(inner, out)
}
_ => {}
}
}
}
/// The text of a list item, flattened onto one line.
fn item_text(blocks: &[Block]) -> String {
let mut parts = Vec::new();
for block in blocks {
match block {
Block::Paragraph(inline) | Block::Heading { inline, .. } => {
parts.push(Doc::plain_text(inline))
}
Block::Aligned { block, .. } => parts.push(item_text(std::slice::from_ref(block))),
_ => {}
}
}
parts.join(" ").trim().to_string()
}
/// The lines of a quote, flattened: gemtext quotes one line at a time.
fn quoted(blocks: &[Block]) -> Vec<String> {
let mut out = Vec::new();
for block in blocks {
match block {
Block::Paragraph(inline) | Block::Heading { inline, .. } => {
out.push(Doc::plain_text(inline))
}
Block::BlockQuote(inner) => out.extend(quoted(inner)),
Block::List { items, .. } => {
out.extend(items.iter().map(|item| format!("* {}", item_text(item))))
}
Block::CodeBlock { lines, .. } | Block::Art { lines, .. } => {
out.extend(lines.iter().cloned())
}
Block::Aligned { block, .. } => out.extend(quoted(std::slice::from_ref(block))),
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use itsybitsy_core::config::PageSettings;
use itsybitsy_core::parse;
use super::*;
/// Render a Markdown fragment the way the server would.
fn render(markdown: &str) -> String {
let doc = parse::markdown(markdown);
let settings = PageSettings::default();
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
let out = Gemtext.render(&doc, &ctx).unwrap();
String::from_utf8(out.body).unwrap()
}
#[test]
fn headings_flatten_onto_three_levels() {
assert_eq!(render("# a\n## b\n### c\n#### d\n"), "# a\n\n## b\n\n### c\n\n### d\n");
}
#[test]
fn paragraphs_are_one_line_each() {
// Unwrapped: a Gemini client wraps to the width the reader actually has.
assert_eq!(render("one two\nthree\n\nnext\n"), "one two three\n\nnext\n");
}
#[test]
fn inline_markup_is_flattened_to_text() {
assert_eq!(render("a *b* **c** `d`\n"), "a b c d\n");
}
#[test]
fn links_follow_the_paragraph_that_held_them() {
assert_eq!(
render("See the [about page](/about) and [home](/).\n"),
"See the about page and home.\n=> /about about page\n=> / home\n"
);
}
#[test]
fn a_paragraph_that_is_only_a_link_emits_just_the_link() {
assert_eq!(render("[about](/about)\n"), "=> /about about\n");
}
#[test]
fn a_link_with_no_label_still_emits_a_line() {
assert_eq!(render("<https://example.com/>\n"), "=> https://example.com/\n");
}
#[test]
fn lists_use_the_single_gemtext_marker() {
assert_eq!(render("- a\n- b\n"), "* a\n* b\n");
}
#[test]
fn an_ordered_list_keeps_its_numbers_in_the_text() {
// gemtext has one marker, so the sequence has to be spelled out or it is
// lost entirely.
assert_eq!(render("3. a\n4. b\n"), "* 3. a\n* 4. b\n");
}
#[test]
fn a_links_inside_a_list_item_are_emitted_after_it() {
assert_eq!(render("- see [x](/x)\n"), "* see x\n=> /x x\n");
}
#[test]
fn code_blocks_become_fences_carrying_their_info_string() {
assert_eq!(render("```rust\nlet x = 1;\n```\n"), "```rust\nlet x = 1;\n```\n");
assert_eq!(render(" plain\n"), "```\nplain\n```\n");
}
#[test]
fn quotes_are_marked_line_by_line() {
assert_eq!(render("> a\n>\n> b\n"), "> a\n> b\n");
}
#[test]
fn a_rule_is_spelled_out_because_gemtext_has_none() {
assert_eq!(render("a\n\n---\n\nb\n"), "a\n\n---\n\nb\n");
}
#[test]
fn tables_become_aligned_preformatted_blocks() {
// gemtext has no table syntax, and unaligned pipes are unreadable.
let out = render("| Format | Port |\n| --- | --- |\n| Gemini | 1965 |\n| Nex | 1900 |\n");
assert_eq!(out, "```\nFormat Port\n------ ----\nGemini 1965\nNex 1900\n```\n");
}
#[test]
fn a_card_divider_emits_nothing() {
// Pagination is a WML concern; a Gemini client scrolls one document.
let doc = parse::markdown("a\n\nb\n");
let settings = PageSettings::default();
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap();
assert_eq!(plain, "a\n\nb\n");
}
#[test]
fn raw_html_is_omitted_rather_than_shown_literally() {
assert_eq!(render("a\n\n<div>x</div>\n\nb\n"), "a\n\nb\n");
}
#[test]
fn output_always_ends_in_exactly_one_newline() {
for source in ["a\n", "# a\n", "- a\n", "```\nx\n```\n"] {
let out = render(source);
assert!(out.ends_with('\n'), "{source:?} -> {out:?}");
assert!(!out.ends_with("\n\n"), "{source:?} -> {out:?}");
}
}
}