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
15
Cargo.lock
generated
15
Cargo.lock
generated
|
|
@ -255,6 +255,7 @@ dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"itsybitsy-core",
|
"itsybitsy-core",
|
||||||
"itsybitsy-gemtext",
|
"itsybitsy-gemtext",
|
||||||
|
"itsybitsy-text",
|
||||||
"itsybitsy-wap",
|
"itsybitsy-wap",
|
||||||
"log",
|
"log",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
@ -269,6 +270,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -278,6 +280,13 @@ dependencies = [
|
||||||
"itsybitsy-core",
|
"itsybitsy-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsybitsy-text"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"itsybitsy-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsybitsy-wap"
|
name = "itsybitsy-wap"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -601,6 +610,12 @@ version = "1.0.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
# Format crates join at the milestone that implements them.
|
# Format crates join at the milestone that implements them.
|
||||||
members = ["core", "gemtext", "wap", "bin"]
|
members = ["core", "gemtext", "wap", "text", "bin"]
|
||||||
default-members = ["bin"]
|
default-members = ["bin"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -17,7 +17,7 @@ It serves. HTTP, Spartan and Nex are working, with gemtext, XHTML-MP and HTML ou
|
||||||
| Markdown parsing, includes, ASCII art, card breaks | implemented |
|
| Markdown parsing, includes, ASCII art, card breaks | implemented |
|
||||||
| gemtext, XHTML-MP and HTML output | implemented |
|
| gemtext, XHTML-MP and HTML output | implemented |
|
||||||
| HTTP, Spartan and Nex listeners, virtual hosting | implemented |
|
| HTTP, Spartan and Nex listeners, virtual hosting | implemented |
|
||||||
| 80-column text and Nex output | planned |
|
| Fixed-width text output (Nex, later Gopher) | implemented |
|
||||||
| WML and XHTML-MP decks | planned |
|
| WML and XHTML-MP decks | planned |
|
||||||
| Gemini and Gopher | planned |
|
| Gemini and Gopher | planned |
|
||||||
|
|
||||||
|
|
@ -51,14 +51,18 @@ A `.itsybitsy.toml` in any content directory says how the Markdown in **that dir
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[defaults]
|
[defaults]
|
||||||
cache_control = 3600
|
max_age = 3600
|
||||||
|
margin_left = 2
|
||||||
|
h1_style = "underline:="
|
||||||
|
|
||||||
[page."index.md"]
|
[page."index.md"]
|
||||||
title = "Notes"
|
title = "Notes"
|
||||||
cache_control = 300
|
max_age = 300
|
||||||
```
|
```
|
||||||
|
|
||||||
`[defaults]` applies to every Markdown file in the directory; a `[page."name.md"]` table overrides it for one file. `title` is per-page by nature, so setting it in `[defaults]` is an error; left unset, it is derived from the page's first level-1 heading. Each renderer's own keys arrive with that renderer. Editing the file re-renders that directory's pages on the next request, and a file that fails to parse makes them error rather than silently falling back to defaults.
|
`[defaults]` applies to every Markdown file in the directory; a `[page."name.md"]` table overrides it for one file. `title` is per-page by nature, so setting it in `[defaults]` is an error; left unset, it is derived from the page's first level-1 heading.
|
||||||
|
|
||||||
|
The fixed-width text format reads `margin_left`, `margin_right`, `paragraph_spacing`, `h1_style` through `h6_style`, `blockquote_bars`, `list_indent`, `code_block_line_numbers` and `wrap_code_blocks`. A heading style is `underline`, `underline:<char>`, `markers` or `plain`; by default the first three levels are underlined with `=`, `-` and `~`, and deeper ones keep their `#` markers, since underline characters run out before heading levels do. Editing the file re-renders that directory's pages on the next request, and a file that fails to parse makes them error rather than silently falling back to defaults.
|
||||||
|
|
||||||
Because the name begins with a dot, the same rule that keeps `.secret.md` out of the URL space keeps this file out of it too.
|
Because the name begins with a dot, the same rule that keeps `.secret.md` out of the URL space keeps this file out of it too.
|
||||||
|
|
||||||
|
|
@ -98,6 +102,8 @@ Include and art targets resolve relative to the file holding the reference and m
|
||||||
|
|
||||||
### Differences from smolweb
|
### Differences from smolweb
|
||||||
|
|
||||||
|
Configuration keys are not carried over verbatim either: md2txt accepts three spellings of `paragraph_spacing`, and its `cache_control` is named for the header it lands in rather than the value it holds, which here is `max_age`. Its `text` and `nex` renderers differ only in heading decoration and in whether links are inlined or numbered — and the numbered form never writes the reference list its numbers point at, so there is one text renderer here rather than two. Tables, which both of its text renderers drop entirely, are rendered as aligned columns.
|
||||||
|
|
||||||
smolweb inherits five invented directives from its two renderer libraries: `{.card}`, `{.include}`, `![[ ]]`, `#[label](art.txt)` and MultiMarkdown attribute lists (`{: .center}`) — two incompatible brace grammars, with art alignment expressible two different ways in one line. None of it appeared in real content, so itsybitsy expresses the same capabilities with standard constructs instead. Parsing once rather than once per library also means the text formats gain setext headings and the WAP formats gain tables and ASCII art, none of which their original parser handled.
|
smolweb inherits five invented directives from its two renderer libraries: `{.card}`, `{.include}`, `![[ ]]`, `#[label](art.txt)` and MultiMarkdown attribute lists (`{: .center}`) — two incompatible brace grammars, with art alignment expressible two different ways in one line. None of it appeared in real content, so itsybitsy expresses the same capabilities with standard constructs instead. Parsing once rather than once per library also means the text formats gain setext headings and the WAP formats gain tables and ASCII art, none of which their original parser handled.
|
||||||
|
|
||||||
## Output formats
|
## Output formats
|
||||||
|
|
@ -109,6 +115,7 @@ Each format is a crate implementing one trait, compiled in behind a cargo featur
|
||||||
| `gemtext` | `text/gemini` | Unwrapped; Gemini and Spartan clients wrap for themselves |
|
| `gemtext` | `text/gemini` | Unwrapped; Gemini and Spartan clients wrap for themselves |
|
||||||
| `xhtmlmp` | `application/vnd.wap.xhtml+xml` | Well-formed XML with the Mobile Profile doctype |
|
| `xhtmlmp` | `application/vnd.wap.xhtml+xml` | Well-formed XML with the Mobile Profile doctype |
|
||||||
| `html` | `text/html` | The same markup without the XML declaration |
|
| `html` | `text/html` | The same markup without the XML declaration |
|
||||||
|
| `text` | `text/plain` | Wrapped to 80 columns, because Nex and Gopher clients do not wrap |
|
||||||
|
|
||||||
HTTP negotiates between them from `Accept`. Only a literal media type counts as a match, so a browser's `*/*` can never be read as willingness to receive a WAP format; `?format=<id>` overrides negotiation outright, and naming an id the listener does not serve is a bad request rather than a silent fallback.
|
HTTP negotiates between them from `Accept`. Only a literal media type counts as a match, so a browser's `*/*` can never be read as willingness to receive a WAP format; `?format=<id>` overrides negotiation outright, and naming an id the listener does not serve is a bad request rather than a silent fallback.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,13 @@ name = "itsybitsy"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["http", "spartan", "nex", "gemtext", "wap"]
|
default = ["http", "spartan", "nex", "gemtext", "wap", "text"]
|
||||||
http = []
|
http = []
|
||||||
spartan = []
|
spartan = []
|
||||||
nex = []
|
nex = []
|
||||||
gemtext = ["dep:itsybitsy-gemtext"]
|
gemtext = ["dep:itsybitsy-gemtext"]
|
||||||
wap = ["dep:itsybitsy-wap"]
|
wap = ["dep:itsybitsy-wap"]
|
||||||
|
text = ["dep:itsybitsy-text"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
@ -25,6 +26,7 @@ clap = { version = "4.6", features = ["derive"] }
|
||||||
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
||||||
itsybitsy-core = { path = "../core" }
|
itsybitsy-core = { path = "../core" }
|
||||||
itsybitsy-gemtext = { path = "../gemtext", optional = true }
|
itsybitsy-gemtext = { path = "../gemtext", optional = true }
|
||||||
|
itsybitsy-text = { path = "../text", optional = true }
|
||||||
itsybitsy-wap = { path = "../wap", optional = true }
|
itsybitsy-wap = { path = "../wap", optional = true }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
|
||||||
head_only,
|
head_only,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let cache = page.settings.cache_control.map(|age| format!("max-age={age}"));
|
let cache = page.settings.max_age.map(|age| format!("max-age={age}"));
|
||||||
let mut headers: Vec<(&str, &str)> = Vec::new();
|
let mut headers: Vec<(&str, &str)> = Vec::new();
|
||||||
// The response body depends on Accept, so a shared cache must not
|
// The response body depends on Accept, so a shared cache must not
|
||||||
// serve one client's format to another.
|
// serve one client's format to another.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ pub fn build() -> Registry {
|
||||||
#[cfg(feature = "gemtext")]
|
#[cfg(feature = "gemtext")]
|
||||||
registry.insert(Arc::new(itsybitsy_gemtext::Gemtext)).expect("duplicate renderer id");
|
registry.insert(Arc::new(itsybitsy_gemtext::Gemtext)).expect("duplicate renderer id");
|
||||||
|
|
||||||
|
#[cfg(feature = "text")]
|
||||||
|
registry.insert(Arc::new(itsybitsy_text::Text)).expect("duplicate renderer id");
|
||||||
|
|
||||||
#[cfg(feature = "wap")]
|
#[cfg(feature = "wap")]
|
||||||
{
|
{
|
||||||
registry.insert(Arc::new(itsybitsy_wap::XhtmlMp)).expect("duplicate renderer id");
|
registry.insert(Arc::new(itsybitsy_wap::XhtmlMp)).expect("duplicate renderer id");
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ impl Server {
|
||||||
write(dir.path(), "one/index.md", "# One\n\nFirst site.\n");
|
write(dir.path(), "one/index.md", "# One\n\nFirst site.\n");
|
||||||
write(dir.path(), "one/about.md", "# About\n\nAbout the first.\n");
|
write(dir.path(), "one/about.md", "# About\n\nAbout the first.\n");
|
||||||
write(dir.path(), "one/img.png", "not really a png");
|
write(dir.path(), "one/img.png", "not really a png");
|
||||||
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\ncache_control = 120\n");
|
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\nmax_age = 120\n");
|
||||||
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
|
||||||
|
|
||||||
let text = format!(
|
let text = format!(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[defaults]
|
[defaults]
|
||||||
cache_control = 60
|
max_age = 60
|
||||||
|
|
||||||
[page."about.md"]
|
[page."about.md"]
|
||||||
title = "About itsybitsy"
|
title = "About itsybitsy"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ publish = false
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pulldown-cmark = { version = "0.13", default-features = false }
|
pulldown-cmark = { version = "0.13", default-features = false }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
unicode-width = "0.2"
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
/// 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>> {
|
fn group_shared_roots(roots: &BTreeMap<String, PathBuf>) -> Vec<Vec<String>> {
|
||||||
let mut by_root: BTreeMap<&PathBuf, Vec<String>> = BTreeMap::new();
|
let mut by_root: BTreeMap<&PathBuf, Vec<String>> = BTreeMap::new();
|
||||||
|
|
@ -391,18 +444,112 @@ pub struct DirConfig {
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PageKeys {
|
pub struct PageKeys {
|
||||||
pub title: Option<String>,
|
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
|
/// Settings resolved for one page: built-in defaults, then `[defaults]`, then
|
||||||
/// the file's own `[page."...'"]` table.
|
/// the file's own `[page."..."]` table.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PageSettings {
|
pub struct PageSettings {
|
||||||
/// An explicitly configured title. Absent means derive one, from the first
|
/// An explicitly configured title. Absent means derive one, from the first
|
||||||
/// level-1 heading if there is one and the file stem otherwise.
|
/// level-1 heading if there is one and the file stem otherwise.
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
/// `Cache-Control: max-age` in seconds. Absent means send no such header.
|
/// `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 {
|
impl DirConfig {
|
||||||
|
|
@ -446,17 +593,20 @@ impl DirConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settings for one file in this directory, by its bare name.
|
/// 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) {
|
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() {
|
if page.title.is_some() {
|
||||||
settings.title = page.title.clone();
|
settings.title = page.title.clone();
|
||||||
}
|
}
|
||||||
if page.cache_control.is_some() {
|
|
||||||
settings.cache_control = page.cache_control;
|
|
||||||
}
|
}
|
||||||
}
|
Ok(settings)
|
||||||
settings
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -687,26 +837,56 @@ mod dir_config_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn an_absent_file_means_built_in_defaults() {
|
fn an_absent_file_means_built_in_defaults() {
|
||||||
let config = DirConfig::default();
|
let config = DirConfig::default();
|
||||||
assert_eq!(config.settings_for("index.md"), PageSettings::default());
|
assert_eq!(config.settings_for("index.md").unwrap(), PageSettings::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_page_table_overrides_the_directory_defaults() {
|
fn a_page_table_overrides_the_directory_defaults() {
|
||||||
let config = load(
|
let config = load(
|
||||||
"[defaults]\ncache_control = 3600\n\n\
|
"[defaults]\nmax_age = 3600\nmargin_left = 4\n\n\
|
||||||
[page.\"index.md\"]\ntitle = \"Notes\"\ncache_control = 300\n",
|
[page.\"index.md\"]\ntitle = \"Notes\"\nmax_age = 300\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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.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,
|
// A file with no table of its own still gets the directory defaults, and
|
||||||
// and no title, which is the signal to derive one.
|
// no title, which is the signal to derive one.
|
||||||
let other = config.settings_for("about.md");
|
let other = config.settings_for("about.md").unwrap();
|
||||||
assert_eq!(other.title, None);
|
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]
|
#[test]
|
||||||
|
|
@ -736,7 +916,7 @@ mod dir_config_tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_parse_failure_surfaces_rather_than_falling_back() {
|
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}");
|
assert!(matches!(err, Error::Toml { .. }), "{err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,6 @@ pub mod preprocess;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod site;
|
pub mod site;
|
||||||
pub mod siteset;
|
pub mod siteset;
|
||||||
|
pub mod wrap;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ impl Site {
|
||||||
|
|
||||||
self.pages.get_or_insert_with(source, stamp, || {
|
self.pages.get_or_insert_with(source, stamp, || {
|
||||||
let name = source.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
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 doc = parse::document(source, &self.root)?;
|
||||||
let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
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))
|
self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem))
|
||||||
|
|
@ -324,7 +324,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_per_directory_config_is_never_served() {
|
fn the_per_directory_config_is_never_served() {
|
||||||
let (dir, site) = fixture();
|
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
|
// 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.
|
// its name and so would resolve literally, like img.png does.
|
||||||
assert_not_found(&site, "/.itsybitsy.toml");
|
assert_not_found(&site, "/.itsybitsy.toml");
|
||||||
|
|
@ -461,19 +461,19 @@ mod tests {
|
||||||
let root = dir.path();
|
let root = dir.path();
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join(DIR_CONFIG),
|
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();
|
.unwrap();
|
||||||
|
|
||||||
let (_, about) = document(&site, "/about");
|
let (_, about) = document(&site, "/about");
|
||||||
assert_eq!(about.title, "About Us");
|
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
|
// index.md shares the directory defaults; with no title configured it
|
||||||
// falls back to its own first heading.
|
// falls back to its own first heading.
|
||||||
let (_, index) = document(&site, "/");
|
let (_, index) = document(&site, "/");
|
||||||
assert_eq!(index.title, "Home");
|
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.
|
// The subdirectory does not inherit: it has no config of its own.
|
||||||
let (_, nested) = document(&site, "/dir");
|
let (_, nested) = document(&site, "/dir");
|
||||||
|
|
@ -484,19 +484,19 @@ mod tests {
|
||||||
fn an_edited_directory_config_takes_effect() {
|
fn an_edited_directory_config_takes_effect() {
|
||||||
let (dir, site) = fixture();
|
let (dir, site) = fixture();
|
||||||
let path = dir.path().join(DIR_CONFIG);
|
let path = dir.path().join(DIR_CONFIG);
|
||||||
fs::write(&path, "[defaults]\ncache_control = 60\n").unwrap();
|
fs::write(&path, "[defaults]\nmax_age = 60\n").unwrap();
|
||||||
assert_eq!(document(&site, "/about").1.settings.cache_control, Some(60));
|
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);
|
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]
|
#[test]
|
||||||
fn a_broken_directory_config_surfaces_as_an_error() {
|
fn a_broken_directory_config_surfaces_as_an_error() {
|
||||||
let (dir, site) = fixture();
|
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.
|
// Not a silent fall back to defaults: that is how a typo ships.
|
||||||
assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. })));
|
assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. })));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
core/src/wrap.rs
Normal file
151
core/src/wrap.rs
Normal 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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
use itsybitsy_core::Error;
|
use itsybitsy_core::Error;
|
||||||
use itsybitsy_core::ir::{Block, Doc, Inline};
|
use itsybitsy_core::ir::{Block, Doc, Inline};
|
||||||
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
|
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
|
||||||
|
use itsybitsy_core::wrap::{display_width, pad};
|
||||||
|
|
||||||
pub struct Gemtext;
|
pub struct Gemtext;
|
||||||
|
|
||||||
|
|
@ -175,7 +176,7 @@ impl Writer {
|
||||||
let widths: Vec<usize> = (0..columns)
|
let widths: Vec<usize> = (0..columns)
|
||||||
.map(|index| {
|
.map(|index| {
|
||||||
all.iter()
|
all.iter()
|
||||||
.map(|row| row.get(index).map_or(0, |c| c.chars().count()))
|
.map(|row| row.get(index).map_or(0, |cell| display_width(cell)))
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
})
|
})
|
||||||
|
|
@ -183,8 +184,9 @@ impl Writer {
|
||||||
|
|
||||||
self.line("```");
|
self.line("```");
|
||||||
for (index, row) in all.iter().enumerate() {
|
for (index, row) in all.iter().enumerate() {
|
||||||
|
// Padded by display width, so a wide character does not skew its column.
|
||||||
let cells: Vec<String> =
|
let cells: Vec<String> =
|
||||||
row.iter().zip(&widths).map(|(cell, width)| format!("{cell:<width$}")).collect();
|
row.iter().zip(&widths).map(|(cell, width)| pad(cell, *width)).collect();
|
||||||
self.line(&cells.join(" "));
|
self.line(&cells.join(" "));
|
||||||
// A rule under the header row, matching the padded column widths.
|
// A rule under the header row, matching the padded column widths.
|
||||||
if index == 0 && !head.is_empty() {
|
if index == 0 && !head.is_empty() {
|
||||||
|
|
@ -274,20 +276,29 @@ fn quoted(blocks: &[Block]) -> Vec<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests_support {
|
||||||
use itsybitsy_core::config::PageSettings;
|
use itsybitsy_core::config::PageSettings;
|
||||||
use itsybitsy_core::parse;
|
use itsybitsy_core::parse;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Render a Markdown fragment the way the server would.
|
/// Render a Markdown fragment the way the server would.
|
||||||
fn render(markdown: &str) -> String {
|
pub fn render(markdown: &str) -> String {
|
||||||
let doc = parse::markdown(markdown);
|
let doc = parse::markdown(markdown);
|
||||||
let settings = PageSettings::default();
|
let settings = PageSettings::default();
|
||||||
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None };
|
||||||
let out = Gemtext.render(&doc, &ctx).unwrap();
|
let out = Gemtext.render(&doc, &ctx).unwrap();
|
||||||
String::from_utf8(out.body).unwrap()
|
String::from_utf8(out.body).unwrap()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use itsybitsy_core::config::PageSettings;
|
||||||
|
use itsybitsy_core::parse;
|
||||||
|
|
||||||
|
use super::tests_support::render;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn headings_flatten_onto_three_levels() {
|
fn headings_flatten_onto_three_levels() {
|
||||||
|
|
@ -387,3 +398,22 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod width_tests {
|
||||||
|
use itsybitsy_core::wrap::display_width;
|
||||||
|
|
||||||
|
use super::tests_support::render;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_columns_align_by_display_width_not_character_count() {
|
||||||
|
// A CJK ideograph is two columns wide; counting characters would leave
|
||||||
|
// the column a column short for each one.
|
||||||
|
let out = render("| a | b |\n| --- | --- |\n| 日本 | x |\n");
|
||||||
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
|
// Compared in columns, not bytes: a multibyte cell shifts byte offsets
|
||||||
|
// without shifting where the text actually appears.
|
||||||
|
let column = |line: &str, ch: char| display_width(&line[..line.find(ch).unwrap()]);
|
||||||
|
assert_eq!(column(lines[1], 'b'), column(lines[3], 'x'), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,5 @@ default_site = "demo"
|
||||||
[listener.nex]
|
[listener.nex]
|
||||||
protocol = "nex"
|
protocol = "nex"
|
||||||
bind = "127.0.0.1:1900"
|
bind = "127.0.0.1:1900"
|
||||||
formats = ["gemtext"]
|
formats = ["text"]
|
||||||
site = "demo"
|
site = "demo"
|
||||||
|
|
|
||||||
14
text/Cargo.toml
Normal file
14
text/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "itsybitsy-text"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.97"
|
||||||
|
description = "Fixed-width plain text output for itsybitsy"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
itsybitsy-core = { path = "../core" }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
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