feat: render fixed-width text for nex
This commit is contained in:
parent
2f38b8b929
commit
9e1cfed284
19 changed files with 1160 additions and 44 deletions
105
text/src/inline.rs
Normal file
105
text/src/inline.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! Inline markup flattened to plain text.
|
||||
//!
|
||||
//! Plain text has no markup to carry emphasis, so it is dropped and the words
|
||||
//! kept. A link becomes `label (url)`: self-contained, and readable without
|
||||
//! scrolling to a reference list somewhere else. md2txt's `text` renderer emits
|
||||
//! numbered markers instead but never writes the list they point at, so the
|
||||
//! numbers lead nowhere.
|
||||
|
||||
use itsybitsy_core::ir::{Doc, Inline};
|
||||
|
||||
/// Flatten a run of inlines, spelling out where each link points.
|
||||
pub fn flatten(inline: &[Inline]) -> String {
|
||||
let mut out = String::new();
|
||||
write(inline, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write(inline: &[Inline], out: &mut String) {
|
||||
for item in inline {
|
||||
match item {
|
||||
Inline::Text(text) => out.push_str(text),
|
||||
// Backticks are the only markup worth keeping: they mark where a
|
||||
// literal begins and end, which prose otherwise cannot show.
|
||||
Inline::Code(code) => out.push_str(&format!("`{code}`")),
|
||||
Inline::Emph(inner) | Inline::Strong(inner) | Inline::Strike(inner) => {
|
||||
write(inner, out)
|
||||
}
|
||||
Inline::Link { href, label, .. } => {
|
||||
let text = Doc::plain_text(label);
|
||||
write_target(&text, href, out);
|
||||
}
|
||||
Inline::Image { src, alt, .. } => {
|
||||
let text = Doc::plain_text(alt);
|
||||
write_target(&text, src, out);
|
||||
}
|
||||
Inline::SoftBreak => out.push(' '),
|
||||
// A hard break is honoured by the layout, which splits on it.
|
||||
Inline::HardBreak => out.push('\n'),
|
||||
// Raw markup shown literally would be worse than leaving it out.
|
||||
Inline::Html(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `label (url)`, or just the url when the label would repeat it.
|
||||
fn write_target(label: &str, url: &str, out: &mut String) {
|
||||
let label = label.trim();
|
||||
if label.is_empty() || label == url {
|
||||
out.push_str(url);
|
||||
} else {
|
||||
out.push_str(&format!("{label} ({url})"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(value: &str) -> Vec<Inline> {
|
||||
vec![Inline::Text(value.into())]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_is_dropped_and_its_words_kept() {
|
||||
let inline = vec![
|
||||
Inline::Text("a ".into()),
|
||||
Inline::Strong(text("b")),
|
||||
Inline::Text(" ".into()),
|
||||
Inline::Emph(text("c")),
|
||||
];
|
||||
assert_eq!(flatten(&inline), "a b c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_keeps_its_backticks_to_mark_where_a_literal_ends() {
|
||||
assert_eq!(flatten(&[Inline::Code("x = 1".into())]), "`x = 1`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_spells_out_where_it_points() {
|
||||
let inline =
|
||||
vec![Inline::Link { href: "/about".into(), title: None, label: text("about page") }];
|
||||
assert_eq!(flatten(&inline), "about page (/about)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_whose_label_is_its_url_is_not_repeated() {
|
||||
let url = "https://example.com/";
|
||||
let inline = vec![Inline::Link { href: url.into(), title: None, label: text(url) }];
|
||||
assert_eq!(flatten(&inline), url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_is_treated_like_a_link() {
|
||||
let inline =
|
||||
vec![Inline::Image { src: "/i.png".into(), title: None, alt: text("a photo") }];
|
||||
assert_eq!(flatten(&inline), "a photo (/i.png)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_html_is_left_out() {
|
||||
let inline = vec![Inline::Text("a".into()), Inline::Html("<br>".into())];
|
||||
assert_eq!(flatten(&inline), "a");
|
||||
}
|
||||
}
|
||||
319
text/src/layout.rs
Normal file
319
text/src/layout.rs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
//! Blocks laid out as fixed-width lines.
|
||||
|
||||
use itsybitsy_core::config::{HeadingStyle, PageSettings};
|
||||
use itsybitsy_core::ir::{Align, Block, Inline};
|
||||
use itsybitsy_core::wrap::{display_width, pad, wrap};
|
||||
|
||||
use crate::inline::flatten;
|
||||
|
||||
/// Where a block's lines sit within the content width.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Placement {
|
||||
align: Option<Align>,
|
||||
/// Columns held back on both sides, from a directive's `margin=`.
|
||||
inset: usize,
|
||||
}
|
||||
|
||||
pub struct Layout<'a> {
|
||||
settings: &'a PageSettings,
|
||||
/// Columns available for content, after both margins.
|
||||
content_width: usize,
|
||||
lines: Vec<String>,
|
||||
/// Set by a container that has just emitted its own separating blank, so its
|
||||
/// first child does not emit a second one.
|
||||
suppress_gap: bool,
|
||||
}
|
||||
|
||||
impl<'a> Layout<'a> {
|
||||
/// `width` is the full terminal width; the margins come out of it.
|
||||
pub fn new(settings: &'a PageSettings, width: Option<u16>) -> Self {
|
||||
let total = width.unwrap_or(80) as usize;
|
||||
let margins = settings.margin_left as usize + settings.margin_right as usize;
|
||||
// Never let margins consume the whole line; content needs somewhere to go.
|
||||
let content_width = total.saturating_sub(margins).max(8);
|
||||
Layout { settings, content_width, lines: Vec::new(), suppress_gap: false }
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
while self.lines.last().is_some_and(|line| line.trim().is_empty()) {
|
||||
self.lines.pop();
|
||||
}
|
||||
let mut out = self.lines.join("\n");
|
||||
out.push('\n');
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
pub fn blocks(&mut self, blocks: &[Block]) {
|
||||
for block in blocks {
|
||||
self.block(block, "", Placement::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out one block. `prefix` is prepended inside the margin, which is how
|
||||
/// quotes and list continuations carry their decoration.
|
||||
fn block(&mut self, block: &Block, prefix: &str, place: Placement) {
|
||||
match block {
|
||||
Block::Heading { level, inline } => {
|
||||
self.gap(prefix, place);
|
||||
let text = flatten(inline);
|
||||
let index = (*level).clamp(1, 6) as usize - 1;
|
||||
match &self.settings.heading_styles[index] {
|
||||
HeadingStyle::Underline(rule) => {
|
||||
let wrapped = self.wrapped(&text, prefix, place);
|
||||
let width = wrapped.iter().map(|l| display_width(l)).max().unwrap_or(0);
|
||||
self.extend(wrapped, prefix, place);
|
||||
self.push(&rule.to_string().repeat(width), prefix, place);
|
||||
}
|
||||
HeadingStyle::Markers => {
|
||||
let marked = format!("{} {text}", "#".repeat(*level as usize));
|
||||
let wrapped = self.wrapped(&marked, prefix, place);
|
||||
self.extend(wrapped, prefix, place);
|
||||
}
|
||||
HeadingStyle::Plain => {
|
||||
let wrapped = self.wrapped(&text, prefix, place);
|
||||
self.extend(wrapped, prefix, place);
|
||||
}
|
||||
}
|
||||
}
|
||||
Block::Paragraph(inline) => {
|
||||
self.gap(prefix, place);
|
||||
// A hard break ends a line without ending the paragraph.
|
||||
for segment in flatten(inline).split('\n') {
|
||||
let wrapped = self.wrapped(segment, prefix, place);
|
||||
self.extend(wrapped, prefix, place);
|
||||
}
|
||||
}
|
||||
Block::CodeBlock { lines, .. } => {
|
||||
self.gap(prefix, place);
|
||||
self.verbatim(lines, prefix, place, self.settings.code_block_line_numbers);
|
||||
}
|
||||
Block::Art { lines, .. } => {
|
||||
self.gap(prefix, place);
|
||||
// Art is never numbered or wrapped: its shape is the content.
|
||||
self.verbatim(lines, prefix, place, false);
|
||||
}
|
||||
Block::BlockQuote(inner) => {
|
||||
self.gap(prefix, place);
|
||||
// Without bars a quote is still indented, or it would be
|
||||
// indistinguishable from body text.
|
||||
let bar = if self.settings.blockquote_bars { "| " } else { " " };
|
||||
self.suppress_gap = true;
|
||||
let nested = format!("{prefix}{bar}");
|
||||
for block in inner {
|
||||
self.block(block, &nested, place);
|
||||
self.suppress_gap = false;
|
||||
}
|
||||
}
|
||||
Block::List { ordered, start, items } => {
|
||||
self.gap(prefix, place);
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let marker = if *ordered {
|
||||
format!("{}. ", start + index as u64)
|
||||
} else {
|
||||
"- ".to_string()
|
||||
};
|
||||
self.item(item, prefix, &marker, place);
|
||||
}
|
||||
}
|
||||
Block::Table { alignments, head, rows } => {
|
||||
self.gap(prefix, place);
|
||||
self.table(alignments, head, rows, prefix, place);
|
||||
}
|
||||
Block::Rule => {
|
||||
self.gap(prefix, place);
|
||||
let width = self.room(prefix, place);
|
||||
self.push(&"-".repeat(width), prefix, place);
|
||||
}
|
||||
Block::Aligned { align, margin, block } => self.block(
|
||||
block,
|
||||
prefix,
|
||||
Placement { align: Some(*align), inset: margin.unwrap_or(0) as usize },
|
||||
),
|
||||
// Nothing paginates here, so a divider has nothing to divide.
|
||||
Block::CardBreak { .. } => {}
|
||||
Block::Html(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One list item: the marker on the first line, continuations aligned under
|
||||
/// the text rather than under the marker.
|
||||
fn item(&mut self, blocks: &[Block], prefix: &str, marker: &str, place: Placement) {
|
||||
let hanging = format!("{prefix}{}", " ".repeat(display_width(marker)));
|
||||
let first = format!("{prefix}{marker}");
|
||||
|
||||
// No suppression flag here: an item's first paragraph is emitted directly
|
||||
// rather than through `block`, so it never gaps, and anything after it
|
||||
// relies on `unblank` instead. Setting the flag would leak it past the
|
||||
// list and swallow the next block's separating line.
|
||||
let mut started = false;
|
||||
for block in blocks {
|
||||
match block {
|
||||
Block::Paragraph(inline) if !started => {
|
||||
started = true;
|
||||
let text = flatten(inline);
|
||||
let wrapped = self.wrapped(&text, &first, place);
|
||||
for (index, line) in wrapped.into_iter().enumerate() {
|
||||
let carrier = if index == 0 { &first } else { &hanging };
|
||||
self.push(&line, carrier, place);
|
||||
}
|
||||
}
|
||||
// A nested list, or anything after the item's first paragraph,
|
||||
// sits at the hanging indent.
|
||||
Block::List { .. } => {
|
||||
let indent = format!(
|
||||
"{hanging}{}",
|
||||
" ".repeat(self.settings.list_indent.saturating_sub(2) as usize)
|
||||
);
|
||||
// No blank line before a nested list: it belongs to the item.
|
||||
let before = self.lines.len();
|
||||
self.block(block, &indent, place);
|
||||
self.unblank(before);
|
||||
}
|
||||
other => {
|
||||
let before = self.lines.len();
|
||||
self.block(other, &hanging, place);
|
||||
self.unblank(before);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !started {
|
||||
self.push("", &first, place);
|
||||
}
|
||||
}
|
||||
|
||||
fn table(
|
||||
&mut self,
|
||||
alignments: &[Option<Align>],
|
||||
head: &[Vec<Inline>],
|
||||
rows: &[Vec<Vec<Inline>>],
|
||||
prefix: &str,
|
||||
place: Placement,
|
||||
) {
|
||||
let mut grid: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
|
||||
if !head.is_empty() {
|
||||
grid.push(head.iter().map(|cell| flatten(cell)).collect());
|
||||
}
|
||||
for row in rows {
|
||||
grid.push(row.iter().map(|cell| flatten(cell)).collect());
|
||||
}
|
||||
let columns = grid.iter().map(Vec::len).max().unwrap_or(0);
|
||||
if columns == 0 {
|
||||
return;
|
||||
}
|
||||
let widths: Vec<usize> = (0..columns)
|
||||
.map(|index| {
|
||||
grid.iter()
|
||||
.map(|row| row.get(index).map_or(0, |c| display_width(c)))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (index, row) in grid.iter().enumerate() {
|
||||
let cells: Vec<String> = (0..columns)
|
||||
.map(|column| {
|
||||
let cell = row.get(column).map(String::as_str).unwrap_or("");
|
||||
cell_aligned(cell, widths[column], alignments.get(column).copied().flatten())
|
||||
})
|
||||
.collect();
|
||||
self.push(cells.join(" ").trim_end(), prefix, place);
|
||||
if index == 0 && !head.is_empty() {
|
||||
let rule: Vec<String> = widths.iter().map(|width| "-".repeat(*width)).collect();
|
||||
self.push(&rule.join(" "), prefix, place);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines kept exactly as written, optionally numbered.
|
||||
fn verbatim(&mut self, lines: &[String], prefix: &str, place: Placement, numbered: bool) {
|
||||
let digits = lines.len().to_string().len().max(2);
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let body =
|
||||
if numbered { format!("{:0digits$} | {line}", index + 1) } else { line.clone() };
|
||||
if self.settings.wrap_code_blocks {
|
||||
for piece in wrap(&body, self.room(prefix, place)) {
|
||||
self.push(&piece, prefix, place);
|
||||
}
|
||||
} else {
|
||||
// Over-long lines are left to overflow: wrapping code changes
|
||||
// what it says.
|
||||
self.push(&body, prefix, place);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns available to content after the prefix and any inset.
|
||||
fn room(&self, prefix: &str, place: Placement) -> usize {
|
||||
self.content_width
|
||||
.saturating_sub(display_width(prefix))
|
||||
.saturating_sub(place.inset * 2)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
fn wrapped(&self, text: &str, prefix: &str, place: Placement) -> Vec<String> {
|
||||
wrap(text, self.room(prefix, place))
|
||||
}
|
||||
|
||||
fn extend(&mut self, lines: Vec<String>, prefix: &str, place: Placement) {
|
||||
for line in lines {
|
||||
self.push(&line, prefix, place);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit one line: left margin, then the prefix, then the content.
|
||||
fn push(&mut self, line: &str, prefix: &str, place: Placement) {
|
||||
let rendered = self.rendered(line, prefix, place);
|
||||
self.lines.push(rendered);
|
||||
}
|
||||
|
||||
/// One line's text, without appending it.
|
||||
fn rendered(&self, line: &str, prefix: &str, place: Placement) -> String {
|
||||
let room = self.room(prefix, place);
|
||||
let offset = match place.align {
|
||||
Some(Align::Center) => room.saturating_sub(display_width(line)) / 2,
|
||||
Some(Align::Right) => room.saturating_sub(display_width(line)),
|
||||
_ => 0,
|
||||
};
|
||||
let content = format!("{}{line}", " ".repeat(place.inset + offset));
|
||||
let mut out = " ".repeat(self.settings.margin_left as usize);
|
||||
out.push_str(prefix);
|
||||
out.push_str(&content);
|
||||
// A margin is not content, so a blank line stays blank.
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// The blank lines that separate blocks, suppressed at the very start.
|
||||
///
|
||||
/// Carries the prefix, so a blank line inside a quote keeps its bar and the
|
||||
/// quote reads as one block rather than two.
|
||||
fn gap(&mut self, prefix: &str, place: Placement) {
|
||||
if std::mem::take(&mut self.suppress_gap) || self.lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let blank = self.rendered("", prefix, place);
|
||||
let present = self.lines.iter().rev().take_while(|line| **line == blank).count();
|
||||
for _ in present..self.settings.paragraph_spacing as usize {
|
||||
self.lines.push(blank.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a leading blank line a nested block inserted at `from`: it belongs
|
||||
/// to the item, not after it.
|
||||
fn unblank(&mut self, from: usize) {
|
||||
if self.lines.get(from).is_some_and(|line| line.trim().is_empty()) {
|
||||
self.lines.remove(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_aligned(cell: &str, width: usize, align: Option<Align>) -> String {
|
||||
let slack = width.saturating_sub(display_width(cell));
|
||||
match align {
|
||||
Some(Align::Right) => format!("{}{cell}", " ".repeat(slack)),
|
||||
Some(Align::Center) => {
|
||||
let left = slack / 2;
|
||||
format!("{}{}", " ".repeat(left), pad(cell, width - left))
|
||||
}
|
||||
_ => pad(cell, width),
|
||||
}
|
||||
}
|
||||
288
text/src/lib.rs
Normal file
288
text/src/lib.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
//! Fixed-width plain text, for Nex and later Gopher.
|
||||
//!
|
||||
//! One renderer, not two. md2txt ships a `text` and a `nex` renderer that differ
|
||||
//! in exactly two things — whether headings get FIGlet banners, and whether links
|
||||
//! are inlined or numbered — and both of those are now configuration. Its
|
||||
//! numbered form never writes the reference list its numbers point at, so the
|
||||
//! inline form is the only one that works and is the default here.
|
||||
//!
|
||||
//! Unlike gemtext this wraps, because Nex and Gopher clients do not.
|
||||
|
||||
mod inline;
|
||||
mod layout;
|
||||
|
||||
use itsybitsy_core::Error;
|
||||
use itsybitsy_core::ir::Doc;
|
||||
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
|
||||
|
||||
use crate::layout::Layout;
|
||||
|
||||
/// The classic 80-column convention, which Nex and Gopher readers expect.
|
||||
const DEFAULT_WIDTH: u16 = 80;
|
||||
|
||||
pub struct Text;
|
||||
|
||||
impl Renderer for Text {
|
||||
fn id(&self) -> &'static str {
|
||||
"text"
|
||||
}
|
||||
|
||||
fn media_type(&self) -> &'static str {
|
||||
"text/plain; charset=utf-8"
|
||||
}
|
||||
|
||||
fn default_width(&self) -> Option<u16> {
|
||||
Some(DEFAULT_WIDTH)
|
||||
}
|
||||
|
||||
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
|
||||
let mut layout = Layout::new(ctx.settings, ctx.width);
|
||||
layout.blocks(&doc.blocks);
|
||||
Ok(Rendered::body(layout.finish()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use itsybitsy_core::config::{HeadingStyle, PageSettings};
|
||||
use itsybitsy_core::ir::{Align, Block, Inline};
|
||||
use itsybitsy_core::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Render at a narrow width so wrapping is visible, with no margins so the
|
||||
/// assertions read as the content itself.
|
||||
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
|
||||
let doc = parse::markdown(markdown);
|
||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||
}
|
||||
|
||||
fn bare() -> PageSettings {
|
||||
PageSettings { margin_left: 0, margin_right: 0, ..Default::default() }
|
||||
}
|
||||
|
||||
fn render(markdown: &str) -> String {
|
||||
render_with(&bare(), 40, markdown)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_are_underlined_with_a_character_per_level() {
|
||||
assert_eq!(render("# One\n"), "One\n===\n");
|
||||
assert_eq!(render("## Two\n"), "Two\n---\n");
|
||||
assert_eq!(render("### Three\n"), "Three\n~~~~~\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeper_headings_keep_their_markers_since_underlines_run_out() {
|
||||
assert_eq!(render("#### Four\n"), "#### Four\n");
|
||||
assert_eq!(render("###### Six\n"), "###### Six\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_style_can_be_configured_per_level() {
|
||||
let mut settings = bare();
|
||||
settings.heading_styles[0] = HeadingStyle::Underline('*');
|
||||
assert_eq!(render_with(&settings, 40, "# One\n"), "One\n***\n");
|
||||
settings.heading_styles[0] = HeadingStyle::Plain;
|
||||
assert_eq!(render_with(&settings, 40, "# One\n"), "One\n");
|
||||
settings.heading_styles[0] = HeadingStyle::Markers;
|
||||
assert_eq!(render_with(&settings, 40, "# One\n"), "# One\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_are_wrapped_because_nex_clients_do_not() {
|
||||
assert_eq!(
|
||||
render("one two three four five six seven eight nine\n"),
|
||||
"one two three four five six seven eight\nnine\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margins_come_out_of_the_width() {
|
||||
let settings = PageSettings { margin_left: 2, margin_right: 2, ..Default::default() };
|
||||
// 40 columns less four of margin leaves 36 for content.
|
||||
let out = render_with(&settings, 40, "aaaa bbbb cccc dddd eeee ffff gggg hhhh\n");
|
||||
for line in out.lines().filter(|l| !l.is_empty()) {
|
||||
assert!(line.starts_with(" "), "{line:?}");
|
||||
assert!(line.len() <= 38, "{line:?} is {} wide", line.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_blank_line_separates_blocks_by_default() {
|
||||
// md2txt emits two, which reads as double-spaced throughout.
|
||||
assert_eq!(render("a\n\nb\n"), "a\n\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_separation_is_configurable() {
|
||||
let settings = PageSettings { paragraph_spacing: 2, ..bare() };
|
||||
assert_eq!(render_with(&settings, 40, "a\n\nb\n"), "a\n\n\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_hang_their_continuations_under_the_text() {
|
||||
assert_eq!(
|
||||
render("- one two three four five six seven eight\n- short\n"),
|
||||
"- one two three four five six seven\n eight\n- short\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordered_list_numbers_from_its_start() {
|
||||
assert_eq!(render("3. a\n4. b\n"), "3. a\n4. b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_list_is_indented_under_its_item() {
|
||||
assert_eq!(render("- a\n - b\n- c\n"), "- a\n - b\n- c\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_carry_a_bar() {
|
||||
assert_eq!(render("> a\n>\n> b\n"), "| a\n|\n| b\n");
|
||||
// Without bars the quote is still indented, or it would read as body text.
|
||||
let settings = PageSettings { blockquote_bars: false, ..bare() };
|
||||
assert_eq!(render_with(&settings, 40, "> a\n"), " a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_quote_gains_another_bar() {
|
||||
assert_eq!(render("> a\n>\n> > b\n"), "| a\n|\n| | b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_blocks_are_numbered_and_left_unwrapped() {
|
||||
// Wrapping code would change what it says.
|
||||
assert_eq!(
|
||||
render("```\nlet x = 1;\nlet y = 2;\n```\n"),
|
||||
"01 | let x = 1;\n02 | let y = 2;\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_numbering_can_be_turned_off() {
|
||||
let settings = PageSettings { code_block_line_numbers: false, ..bare() };
|
||||
assert_eq!(render_with(&settings, 40, "```\nx\n```\n"), "x\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_over_long_code_line_overflows_unless_wrapping_is_asked_for() {
|
||||
let long = "a".repeat(60);
|
||||
let settings = PageSettings { code_block_line_numbers: false, ..bare() };
|
||||
assert_eq!(render_with(&settings, 40, &format!("```\n{long}\n```\n")), format!("{long}\n"));
|
||||
|
||||
let wrapping = PageSettings { wrap_code_blocks: true, ..settings };
|
||||
let out = render_with(&wrapping, 40, &format!("```\n{long}\n```\n"));
|
||||
assert_eq!(out.lines().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_are_rendered_as_aligned_columns() {
|
||||
// md2txt drops tables entirely; this is the flaw that fixes.
|
||||
assert_eq!(
|
||||
render("| Format | Port |\n| --- | --- |\n| Nex | 1900 |\n"),
|
||||
"Format Port\n------ ----\nNex 1900\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tables_column_alignment_is_honoured() {
|
||||
assert_eq!(
|
||||
render("| name | n |\n| :--- | --: |\n| ab | 1 |\n| c | 22 |\n"),
|
||||
"name n\n---- --\nab 1\nc 22\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_spans_the_content_width() {
|
||||
let out = render("a\n\n---\n\nb\n");
|
||||
assert!(out.contains(&"-".repeat(40)), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_say_where_they_point() {
|
||||
assert_eq!(render("See [about](/about).\n"), "See about (/about).\n");
|
||||
}
|
||||
|
||||
/// Render blocks directly: alignment and card dividers are produced by
|
||||
/// `parse::document`, which needs a file, so the unit tests build them.
|
||||
fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec<Block>) -> String {
|
||||
let doc = Doc { blocks, first_h1: None };
|
||||
let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width) };
|
||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||
}
|
||||
|
||||
fn para(text: &str) -> Block {
|
||||
Block::Paragraph(vec![Inline::Text(text.into())])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_is_honoured_here_because_the_width_is_fixed() {
|
||||
// The one format family that can express it; gemtext and HTML cannot.
|
||||
let centred =
|
||||
Block::Aligned { align: Align::Center, margin: None, block: Box::new(para("abc")) };
|
||||
assert_eq!(render_blocks(&bare(), 11, vec![centred]), " abc\n");
|
||||
|
||||
let right =
|
||||
Block::Aligned { align: Align::Right, margin: None, block: Box::new(para("abc")) };
|
||||
assert_eq!(render_blocks(&bare(), 11, vec![right]), " abc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alignment_margin_holds_columns_back_on_both_sides() {
|
||||
let right =
|
||||
Block::Aligned { align: Align::Right, margin: Some(2), block: Box::new(para("abc")) };
|
||||
// 11 columns less two of inset each side leaves 7; "abc" ends at column 9.
|
||||
assert_eq!(render_blocks(&bare(), 11, vec![right]), " abc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_divider_emits_nothing_because_text_does_not_paginate() {
|
||||
let blocks = vec![para("a"), Block::CardBreak { title: Some("W".into()) }, para("b")];
|
||||
assert_eq!(render_blocks(&bare(), 40, blocks), "a\n\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_ends_in_exactly_one_newline_with_no_trailing_blanks() {
|
||||
for source in ["a\n", "# a\n", "- a\n", "a\n\n---\n"] {
|
||||
let out = render(source);
|
||||
assert!(out.ends_with('\n'), "{source:?} -> {out:?}");
|
||||
assert!(!out.ends_with("\n\n"), "{source:?} -> {out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod separation_tests {
|
||||
use itsybitsy_core::config::PageSettings;
|
||||
use itsybitsy_core::parse;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn render(markdown: &str) -> String {
|
||||
let doc = parse::markdown(markdown);
|
||||
let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() };
|
||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40) };
|
||||
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_after_a_list_is_still_separated_from_it() {
|
||||
// Regression: the flag that stops a list item gapping twice must not
|
||||
// outlive the list and swallow the next block's blank line.
|
||||
assert_eq!(render("- a\n- b\n\nafter\n"), "- a\n- b\n\nafter\n");
|
||||
assert_eq!(render("- a\n\n| h |\n| --- |\n| c |\n"), "- a\n\nh\n-\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_after_a_quote_is_still_separated_from_it() {
|
||||
assert_eq!(render("> q\n\nafter\n"), "| q\n\nafter\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_lists_are_separated() {
|
||||
assert_eq!(render("- a\n\n1. b\n"), "- a\n\n1. b\n");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue