feat: serve rendered pages over http, spartan and nex
This commit is contained in:
parent
46f12b10da
commit
2f38b8b929
30 changed files with 2896 additions and 90 deletions
14
gemtext/Cargo.toml
Normal file
14
gemtext/Cargo.toml
Normal 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
389
gemtext/src/lib.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue