feat: render fixed-width text for nex

This commit is contained in:
randogoth 2026-10-04 20:27:29 +03:00
parent 2f38b8b929
commit 9e1cfed284
19 changed files with 1160 additions and 44 deletions

View file

@ -10,6 +10,7 @@ publish = false
[dependencies]
pulldown-cmark = { version = "0.13", default-features = false }
serde = { version = "1.0", features = ["derive"] }
unicode-width = "0.2"
toml = "1.1"
[dev-dependencies]

View file

@ -325,6 +325,59 @@ impl ServerConfig {
}
}
/// Overlay the keys a table set onto resolved settings, leaving the rest alone.
fn apply(keys: &PageKeys, settings: &mut PageSettings) -> Result<(), Error> {
if let Some(value) = keys.max_age {
settings.max_age = Some(value);
}
if let Some(value) = keys.margin_left {
settings.margin_left = value;
}
if let Some(value) = keys.margin_right {
settings.margin_right = value;
}
if let Some(value) = keys.paragraph_spacing {
settings.paragraph_spacing = value;
}
if let Some(value) = keys.blockquote_bars {
settings.blockquote_bars = value;
}
if let Some(value) = keys.list_indent {
settings.list_indent = value;
}
if let Some(value) = keys.code_block_line_numbers {
settings.code_block_line_numbers = value;
}
if let Some(value) = keys.wrap_code_blocks {
settings.wrap_code_blocks = value;
}
let levels = [
&keys.h1_style,
&keys.h2_style,
&keys.h3_style,
&keys.h4_style,
&keys.h5_style,
&keys.h6_style,
];
for (index, configured) in levels.iter().enumerate() {
let Some(value) = configured.as_deref() else { continue };
let default_rule = match DEFAULT_HEADING_STYLES[index] {
HeadingStyle::Underline(rule) => rule,
_ => '-',
};
settings.heading_styles[index] =
HeadingStyle::parse(value, default_rule).ok_or_else(|| {
Error::config(format!(
"h{}_style = '{value}' is not one of underline, underline:<char>, markers \
or plain",
index + 1
))
})?;
}
Ok(())
}
/// Site names that canonicalise to the same root, so they can share one `Site`.
fn group_shared_roots(roots: &BTreeMap<String, PathBuf>) -> Vec<Vec<String>> {
let mut by_root: BTreeMap<&PathBuf, Vec<String>> = BTreeMap::new();
@ -391,18 +444,112 @@ pub struct DirConfig {
#[serde(deny_unknown_fields)]
pub struct PageKeys {
pub title: Option<String>,
pub cache_control: Option<u32>,
/// `Cache-Control: max-age` in seconds. Named for the value it holds rather
/// than for the header it ends up in.
pub max_age: Option<u32>,
// Fixed-width text layout. Only the text formats read these; gemtext is
// unwrapped and the WAP formats lay themselves out.
pub margin_left: Option<u16>,
pub margin_right: Option<u16>,
/// Blank lines between blocks.
pub paragraph_spacing: Option<u16>,
/// How a heading of each level is decorated. See [`HeadingStyle`].
pub h1_style: Option<String>,
pub h2_style: Option<String>,
pub h3_style: Option<String>,
pub h4_style: Option<String>,
pub h5_style: Option<String>,
pub h6_style: Option<String>,
/// Prefix quoted text with `| ` rather than only indenting it.
pub blockquote_bars: Option<bool>,
/// Columns a nested list is indented by.
pub list_indent: Option<u16>,
pub code_block_line_numbers: Option<bool>,
/// Wrap over-long code lines. Off by default, because wrapping code changes
/// what it says; on, it is better than losing the end of the line.
pub wrap_code_blocks: Option<bool>,
}
/// How a heading is decorated in the fixed-width formats.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeadingStyle {
/// Underlined with the given character.
Underline(char),
/// Prefixed with `#` markers, as the source writes it.
Markers,
/// The text alone.
Plain,
}
impl HeadingStyle {
/// Parse a configured value: `underline`, `underline:<char>`, `markers` or
/// `plain`. `default_rule` is the character bare `underline` uses.
fn parse(value: &str, default_rule: char) -> Option<Self> {
let (name, argument) = value.split_once(':').unwrap_or((value, ""));
match name.trim() {
"underline" => {
let rule = argument.trim().chars().next().unwrap_or(default_rule);
Some(HeadingStyle::Underline(rule))
}
"markers" => Some(HeadingStyle::Markers),
"plain" => Some(HeadingStyle::Plain),
_ => None,
}
}
}
/// Built-in decoration per heading level: the first three are underlined with a
/// distinct character each, and the rest keep their `#` markers, which is both
/// what a plain-text reader expects and unambiguous past three levels.
const DEFAULT_HEADING_STYLES: [HeadingStyle; 6] = [
HeadingStyle::Underline('='),
HeadingStyle::Underline('-'),
HeadingStyle::Underline('~'),
HeadingStyle::Markers,
HeadingStyle::Markers,
HeadingStyle::Markers,
];
/// Settings resolved for one page: built-in defaults, then `[defaults]`, then
/// the file's own `[page."...'"]` table.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
/// the file's own `[page."..."]` table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageSettings {
/// An explicitly configured title. Absent means derive one, from the first
/// level-1 heading if there is one and the file stem otherwise.
pub title: Option<String>,
/// `Cache-Control: max-age` in seconds. Absent means send no such header.
pub cache_control: Option<u32>,
pub max_age: Option<u32>,
pub margin_left: u16,
pub margin_right: u16,
pub paragraph_spacing: u16,
/// Indexed by heading level minus one.
pub heading_styles: [HeadingStyle; 6],
pub blockquote_bars: bool,
pub list_indent: u16,
pub code_block_line_numbers: bool,
pub wrap_code_blocks: bool,
}
impl Default for PageSettings {
fn default() -> Self {
PageSettings {
title: None,
max_age: None,
// A small margin on both sides, so text does not start hard against
// the terminal edge.
margin_left: 2,
margin_right: 2,
// One blank line between blocks. md2txt uses two, which reads as
// double-spaced throughout.
paragraph_spacing: 1,
heading_styles: DEFAULT_HEADING_STYLES,
blockquote_bars: true,
list_indent: 2,
code_block_line_numbers: true,
wrap_code_blocks: false,
}
}
}
impl DirConfig {
@ -446,17 +593,20 @@ impl DirConfig {
}
/// Settings for one file in this directory, by its bare name.
pub fn settings_for(&self, file_name: &str) -> PageSettings {
let mut settings = PageSettings { title: None, cache_control: self.defaults.cache_control };
///
/// Built-in defaults, then `[defaults]`, then the file's own table. A key the
/// file does not mention keeps whatever the directory said.
pub fn settings_for(&self, file_name: &str) -> Result<PageSettings, Error> {
let mut settings = PageSettings::default();
apply(&self.defaults, &mut settings)?;
if let Some(page) = self.page.get(file_name) {
apply(page, &mut settings)?;
// Only a page may name itself, which is why this is not in `apply`.
if page.title.is_some() {
settings.title = page.title.clone();
}
if page.cache_control.is_some() {
settings.cache_control = page.cache_control;
}
}
settings
Ok(settings)
}
}
@ -687,26 +837,56 @@ mod dir_config_tests {
#[test]
fn an_absent_file_means_built_in_defaults() {
let config = DirConfig::default();
assert_eq!(config.settings_for("index.md"), PageSettings::default());
assert_eq!(config.settings_for("index.md").unwrap(), PageSettings::default());
}
#[test]
fn a_page_table_overrides_the_directory_defaults() {
let config = load(
"[defaults]\ncache_control = 3600\n\n\
[page.\"index.md\"]\ntitle = \"Notes\"\ncache_control = 300\n",
"[defaults]\nmax_age = 3600\nmargin_left = 4\n\n\
[page.\"index.md\"]\ntitle = \"Notes\"\nmax_age = 300\n",
)
.unwrap();
let index = config.settings_for("index.md");
let index = config.settings_for("index.md").unwrap();
assert_eq!(index.title.as_deref(), Some("Notes"));
assert_eq!(index.cache_control, Some(300));
assert_eq!(index.max_age, Some(300));
// A key the page does not mention keeps what the directory said.
assert_eq!(index.margin_left, 4);
// A file with no table of its own still gets the directory defaults,
// and no title, which is the signal to derive one.
let other = config.settings_for("about.md");
// A file with no table of its own still gets the directory defaults, and
// no title, which is the signal to derive one.
let other = config.settings_for("about.md").unwrap();
assert_eq!(other.title, None);
assert_eq!(other.cache_control, Some(3600));
assert_eq!(other.max_age, Some(3600));
assert_eq!(other.margin_left, 4);
}
#[test]
fn heading_styles_are_configurable_per_level() {
let config =
load("[defaults]\nh1_style = \"underline:*\"\nh4_style = \"plain\"\n").unwrap();
let settings = config.settings_for("x.md").unwrap();
assert_eq!(settings.heading_styles[0], HeadingStyle::Underline('*'));
assert_eq!(settings.heading_styles[3], HeadingStyle::Plain);
// Levels left alone keep their built-in decoration.
assert_eq!(settings.heading_styles[1], HeadingStyle::Underline('-'));
}
#[test]
fn a_bare_underline_keeps_the_levels_own_character() {
let config = load("[defaults]\nh2_style = \"underline\"\n").unwrap();
assert_eq!(
config.settings_for("x.md").unwrap().heading_styles[1],
HeadingStyle::Underline('-')
);
}
#[test]
fn an_unknown_heading_style_is_an_error_naming_the_alternatives() {
let config = load("[defaults]\nh1_style = \"figlet\"\n").unwrap();
let err = config.settings_for("x.md").unwrap_err();
assert!(err.to_string().contains("underline, underline:<char>"), "{err}");
}
#[test]
@ -736,7 +916,7 @@ mod dir_config_tests {
#[test]
fn a_parse_failure_surfaces_rather_than_falling_back() {
let err = load("[defaults]\ncache_control =\n").unwrap_err();
let err = load("[defaults]\nmax_age =\n").unwrap_err();
assert!(matches!(err, Error::Toml { .. }), "{err}");
}
}

View file

@ -16,5 +16,6 @@ pub mod preprocess;
pub mod render;
pub mod site;
pub mod siteset;
pub mod wrap;
pub use error::Error;

View file

@ -161,7 +161,7 @@ impl Site {
self.pages.get_or_insert_with(source, stamp, || {
let name = source.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let settings = self.dir_config(dir)?.settings_for(name);
let settings = self.dir_config(dir)?.settings_for(name)?;
let doc = parse::document(source, &self.root)?;
let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem))
@ -324,7 +324,7 @@ mod tests {
#[test]
fn the_per_directory_config_is_never_served() {
let (dir, site) = fixture();
fs::write(dir.path().join(DIR_CONFIG), "[defaults]\ncache_control = 60\n").unwrap();
fs::write(dir.path().join(DIR_CONFIG), "[defaults]\nmax_age = 60\n").unwrap();
// It is not a dotfile by accident: without that rule it carries a dot in
// its name and so would resolve literally, like img.png does.
assert_not_found(&site, "/.itsybitsy.toml");
@ -461,19 +461,19 @@ mod tests {
let root = dir.path();
fs::write(
root.join(DIR_CONFIG),
"[defaults]\ncache_control = 3600\n\n[page.\"about.md\"]\ntitle = \"About Us\"\n",
"[defaults]\nmax_age = 3600\n\n[page.\"about.md\"]\ntitle = \"About Us\"\n",
)
.unwrap();
let (_, about) = document(&site, "/about");
assert_eq!(about.title, "About Us");
assert_eq!(about.settings.cache_control, Some(3600));
assert_eq!(about.settings.max_age, Some(3600));
// index.md shares the directory defaults; with no title configured it
// falls back to its own first heading.
let (_, index) = document(&site, "/");
assert_eq!(index.title, "Home");
assert_eq!(index.settings.cache_control, Some(3600));
assert_eq!(index.settings.max_age, Some(3600));
// The subdirectory does not inherit: it has no config of its own.
let (_, nested) = document(&site, "/dir");
@ -484,19 +484,19 @@ mod tests {
fn an_edited_directory_config_takes_effect() {
let (dir, site) = fixture();
let path = dir.path().join(DIR_CONFIG);
fs::write(&path, "[defaults]\ncache_control = 60\n").unwrap();
assert_eq!(document(&site, "/about").1.settings.cache_control, Some(60));
fs::write(&path, "[defaults]\nmax_age = 60\n").unwrap();
assert_eq!(document(&site, "/about").1.settings.max_age, Some(60));
fs::write(&path, "[defaults]\ncache_control = 120\n").unwrap();
fs::write(&path, "[defaults]\nmax_age = 120\n").unwrap();
bump_mtime(&path);
assert_eq!(document(&site, "/about").1.settings.cache_control, Some(120));
assert_eq!(document(&site, "/about").1.settings.max_age, Some(120));
}
#[test]
fn a_broken_directory_config_surfaces_as_an_error() {
let (dir, site) = fixture();
fs::write(dir.path().join(DIR_CONFIG), "[defaults]\ncache_control = \"soon\"\n").unwrap();
fs::write(dir.path().join(DIR_CONFIG), "[defaults]\nmax_age = \"soon\"\n").unwrap();
// Not a silent fall back to defaults: that is how a typo ships.
assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. })));
}

151
core/src/wrap.rs Normal file
View file

@ -0,0 +1,151 @@
//! Measuring and wrapping text for the fixed-width formats.
//!
//! Shared rather than per-format so that a table's column alignment and a
//! paragraph's line breaks agree about how wide a character is. Width is counted
//! in terminal columns, not bytes or scalar values: a CJK ideograph occupies two
//! columns, and a combining mark none.
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Columns `text` occupies when printed.
pub fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
/// Pad `text` on the right to `width` columns.
pub fn pad(text: &str, width: usize) -> String {
let mut out = text.to_string();
for _ in display_width(text)..width {
out.push(' ');
}
out
}
/// Break `text` into lines no wider than `width` columns.
///
/// Greedy: each line takes as many words as fit. A word wider than the whole
/// width is split rather than left to overflow, since the formats this serves
/// have no horizontal scroll. A width of zero means do not wrap.
pub fn wrap(text: &str, width: usize) -> Vec<String> {
let text = text.trim();
if width == 0 {
return if text.is_empty() { Vec::new() } else { vec![text.to_string()] };
}
let mut lines = Vec::new();
let mut line = String::new();
let mut line_width = 0;
for word in text.split_whitespace() {
for piece in split_to_fit(word, width) {
let piece_width = display_width(&piece);
// The +1 is the space that would join it to what is already there.
if line_width > 0 && line_width + 1 + piece_width > width {
lines.push(std::mem::take(&mut line));
line_width = 0;
}
if line_width > 0 {
line.push(' ');
line_width += 1;
}
line.push_str(&piece);
line_width += piece_width;
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
/// Split one word into chunks that each fit `width`, on column boundaries.
///
/// A word that already fits comes back whole, which is the common case.
fn split_to_fit(word: &str, width: usize) -> Vec<String> {
if display_width(word) <= width {
return vec![word.to_string()];
}
let mut chunks = Vec::new();
let mut chunk = String::new();
let mut chunk_width = 0;
for ch in word.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if chunk_width + ch_width > width && !chunk.is_empty() {
chunks.push(std::mem::take(&mut chunk));
chunk_width = 0;
}
chunk.push(ch);
chunk_width += ch_width;
}
if !chunk.is_empty() {
chunks.push(chunk);
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_in_terminal_columns() {
assert_eq!(display_width("abc"), 3);
// A CJK ideograph occupies two columns, not one character's worth.
assert_eq!(display_width("日本語"), 6);
// A combining mark occupies none.
assert_eq!(display_width("e\u{301}"), 1);
}
#[test]
fn pads_to_a_column_count() {
assert_eq!(pad("ab", 5), "ab ");
assert_eq!(pad("日本", 6), "日本 ");
// Already at or over the width, so nothing is added.
assert_eq!(pad("abcde", 5), "abcde");
assert_eq!(pad("abcdef", 5), "abcdef");
}
#[test]
fn fills_each_line_greedily() {
assert_eq!(wrap("one two three four", 9), vec!["one two", "three", "four"]);
assert_eq!(wrap("aaa bbb", 7), vec!["aaa bbb"], "an exact fit stays on one line");
}
#[test]
fn collapses_runs_of_whitespace() {
assert_eq!(wrap("a b\n\tc", 80), vec!["a b c"]);
}
#[test]
fn a_word_wider_than_the_line_is_split_rather_than_overflowing() {
// These formats have no horizontal scroll, so overflow would be lost.
assert_eq!(wrap("aaaaaaaa", 3), vec!["aaa", "aaa", "aa"]);
assert_eq!(wrap("ok aaaaa", 3), vec!["ok", "aaa", "aa"]);
}
#[test]
fn a_split_never_lands_mid_column() {
// Splitting between the two columns of a wide character would corrupt it.
assert_eq!(wrap("日本語", 3), vec!["日", "本", "語"]);
assert_eq!(wrap("日本語", 4), vec!["日本", "語"]);
}
#[test]
fn a_width_of_zero_means_do_not_wrap() {
assert_eq!(wrap("one two three", 0), vec!["one two three"]);
assert!(wrap("", 0).is_empty());
}
#[test]
fn empty_input_produces_no_lines() {
assert!(wrap("", 20).is_empty());
assert!(wrap(" \n ", 20).is_empty());
}
#[test]
fn no_line_carries_trailing_whitespace() {
for line in wrap("one two three four five six", 10) {
assert_eq!(line.trim_end(), line, "{line:?}");
}
}
}