feat: add inline image rendering and remove bundled vault

- Add ratatui-image and image crates for terminal image support
- Auto-detect terminal protocol (Kitty/iTerm2/Sixel) with halfblocks fallback
- Render inline images with Protocol API, cache decoded images
- Handle partial scroll visibility via temp-buffer rendering
- Support alpha compositing (RGBA→RGB) and dynamic height adjustment
- Add force_halfblocks config option to bypass protocol detection
- Suppress link brackets around image-wrapped links
- Remove bundled vault files (demo content moved to test vault)
- Add .gitignore for target dir and .DS_Store
This commit is contained in:
2026-03-01 15:17:47 +01:00
parent f7870179ee
commit 8ef587c163
19 changed files with 1950 additions and 703 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
.DS_Store
Generated
+1381 -10
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,3 +16,5 @@ notify = "6.1"
ansi-to-tui = "8.0"
walkdir = "2.5"
ureq = "2.12"
ratatui-image = { version = "10.0", default-features = false }
image = "0.25"
+394 -13
View File
@@ -20,7 +20,8 @@
//! - `Err(BrokenPipe)` — SSH connection closed mid-write (silent exit)
//! - `Err(_)` — unexpected I/O error (logged to stderr after terminal restore)
use std::io::{self, Write};
use std::collections::HashMap;
use std::io::{self, Read as _, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::time::{Duration, Instant, UNIX_EPOCH};
@@ -28,6 +29,9 @@ use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, Mo
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher};
use ratatui_image::picker::Picker;
use ratatui_image::protocol::Protocol;
use ratatui_image::{Image as ImageWidget, Resize};
use crate::config::Config;
use crate::terminal::Term;
use crate::signals::SignalFlags;
@@ -171,6 +175,18 @@ struct HistoryEntry {
selected_link: Option<usize>,
}
// ── ImageCacheEntry ────────────────────────────────────────────────────────
/// Cache entry for a loaded (or failed) image.
enum ImageCacheEntry {
/// Image loaded and pre-encoded at a fixed size via `picker.new_protocol()`.
Loaded {
protocol: Protocol,
},
/// Image failed to load — [IMAGE: alt] placeholder stays visible.
Failed,
}
// ── App ───────────────────────────────────────────────────────────────────────
/// Application state for the bbs-md event loop.
@@ -209,6 +225,9 @@ pub struct App {
/// Height of the content area from the last draw, used for page scrolling.
last_content_height: u16,
/// Width of the content area from the last draw, used for image protocol sizing.
last_content_width: u16,
// ── Phase 3 additions ─────────────────────────────────────────────────────
/// Browser-style navigation history. Vec of visited pages with state.
history: Vec<HistoryEntry>,
@@ -250,6 +269,14 @@ pub struct App {
copy_mode: bool,
/// Index of the currently selected copyable block in copy mode (None = none selected).
selected_block: Option<usize>,
// ── Image support ────────────────────────────────────────────────────────
/// Terminal image protocol picker (auto-detects Kitty/iTerm2/Sixel/halfblocks).
picker: Picker,
/// Image records from the current rendered document.
image_records: Vec<crate::renderer::ImageRecord>,
/// Cache of loaded images keyed by source string.
image_cache: HashMap<String, ImageCacheEntry>,
}
impl App {
@@ -271,8 +298,10 @@ impl App {
raw_content: Option<String>,
link_records: Vec<crate::renderer::LinkRecord>,
copyable_blocks: Vec<crate::renderer::CopyableBlock>,
image_records: Vec<crate::renderer::ImageRecord>,
current_path: String,
file_watcher: Option<FileWatcher>,
picker: Picker,
) -> Self {
let filename = match &document {
DocumentState::Loaded { filename, .. } => filename.clone(),
@@ -303,6 +332,7 @@ impl App {
raw_content,
filename,
last_content_height: 24,
last_content_width: 80,
history: initial_history,
history_index: 0,
link_records,
@@ -319,6 +349,9 @@ impl App {
copy_feedback_until: None,
copy_mode: false,
selected_block: None,
picker,
image_records,
image_cache: HashMap::new(),
}
}
@@ -348,6 +381,9 @@ impl App {
return Ok(ShutdownReason::Signal);
}
// 1a. Lazy-load images that are now visible in the viewport
self.ensure_visible_images_loaded();
// 2. Draw the UI
// If draw() returns BrokenPipe, the ? propagates it up.
// main.rs catches BrokenPipe and exits cleanly (LIFE-04).
@@ -449,7 +485,7 @@ impl App {
let width = new_width.saturating_sub(self.config.margin * 2);
// Remote pages have no vault context — wiki-links cannot resolve
let vault_ref = if self.current_url.is_some() { None } else { Some(&*vault_path) };
let (mut lines, mut link_records, mut copyable_blocks) = crate::renderer::render_markdown(
let (mut lines, mut link_records, mut copyable_blocks, mut image_records) = crate::renderer::render_markdown(
content,
width,
vault_ref,
@@ -470,11 +506,16 @@ impl App {
record.start_line += splash_count;
record.end_line += splash_count;
}
for record in &mut image_records {
record.line_index += splash_count;
}
}
}
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
// Preserve selected_link if still valid after re-render
if let Some(i) = self.selected_link {
if i >= self.link_records.len() {
@@ -832,6 +873,215 @@ impl App {
}
}
// ── Image support ──────────────────────────────────────────────────────────
/// Load an image from a local path or remote URL.
///
/// - **Remote** (`http://`/`https://`): validates against `config.allowed_remote_domains`,
/// fetches via `ureq` (10s timeout, 10MB cap), decodes with the `image` crate.
/// - **Local**: resolves relative to the current document's directory in the vault,
/// guards with `vault::is_within_vault()`, decodes with the `image` crate.
fn load_image(&self, source: &str) -> Result<image::DynamicImage, String> {
// Resolve relative image paths against the remote document's base URL
let is_absolute_url = source.starts_with("http://") || source.starts_with("https://");
if !is_absolute_url {
if let Some(ref base_url) = self.current_url {
// Strip filename from base URL to get the directory
let base = if let Some(pos) = base_url.rfind('/') {
&base_url[..pos + 1]
} else {
base_url.as_str()
};
// Strip leading "./" from source if present
let clean_source = source.strip_prefix("./").unwrap_or(source);
let resolved = format!("{}{}", base, clean_source);
return self.load_image(&resolved);
}
}
if is_absolute_url {
// Remote image — check domain whitelist
let domain = url_domain(source).map_err(|_| "Invalid URL".to_string())?;
if !crate::vault::domain_is_allowed(&domain, &self.config.allowed_remote_domains) {
return Err(format!("Domain '{}' not in allowed_remote_domains", domain));
}
let response = ureq::get(source)
.timeout(std::time::Duration::from_secs(10))
.call()
.map_err(|e| format!("Fetch error: {}", e))?;
let max_image = self.config.max_remote_image_size as usize;
let content_length = response.header("content-length")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0);
if content_length > max_image {
return Err(format!("Image too large (>{} bytes)", max_image));
}
let mut bytes = Vec::new();
response.into_reader()
.take(max_image as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| format!("Read error: {}", e))?;
if bytes.len() > max_image {
return Err(format!("Image too large (>{} bytes)", max_image));
}
image::load_from_memory(&bytes)
.map_err(|e| format!("Decode error: {}", e))
} else {
// Local image — resolve relative to current document's directory
let vault_path = &self.config.vault_path;
let doc_dir = vault_path.join(&self.current_path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| vault_path.clone());
let full_path = doc_dir.join(source);
let full_path = full_path.canonicalize()
.map_err(|e| format!("Path error: {}", e))?;
if !crate::vault::is_within_vault(vault_path, &full_path) {
return Err("Image path outside vault".to_string());
}
image::open(&full_path)
.map_err(|e| format!("Decode error: {}", e))
}
}
/// Ensure all images whose reserved lines overlap the viewport are loaded into cache.
///
/// Called just before `terminal.draw()` in the event loop. Only loads images
/// that are visible, avoiding upfront loading of off-screen images.
/// After loading, adjusts the reserved blank lines to match the actual image height.
fn ensure_visible_images_loaded(&mut self) {
let viewport_start = self.scroll_offset as usize;
let viewport_end = viewport_start + self.last_content_height as usize;
// Collect sources that need loading
let to_load: Vec<String> = self.image_records.iter()
.filter(|rec| {
let img_start = rec.line_index;
let img_end = rec.line_index + rec.height as usize;
img_start < viewport_end && img_end > viewport_start
})
.filter(|rec| !self.image_cache.contains_key(&rec.source))
.map(|rec| rec.source.clone())
.collect();
for source in to_load {
match self.load_image(&source) {
Ok(dyn_image) => {
// Flatten alpha channel: composite transparent pixels onto the
// terminal background so halfblocks doesn't render them as black.
// We convert RGBA→RGB by blending against transparent, then wrap
// back as a DynamicImage. This avoids the halfblocks primitive
// encoder's `to_rgb8()` which composites against solid black and
// writes explicit Rgb(0,0,0) cells instead of the terminal default.
let rgba = dyn_image.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
let mut rgb_buf = image::RgbImage::new(w, h);
for (x, y, pixel) in rgba.enumerate_pixels() {
let a = pixel[3] as f32 / 255.0;
// Composite against terminal background (assume black)
let r = (pixel[0] as f32 * a) as u8;
let g = (pixel[1] as f32 * a) as u8;
let b = (pixel[2] as f32 * a) as u8;
rgb_buf.put_pixel(x, y, image::Rgb([r, g, b]));
}
let opaque = image::DynamicImage::ImageRgb8(rgb_buf);
// Pre-encode at fixed size using the stateless Protocol API.
// Target rect: full content width, generous max height.
let target_rect = Rect::new(0, 0, self.last_content_width, 100);
match self.picker.new_protocol(opaque, target_rect, Resize::Fit(None)) {
Ok(protocol) => {
let actual_height = protocol.area().height;
self.image_cache.insert(source.clone(), ImageCacheEntry::Loaded {
protocol,
});
self.adjust_image_height(&source, actual_height);
}
Err(_) => {
self.image_cache.insert(source.clone(), ImageCacheEntry::Failed);
// Shrink placeholder to 1 line for the fallback text
self.adjust_image_height(&source, 1);
}
}
}
Err(_) => {
self.image_cache.insert(source.clone(), ImageCacheEntry::Failed);
// Shrink placeholder to 1 line for the fallback text
self.adjust_image_height(&source, 1);
}
}
}
}
/// Adjust the reserved blank lines in the document for a freshly loaded image.
///
/// When the actual rendered height differs from the placeholder height reserved
/// by the renderer, inserts or removes blank lines and shifts all subsequent
/// records (image_records, link_records, copyable_blocks) by the delta.
fn adjust_image_height(&mut self, source: &str, actual_height: u16) {
// Find the image record by source
let rec_idx = match self.image_records.iter().position(|r| r.source == source) {
Some(i) => i,
None => return,
};
let old_height = self.image_records[rec_idx].height;
if actual_height == old_height {
return;
}
let line_index = self.image_records[rec_idx].line_index;
let delta = actual_height as isize - old_height as isize;
// Modify the document lines
if let DocumentState::Loaded { ref mut lines, .. } = self.document {
if delta > 0 {
// Insert extra blank lines after the current reserved space
let insert_at = (line_index + old_height as usize).min(lines.len());
for _ in 0..delta {
lines.insert(insert_at, Line::default());
}
} else {
// Remove excess blank lines from the end of the reserved space
let remove_count = (-delta) as usize;
let remove_start = line_index + actual_height as usize;
for _ in 0..remove_count {
if remove_start < lines.len() {
lines.remove(remove_start);
}
}
}
}
// Update this image record's height
self.image_records[rec_idx].height = actual_height;
// Shift all records that come after this image's line_index by delta
for rec in &mut self.image_records {
if rec.line_index > line_index {
rec.line_index = (rec.line_index as isize + delta) as usize;
}
}
for rec in &mut self.link_records {
if rec.line_index > line_index {
rec.line_index = (rec.line_index as isize + delta) as usize;
}
}
for rec in &mut self.copyable_blocks {
if rec.start_line > line_index {
rec.start_line = (rec.start_line as isize + delta) as usize;
rec.end_line = (rec.end_line as isize + delta) as usize;
}
}
}
// ── Navigation methods ─────────────────────────────────────────────────────
/// Navigate to a new document by vault-relative path.
@@ -862,7 +1112,7 @@ impl App {
.map(|(w, _)| w)
.unwrap_or(80)
.saturating_sub(self.config.margin * 2);
let (mut lines, mut link_records, mut copyable_blocks) =
let (mut lines, mut link_records, mut copyable_blocks, mut image_records) =
crate::renderer::render_markdown(&content, width, Some(&vault_path));
// Read file metadata for status bar
@@ -884,6 +1134,9 @@ impl App {
record.start_line += splash_count;
record.end_line += splash_count;
}
for record in &mut image_records {
record.line_index += splash_count;
}
}
}
@@ -894,6 +1147,8 @@ impl App {
self.raw_content = Some(content);
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
self.selected_link = None;
self.link_mode = false;
self.copy_mode = false;
@@ -917,6 +1172,8 @@ impl App {
self.raw_content = None;
self.link_records = Vec::new();
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.selected_link = None;
self.copy_mode = false;
self.selected_block = None;
@@ -927,6 +1184,8 @@ impl App {
self.raw_content = None;
self.link_records = Vec::new();
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.selected_link = None;
self.copy_mode = false;
self.selected_block = None;
@@ -962,6 +1221,8 @@ impl App {
self.raw_content = None; // No raw markdown for virtual pages
self.link_records = link_records;
self.copyable_blocks = Vec::new(); // No copyable blocks in directory listing
self.image_records = Vec::new();
self.image_cache.clear();
self.selected_link = if self.link_records.is_empty() { None } else { Some(0) };
self.link_mode = true; // Directory is always in link mode
self.copy_mode = false;
@@ -1022,7 +1283,7 @@ impl App {
.map(|(w, _)| w)
.unwrap_or(80)
.saturating_sub(self.config.margin * 2);
let (mut lines, mut link_records, mut copyable_blocks) =
let (mut lines, mut link_records, mut copyable_blocks, mut image_records) =
crate::renderer::render_markdown(&content, width, Some(&vault_path));
// Splash prepend for index.md (same logic as navigate_to)
@@ -1041,6 +1302,9 @@ impl App {
record.start_line += splash_count;
record.end_line += splash_count;
}
for record in image_records.iter_mut() {
record.line_index += splash_count;
}
}
}
@@ -1051,6 +1315,8 @@ impl App {
self.raw_content = Some(content);
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
self.filename = filename;
// Update metadata
@@ -1133,6 +1399,8 @@ impl App {
self.raw_content = None;
self.link_records = link_records;
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.selected_link = target_link;
self.link_mode = true; // Directory is always in link mode
self.copy_mode = false;
@@ -1147,7 +1415,7 @@ impl App {
// Temporarily move history_index back so navigate_to_remote doesn't double-push
// We call navigate_to_remote which will push a new entry, so we correct after.
// Simpler: inline the remote fetch logic here with scroll/link restoration.
match crate::vault::fetch_remote_markdown(&target_path, &self.config.allowed_remote_domains) {
match crate::vault::fetch_remote_markdown(&target_path, &self.config.allowed_remote_domains, self.config.max_remote_document_size) {
crate::vault::RemoteDocument::Loaded { url: fetched_url, content } => {
let filename = fetched_url
.split('?').next().unwrap_or(&fetched_url)
@@ -1159,12 +1427,14 @@ impl App {
let width = ratatui::crossterm::terminal::size()
.map(|(w, _)| w).unwrap_or(80)
.saturating_sub(self.config.margin * 2);
let (lines, link_records, copyable_blocks) =
let (lines, link_records, copyable_blocks, image_records) =
crate::renderer::render_markdown(&content, width, None);
self.document = DocumentState::Loaded { filename: filename.clone(), lines };
self.raw_content = Some(content);
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
self.selected_link = target_link;
self.link_mode = false;
self.copy_mode = false;
@@ -1192,7 +1462,7 @@ impl App {
.map(|(w, _)| w)
.unwrap_or(80)
.saturating_sub(self.config.margin * 2);
let (mut lines, mut link_records, mut copyable_blocks) =
let (mut lines, mut link_records, mut copyable_blocks, mut image_records) =
crate::renderer::render_markdown(&content, width, Some(&vault_path));
// Read file metadata for status bar
@@ -1213,6 +1483,9 @@ impl App {
record.start_line += splash_count;
record.end_line += splash_count;
}
for record in &mut image_records {
record.line_index += splash_count;
}
}
}
@@ -1223,6 +1496,8 @@ impl App {
self.raw_content = Some(content);
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
self.selected_link = target_link;
self.copy_mode = false;
self.selected_block = None;
@@ -1265,6 +1540,8 @@ impl App {
self.raw_content = None;
self.link_records = link_records;
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.selected_link = target_link;
self.link_mode = true; // Directory is always in link mode
self.copy_mode = false;
@@ -1276,7 +1553,7 @@ impl App {
self.page_meta = None;
} else if target_path.starts_with("http://") || target_path.starts_with("https://") {
// Remote URL — re-fetch (consistent with "re-load from disk" pattern)
match crate::vault::fetch_remote_markdown(&target_path, &self.config.allowed_remote_domains) {
match crate::vault::fetch_remote_markdown(&target_path, &self.config.allowed_remote_domains, self.config.max_remote_document_size) {
crate::vault::RemoteDocument::Loaded { url: fetched_url, content } => {
let filename = fetched_url
.split('?').next().unwrap_or(&fetched_url)
@@ -1288,12 +1565,14 @@ impl App {
let width = ratatui::crossterm::terminal::size()
.map(|(w, _)| w).unwrap_or(80)
.saturating_sub(self.config.margin * 2);
let (lines, link_records, copyable_blocks) =
let (lines, link_records, copyable_blocks, image_records) =
crate::renderer::render_markdown(&content, width, None);
self.document = DocumentState::Loaded { filename: filename.clone(), lines };
self.raw_content = Some(content);
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
self.selected_link = target_link;
self.link_mode = false;
self.copy_mode = false;
@@ -1321,7 +1600,7 @@ impl App {
.map(|(w, _)| w)
.unwrap_or(80)
.saturating_sub(self.config.margin * 2);
let (mut lines, mut link_records, mut copyable_blocks) =
let (mut lines, mut link_records, mut copyable_blocks, mut image_records) =
crate::renderer::render_markdown(&content, width, Some(&vault_path));
// Read file metadata for status bar
@@ -1342,6 +1621,9 @@ impl App {
record.start_line += splash_count;
record.end_line += splash_count;
}
for record in &mut image_records {
record.line_index += splash_count;
}
}
}
@@ -1352,6 +1634,8 @@ impl App {
self.raw_content = Some(content);
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
self.selected_link = target_link;
self.copy_mode = false;
self.selected_block = None;
@@ -1410,6 +1694,8 @@ impl App {
self.document = DocumentState::Missing { path: full_path };
self.link_records = Vec::new();
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.selected_link = None;
}
}
@@ -1430,7 +1716,7 @@ impl App {
// Truncate forward history
self.history.truncate(self.history_index + 1);
match crate::vault::fetch_remote_markdown(url, &self.config.allowed_remote_domains) {
match crate::vault::fetch_remote_markdown(url, &self.config.allowed_remote_domains, self.config.max_remote_document_size) {
crate::vault::RemoteDocument::Loaded { url: fetched_url, content } => {
// Derive a display filename from the URL path
let filename = fetched_url
@@ -1456,7 +1742,7 @@ impl App {
.saturating_sub(self.config.margin * 2);
// Pass None for vault_path — wiki-links in remote content cannot resolve
let (lines, link_records, copyable_blocks) =
let (lines, link_records, copyable_blocks, image_records) =
crate::renderer::render_markdown(&content, width, None);
self.document = DocumentState::Loaded {
@@ -1466,6 +1752,8 @@ impl App {
self.raw_content = Some(content);
self.link_records = link_records;
self.copyable_blocks = copyable_blocks;
self.image_records = image_records;
self.image_cache.clear();
self.selected_link = None;
self.link_mode = false;
self.copy_mode = false;
@@ -1496,6 +1784,8 @@ impl App {
};
self.link_records = Vec::new();
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.raw_content = None;
self.current_url = None;
self.page_meta = None;
@@ -1508,6 +1798,8 @@ impl App {
};
self.link_records = Vec::new();
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.raw_content = None;
self.current_url = None;
self.page_meta = None;
@@ -1522,6 +1814,8 @@ impl App {
};
self.link_records = Vec::new();
self.copyable_blocks = Vec::new();
self.image_records = Vec::new();
self.image_cache.clear();
self.raw_content = None;
self.current_url = None;
self.page_meta = None;
@@ -1636,8 +1930,9 @@ impl App {
let margin = ratatui::layout::Margin { horizontal: self.config.margin, vertical: 0 };
let inner_content = content_area.inner(margin);
// Update content height for page scrolling and content rect for mouse hit-testing
// Update content dimensions for page scrolling, image sizing, and mouse hit-testing
self.last_content_height = inner_content.height;
self.last_content_width = inner_content.width;
self.last_inner_content = inner_content;
// ── Content area ─────────────────────────────────────────────────────
@@ -1695,6 +1990,78 @@ impl App {
let para = Paragraph::new(display_lines).scroll((self.scroll_offset, 0));
frame.render_widget(para, inner_content);
// Overlay loaded images on top of their reserved placeholder lines.
//
// Protocol always renders from the top-left, so to support partial
// visibility (top or bottom), we render into a temp buffer and copy
// only the visible rows into the frame buffer. This also prevents
// images from bleeding into the status bar.
let viewport_start = self.scroll_offset as usize;
let viewport_end = viewport_start + inner_content.height as usize;
// Collect visible image info (source, alt, doc line range, rows hidden at top)
let visible_images: Vec<(String, String, u16, u16, u16)> = self.image_records.iter()
.filter_map(|record| {
let img_start = record.line_index;
let img_end = img_start + record.height as usize;
// Skip if entirely outside viewport
if img_end <= viewport_start || img_start >= viewport_end {
return None;
}
let rows_hidden_top = viewport_start.saturating_sub(img_start) as u16;
let visible_top = img_start.max(viewport_start);
let visible_bottom = img_end.min(viewport_end);
let visible_height = (visible_bottom - visible_top) as u16;
if visible_height == 0 {
return None;
}
let screen_y = inner_content.y + (visible_top - viewport_start) as u16;
Some((record.source.clone(), record.alt.clone(), screen_y, visible_height, rows_hidden_top))
})
.collect();
for (source, alt, screen_y, visible_height, rows_hidden_top) in &visible_images {
match self.image_cache.get(source) {
Some(ImageCacheEntry::Loaded { protocol }) => {
let img_area = protocol.area();
// Render image widget into a temp buffer at origin
let temp_rect = Rect::new(0, 0, img_area.width, img_area.height);
let mut temp_buf = ratatui::buffer::Buffer::empty(temp_rect);
ImageWidget::new(protocol).render(temp_rect, &mut temp_buf);
// Copy only the visible rows from temp buffer to frame
let buf = frame.buffer_mut();
for row in 0..*visible_height {
let src_y = rows_hidden_top + row;
let dst_y = screen_y + row;
for col in 0..img_area.width.min(inner_content.width) {
if let Some(src_cell) = temp_buf.cell((col, src_y)) {
if let Some(dst_cell) = buf.cell_mut((inner_content.x + col, dst_y)) {
*dst_cell = src_cell.clone();
}
}
}
}
}
Some(ImageCacheEntry::Failed) => {
// Show fallback placeholder for failed images
let placeholder = format!("[IMAGE: {}]", alt);
let fallback_rect = Rect::new(inner_content.x, *screen_y, inner_content.width, 1);
let text = Paragraph::new(Line::from(Span::styled(
placeholder,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
)));
frame.render_widget(text, fallback_rect);
}
None => {}
}
}
}
DocumentState::Missing { path } => {
let path = path.clone();
@@ -2056,6 +2423,20 @@ fn build_directory_lines(
/// # Examples
///
/// ```text
/// Extract the domain from a URL string.
///
/// ```text
/// url_domain("https://example.com/path") → Ok("example.com")
/// ```
fn url_domain(url: &str) -> Result<String, ()> {
url.split_once("://")
.and_then(|(_, rest)| rest.split('/').next())
.and_then(|host| Some(host.split(':').next().unwrap_or(host)))
.filter(|h| !h.is_empty())
.map(|h| h.to_string())
.ok_or(())
}
/// build_breadcrumb("index.md") → "index"
/// build_breadcrumb("guides/getting-started.md") → "guides > getting-started"
/// ```
+23
View File
@@ -18,6 +18,18 @@ pub struct Config {
#[serde(default)]
pub allowed_remote_domains: Vec<String>,
#[serde(default = "default_max_remote_document_size")]
pub max_remote_document_size: u64,
#[serde(default = "default_max_remote_image_size")]
pub max_remote_image_size: u64,
/// When true, forces the halfblocks (Unicode block character) image renderer
/// instead of auto-detecting Kitty/iTerm2/Sixel protocols. Useful for
/// terminals where protocol detection fails or produces garbled output.
#[serde(default)]
pub force_halfblocks: bool,
}
fn default_vault_path() -> PathBuf {
@@ -28,6 +40,14 @@ fn default_theme() -> String {
"default".to_string()
}
fn default_max_remote_document_size() -> u64 {
5_000_000
}
fn default_max_remote_image_size() -> u64 {
10_485_760
}
impl Default for Config {
fn default() -> Self {
Config {
@@ -35,6 +55,9 @@ impl Default for Config {
theme: default_theme(),
margin: 0,
allowed_remote_domains: Vec::new(),
max_remote_document_size: default_max_remote_document_size(),
max_remote_image_size: default_max_remote_image_size(),
force_halfblocks: false,
}
}
}
+11 -1
View File
@@ -117,7 +117,7 @@ pub fn highlight_code(code: &str, lang: &str) -> Vec<Line<'static>> {
.highlight_line(line, ss)
.unwrap_or_default();
let spans: Vec<Span<'static>> = ranges
let mut spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(style, text)| {
let fg = syntect_color_to_cga(style.foreground);
@@ -125,6 +125,16 @@ pub fn highlight_code(code: &str, lang: &str) -> Vec<Line<'static>> {
})
.collect();
// Strip trailing newline from the last span — LinesWithNewlines preserves
// \n for syntect grammar correctness, but it must not reach the display layer
// or it inflates content_len in emit_code_block by 1, misaligning the right border.
if let Some(last) = spans.last_mut() {
let trimmed = last.content.trim_end_matches('\n').trim_end_matches('\r');
if trimmed.len() != last.content.len() {
*last = Span::styled(trimmed.to_string(), last.style);
}
}
result.push(Line::from(spans));
}
+26 -5
View File
@@ -37,16 +37,17 @@ fn main() {
// On resize, the event loop re-renders with the updated width.
let initial_width = ratatui::crossterm::terminal::size()
.map(|(w, _)| w)
.unwrap_or(80);
.unwrap_or(80)
.saturating_sub(app_config.margin * 2);
let (initial_doc, raw_content, initial_link_records) =
let (initial_doc, raw_content, initial_link_records, initial_copyable_blocks, initial_image_records) =
match vault::load_document(&app_config.vault_path, "index.md") {
vault::VaultDocument::Loaded { path, content } => {
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "index.md".to_string());
let (mut lines, mut link_records) = renderer::render_markdown(
let (mut lines, mut link_records, mut copyable_blocks, mut image_records) = renderer::render_markdown(
&content,
initial_width,
Some(&app_config.vault_path),
@@ -61,18 +62,27 @@ fn main() {
for record in &mut link_records {
record.line_index += splash_count;
}
for record in &mut copyable_blocks {
record.start_line += splash_count;
record.end_line += splash_count;
}
for record in &mut image_records {
record.line_index += splash_count;
}
}
let doc = app::DocumentState::Loaded { filename, lines };
(doc, Some(content), link_records)
(doc, Some(content), link_records, copyable_blocks, image_records)
}
vault::VaultDocument::Missing { path } => {
(app::DocumentState::Missing { path }, None, Vec::new())
(app::DocumentState::Missing { path }, None, Vec::new(), Vec::new(), Vec::new())
}
vault::VaultDocument::ReadError { path, reason } => {
(
app::DocumentState::Error { path, reason },
None,
Vec::new(),
Vec::new(),
Vec::new(),
)
}
};
@@ -120,6 +130,14 @@ fn main() {
}
};
// 6a. Initialize image protocol picker (auto-detect Kitty/iTerm2/Sixel, fallback to halfblocks)
let picker = if app_config.force_halfblocks {
ratatui_image::picker::Picker::halfblocks()
} else {
ratatui_image::picker::Picker::from_query_stdio()
.unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks())
};
// ── EVENT LOOP PHASE ──────────────────────────────────────────────────────
// 7. Create app state and run the event loop.
@@ -132,8 +150,11 @@ fn main() {
initial_doc,
raw_content,
initial_link_records,
initial_copyable_blocks,
initial_image_records,
"index.md".to_string(),
file_watcher,
picker,
);
let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags);
+101 -29
View File
@@ -6,7 +6,7 @@
//!
//! # Public API
//!
//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>)`
//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>)`
use std::path::Path;
use pulldown_cmark::{
@@ -63,6 +63,24 @@ pub struct CopyableBlock {
pub kind: BlockKind,
}
// ── ImageRecord ──────────────────────────────────────────────────────────────
/// Metadata for a single image discovered during rendering.
///
/// Produced by `render_markdown` alongside lines, link records, and copyable blocks.
/// The renderer reserves `height` blank placeholder lines starting at `line_index`,
/// and the app layer overlays actual image rendering on top of those lines.
pub struct ImageRecord {
/// Index into `Vec<Line>` where this image's reserved space begins.
pub line_index: usize,
/// Number of lines reserved for this image (default 15).
pub height: u16,
/// Image source: local path or URL from markdown `![](source)`.
pub source: String,
/// Alt text from markdown `![alt]()`.
pub alt: String,
}
// ── Internal pending link helpers ─────────────────────────────────────────────
/// Captured at `Tag::Link` Start — records link metadata before the text spans are pushed.
@@ -143,6 +161,13 @@ struct RenderState {
blockquote_start_line: Option<usize>,
/// Accumulated raw text for the current blockquote.
blockquote_raw: String,
// ── Image state ──────────────────────────────────────────────────────────
/// All image records discovered during rendering.
image_records: Vec<ImageRecord>,
/// Captured destination URL from the current `Tag::Image` (set at Start, consumed at End).
image_dest: Option<String>,
/// True when the current link contains an image (suppresses `[]` brackets).
link_has_image: bool,
}
impl RenderState {
@@ -174,6 +199,9 @@ impl RenderState {
text_section_raw: String::new(),
blockquote_start_line: None,
blockquote_raw: String::new(),
image_records: Vec::new(),
image_dest: None,
link_has_image: false,
}
}
@@ -373,7 +401,7 @@ impl RenderState {
// ── Finish ────────────────────────────────────────────────────────────────
fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>) {
fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>) {
// Flush any trailing spans that were not terminated with a paragraph end
if !self.current_spans.is_empty() {
self.flush_line();
@@ -393,7 +421,7 @@ impl RenderState {
});
}
}
(self.lines, self.link_records, self.copyable_blocks)
(self.lines, self.link_records, self.copyable_blocks, self.image_records)
}
}
@@ -565,19 +593,52 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
}
// ── Images ────────────────────────────────────────────────────────────
Event::Start(Tag::Image { .. }) => {
Event::Start(Tag::Image { dest_url, .. }) => {
state.in_image = true;
state.image_alt.clear();
state.image_dest = Some(dest_url.to_string());
// If inside a link, remove the "[" bracket that was pushed at Link Start
// so we don't get visible [] around the image placeholder.
if state.pending_link.is_some() {
if let Some(last) = state.current_spans.last() {
if last.content.as_ref() == "[" {
state.current_spans.pop();
}
}
state.link_has_image = true;
}
}
Event::End(TagEnd::Image) => {
let alt = std::mem::take(&mut state.image_alt);
let placeholder = format!("[IMAGE: {}]", alt);
state.current_spans.push(Span::styled(
placeholder,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
));
let source = state.image_dest.take().unwrap_or_default();
// Flush any pending spans before the image placeholder
if !state.current_spans.is_empty() {
state.flush_line();
}
// Record the line_index (first blank line of the reserved space).
// Fixed height — images are rendered at 2x original pixels, so we
// reserve a modest fixed space. The image overlay fills what it needs
// and blank lines remain for anything taller.
let line_index = state.lines.len();
let height: u16 = 15;
// Reserve blank lines — the image overlay renders on top of these.
// If the image fails to load, the alt text is stored in ImageRecord
// for the app layer to display as a fallback.
for _ in 0..height {
state.push_blank();
}
state.image_records.push(ImageRecord {
line_index,
height,
source,
alt,
});
state.in_image = false;
}
@@ -629,23 +690,31 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
Event::End(TagEnd::Link) => {
if let Some(pending) = state.pending_link.take() {
// Push closing bracket "]" with the same link style
state.current_spans.push(Span::styled("]".to_string(), pending.link_style));
if state.link_has_image {
// Link wrapped an image — brackets were already suppressed,
// skip the closing "]" and don't create a link record.
state.link_has_image = false;
} else {
// Push closing bracket "]" with the same link style
state.current_spans.push(Span::styled("]".to_string(), pending.link_style));
// Compute span_len: total chars in all spans pushed for this link
// (from link_span_start_count to current end, inclusive of brackets)
let span_len: usize = state.current_spans[state.link_span_start_count..]
.iter()
.map(|s| s.content.chars().count())
.sum();
// Compute span_len: total chars in all spans pushed for this link.
// Clamp start index — a flush_line mid-link drains current_spans,
// making link_span_start_count stale.
let start = state.link_span_start_count.min(state.current_spans.len());
let span_len: usize = state.current_spans[start..]
.iter()
.map(|s| s.content.chars().count())
.sum();
// Enqueue pending link record — line_index will be set at flush_line
state.pending_link_records.push(PendingLinkRecord {
dest: pending.dest,
is_wiki: pending.is_wiki,
col_offset: pending.col_offset,
span_len,
});
// Enqueue pending link record — line_index will be set at flush_line
state.pending_link_records.push(PendingLinkRecord {
dest: pending.dest,
is_wiki: pending.is_wiki,
col_offset: pending.col_offset,
span_len,
});
}
}
}
@@ -952,14 +1021,17 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// ── Public API ────────────────────────────────────────────────────────────────
/// Convert a markdown string into styled ratatui lines plus link and copyable block metadata.
/// Convert a markdown string into styled ratatui lines plus link, copyable block,
/// and image metadata.
///
/// Returns a 3-tuple `(Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>)`:
/// Returns a 4-tuple `(Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>)`:
/// - Lines: styled display content for ratatui `Paragraph`
/// - LinkRecords: parallel metadata for every link found (line_index, col_offset,
/// span_len, dest, is_wiki) — consumed for Tab-cycling navigation
/// - CopyableBlocks: metadata for every copyable block (start_line, end_line,
/// raw_content, kind) — consumed for copy mode and OSC 52 clipboard
/// - ImageRecords: metadata for every image found (line_index, height, source,
/// alt) — consumed for terminal image overlay rendering
///
/// The `vault_path` parameter is used to resolve wiki-links at render time,
/// enabling broken wiki-links to be shown in red/strikethrough inline. Pass
@@ -977,7 +1049,7 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
///
/// Panics if `crate::highlighter::init_highlighter()` has not been called before
/// the first code block is encountered.
pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>) {
pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>) {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
+7 -1
View File
@@ -14,6 +14,7 @@ use std::io::stdout;
use ratatui::crossterm::{
execute,
terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
event::{EnableMouseCapture, DisableMouseCapture},
cursor::Show,
};
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
@@ -30,7 +31,7 @@ pub type Term = Terminal<CrosstermBackend<std::io::Stdout>>;
/// Returns an error if raw mode cannot be enabled or the alternate screen cannot be entered.
pub fn init_terminal() -> std::io::Result<Term> {
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?;
execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout());
Terminal::with_options(backend, TerminalOptions {
viewport: Viewport::Fullscreen,
@@ -43,6 +44,7 @@ pub fn init_terminal() -> std::io::Result<Term> {
/// uses `let _ =` to suppress errors — this function is called from cleanup paths
/// including the panic hook, where panicking would hide the original error.
pub fn restore_terminal() {
let _ = execute!(std::io::stdout(), DisableMouseCapture);
let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), Show);
@@ -60,6 +62,10 @@ pub fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
// Restore terminal — use let _ to avoid double-panic if cleanup itself fails
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::DisableMouseCapture
);
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::terminal::LeaveAlternateScreen
+3 -3
View File
@@ -234,7 +234,7 @@ fn extract_domain(url: &str) -> Option<String> {
/// Matching rules (case-insensitive):
/// - Exact match: `example.com` matches `example.com`
/// - Subdomain match: `sub.example.com` matches whitelist entry `example.com`
fn domain_is_allowed(domain: &str, allowed_domains: &[String]) -> bool {
pub fn domain_is_allowed(domain: &str, allowed_domains: &[String]) -> bool {
for allowed in allowed_domains {
let allowed_lower = allowed.to_lowercase();
if domain == allowed_lower {
@@ -257,7 +257,7 @@ fn domain_is_allowed(domain: &str, allowed_domains: &[String]) -> bool {
/// 3. Validate the Content-Type header (accept markdown/plain text; reject HTML/binary).
/// 4. Read the response body (capped at 5 MB to prevent memory exhaustion).
/// 5. Return the appropriate `RemoteDocument` variant.
pub fn fetch_remote_markdown(url: &str, allowed_domains: &[String]) -> RemoteDocument {
pub fn fetch_remote_markdown(url: &str, allowed_domains: &[String], max_size: u64) -> RemoteDocument {
// Step 1: Domain whitelist check
let domain = match extract_domain(url) {
Some(d) => d,
@@ -320,7 +320,7 @@ pub fn fetch_remote_markdown(url: &str, allowed_domains: &[String]) -> RemoteDoc
// Step 4: Read body with 5 MB limit
let mut body = String::new();
let mut reader = response.into_reader().take(5_000_000);
let mut reader = response.into_reader().take(max_size);
if let Err(e) = reader.read_to_string(&mut body) {
return RemoteDocument::FetchError {
url: url.to_string(),
-71
View File
@@ -1,71 +0,0 @@
---
description: About BBS-MD and its design philosophy
---
# About BBS-MD
## What Is This?
BBS-MD is a terminal-based markdown vault reader that channels the aesthetic of 1990s bulletin board systems. It turns a folder of markdown files into a navigable, interlinked information system — like a personal wiki you browse over SSH.
## Design Philosophy
### Terminal-Native
BBS-MD is built for the terminal. No web browser, no Electron, no GUI toolkit. Just your terminal emulator, a monospace font, and 16 colors. It runs over SSH, in tmux, on a Raspberry Pi, on a VPS — anywhere you have a shell.
### Vault as Filesystem
Your content is just markdown files in a directory. No database, no proprietary format, no lock-in. Edit with vim, VS Code, or `echo >>`. BBS-MD watches for changes and refreshes live.
### Retro Aesthetic
The CGA 16-color palette isn't a limitation — it's a feature. Box-drawing characters for code blocks and tables. ANSI art on the landing page. Reverse-video status bar. Every design choice asks: *would a SysOp in 1994 approve?*
### Safe by Default
BBS-MD is designed to run as a login shell. That means:
- A panic must never leave the terminal broken
- A disconnect must never corrupt state
- The user must always be able to exit
- The app must always restore the terminal
These aren't nice-to-haves. When your app *is* the shell, safety is the foundation.
## Tech Stack
| Component | Technology |
|----------------|-------------------|
| Language | Rust (Edition 2024) |
| TUI Framework | ratatui 0.30 |
| Terminal | crossterm |
| Markdown | pulldown-cmark (GFM) |
| Highlighting | syntect |
| ANSI Art | ansi-to-tui |
| File Watching | notify 6.1 |
| Dir Traversal | walkdir 2.5 |
## Architecture
```
bbs-md/
src/
main.rs Entry point, terminal init, event loop
app.rs App state, navigation, key handling
config.rs TOML config loading (bbs.toml)
vault.rs Document loading, wiki-link resolution
renderer.rs Markdown-to-styled-lines pipeline
highlighter.rs Syntax highlighting with CGA colors
splash.rs ANSI art splash screen loader
signals.rs Unix signal handlers (SIGHUP, SIGTERM)
terminal.rs Terminal init/restore, panic hook
vault/
index.md Landing page (splash.txt prepended)
splash.txt ANSI art header (optional)
*.md Your content lives here
```
> BBS-MD is a single binary with no runtime dependencies. Compile it, drop it on a server, point it at a vault.
Back to [[index|home]] or read the [[Changelog]].
-64
View File
@@ -1,64 +0,0 @@
---
description: Version history and release notes
---
# Changelog
All notable changes to BBS-MD are documented here.
## v1.0.0 — 2026-03-01
The initial release. Four phases of development, ten plans executed.
### Phase 1: Safety Foundation
- Panic hook restores terminal state before unwinding
- SIGHUP and SIGTERM signal handlers for graceful shutdown
- Login shell detection (`argv[0]` dash prefix)
- TOML configuration via `bbs.toml`
- Double Ctrl+C quit confirmation (critical for login shell mode)
### Phase 2: Vault Core and Rendering
- Full GFM markdown rendering pipeline via pulldown-cmark
- Syntax-highlighted fenced code blocks with box-drawing borders
- Tables with aligned columns and full grid borders
- Scrolling with j/k, arrow keys, PgUp/PgDn
- Error screens for missing or unreadable documents
- Terminal resize re-rendering
### Phase 3: Navigation and Links
- Wiki-link resolution (`[[Page Name]]` to `page-name.md`)
- Standard markdown link following (`[text](path.md)`)
- Browser-style back/forward navigation history
- Modal link cycling with Tab to enter, Up/Down to navigate
- Draw-time REVERSED highlight on selected links
- Breadcrumb path display in status bar
- Broken wiki-link detection at render time (red strikethrough)
### Phase 4: BBS Polish and Live Content
- ANSI art splash screen on index.md via `splash.txt`
- File metadata in status bar (last modified date, file size)
- Virtual `[[Directory]]` page with tree view of all vault files
- Live filesystem watching with 300ms debounce auto-refresh
- Arrow key navigation (Left/Right for back/forward)
- Frontmatter description display in directory listing
---
## Roadmap
Future ideas (not yet planned):
- Theming support (different color palettes)
- Search across vault documents
- Bookmarks and favorites
- Multi-vault support
- SSH server built-in (no external sshd needed)
- User authentication and access control
---
Back to [[index|home]] or see [[About]].
-77
View File
@@ -1,77 +0,0 @@
---
description: Full feature breakdown with examples
---
# Features
BBS-MD is a terminal-based markdown vault reader built in Rust. Here's everything it can do.
## Markdown Rendering
BBS-MD renders a full subset of GitHub-Flavored Markdown with retro CGA-themed styling:
- **Headers** (H1-H6) with distinct colors and decorators
- **Bold**, *italic*, and ~~strikethrough~~ text
- `Inline code` with syntax highlighting
- Fenced code blocks with language-aware highlighting
- Ordered and unordered lists with nesting
- Blockquotes with yellow pipe borders
- Tables with full box-drawing grid borders
- Horizontal rules
- Image placeholders
### Code Block Example
```rust
fn main() {
println!("Welcome to BBS-MD!");
let vault = Vault::new("./vault");
vault.browse();
}
```
### Table Example
| Feature | Status | Since |
|----------------|-----------|---------|
| Markdown | Complete | Phase 2 |
| Wiki-Links | Complete | Phase 3 |
| ANSI Splash | Complete | Phase 4 |
| Live Reload | Complete | Phase 4 |
## Wiki-Link Navigation
Connect your documents with `[[Wiki Links]]`. BBS-MD resolves them at render time:
- `[[Page Name]]` — links to `page-name.md` (case-insensitive, hyphenated)
- `[[Directory]]` — special magic link to the vault directory listing
- Broken links are shown in ~~red strikethrough~~ so you know what needs fixing
See the [[Navigation Guide]] for the full key binding reference.
## ANSI Art Splash Screen
Place a `splash.txt` file in your vault root containing ANSI escape codes. BBS-MD parses it with `ansi-to-tui` and displays it as a colorful header above your `index.md` content. If the file is missing, the index renders normally with no error.
## Live Filesystem Watching
Edit your markdown files in any editor. BBS-MD watches for changes and auto-refreshes the currently displayed page within about a second. Rapid saves are debounced — you won't see flickering.
- Watches the parent directory of the current file
- Survives atomic saves (vim, neovim style)
- Non-fatal: if the watcher fails, the app still works without live reload
## Directory Listing
Navigate to `[[Directory]]` to see a tree view of every document in your vault. Directories appear in **yellow bold**, files as cyan bracketed links. Tab-cycle through entries and press Enter to open any document.
## Safe Login Shell Mode
BBS-MD can be set as a user's login shell for SSH access. In this mode:
- `q` key is disabled (only double Ctrl+C exits)
- Panic hook restores the terminal before crashing
- Signal handlers (SIGHUP, SIGTERM) clean up gracefully
- The terminal is always left in a usable state
Back to [[index|home]].
-79
View File
@@ -1,79 +0,0 @@
---
description: Running BBS-MD as a public SSH service
---
# SysOp Handbook
So you want to run a BBS. Welcome to the club.
## Setting Up SSH Access
The classic BBS experience: users connect via SSH and land directly in BBS-MD.
### 1. Create a BBS User
```bash
# Create user with bbs-md as their login shell
sudo useradd -m -s /usr/local/bin/bbs-md bbsguest
# Set up their vault
sudo mkdir -p /home/bbsguest/vault
sudo cp -r /path/to/your/vault/* /home/bbsguest/vault/
# Set a password (or use SSH keys)
sudo passwd bbsguest
```
### 2. Configure bbs.toml
Place `bbs.toml` next to the binary or in the user's home directory:
```toml
vault_path = "/home/bbsguest/vault"
theme = "default"
```
### 3. Test the Connection
```bash
ssh bbsguest@your-server.com
```
The user lands directly on the index page. No shell prompt, no confusion. Just content.
## Security Considerations
- BBS-MD runs as a **login shell** — there is no escape to bash
- The `q` key is **disabled** in login shell mode
- Only double `Ctrl+C` can exit (returns to SSH disconnect)
- Path traversal is guarded — `../../etc/passwd` won't resolve
- Only `.md` files inside the vault are accessible
## Content Management
Your vault is just a directory of markdown files. You can:
- Edit files over SFTP or SCP
- Use `rsync` to sync from a local copy
- Mount a Git repository and pull updates
- Write a cron job to generate content
BBS-MD watches for changes. Your updates appear live.
> The best BBS is the one that's always fresh.
## Monitoring
Watch the process with standard Unix tools:
```bash
# Check if bbs-md is running for connected users
ps aux | grep bbs-md
# Watch vault changes in real time
inotifywait -m /home/bbsguest/vault/
```
---
Back to [[index|home]] or see the [[Features]].
-69
View File
@@ -1,69 +0,0 @@
---
description: Tips for writing vault content
---
# Writing Content
Tips for creating great content for your BBS-MD vault.
## File Structure
Every `.md` file in your vault directory is a page. Subdirectories create sections:
```
vault/
index.md <- Landing page (always loads first)
splash.txt <- ANSI art header (optional)
features.md <- Top-level page
guides/
sysop-handbook.md
writing-content.md
```
## Frontmatter
Add YAML frontmatter to your pages for metadata:
```
---
description: A short summary shown in the directory listing
---
```
The `description` field appears beside your file in the [[Directory]] listing, helping visitors find what they're looking for.
## Linking Between Pages
### Wiki-Links (Recommended)
Use double-bracket syntax to link between pages:
- `[[Page Name]]` resolves to `page-name.md`
- Case-insensitive: `[[features]]` and `[[Features]]` both work
- Cross-directory: `[[SysOp Handbook]]` finds `guides/sysop-handbook.md`
### Standard Links
Standard markdown links work too:
- `[Click here](features.md)` — relative to vault root
- `[Guide](guides/sysop-handbook.md)` — path to subdirectory files
### Special Links
- `[[Directory]]` — opens the virtual directory listing page
## Best Practices
1. **Start with index.md** — it's your front door
2. **Use frontmatter descriptions** — they show up in the directory
3. **Link generously** — wiki-links make your vault navigable
4. **Keep files small** — one topic per page, like a real BBS
5. **Use all six heading levels** — they each have distinct styling
6. **Add a splash.txt** — ANSI art makes everything better
> A well-linked vault is a joy to browse. A pile of unlinked pages is a graveyard.
---
Back to [[index|home]] or browse the [[Directory]].
-40
View File
@@ -1,40 +0,0 @@
---
description: Main landing page — start here
---
# Welcome to BBS-MD
Welcome to **BBS-MD**, a retro-styled terminal markdown vault reader that brings the spirit of 1990s bulletin board systems into the modern era. Browse linked documents, explore your vault, and enjoy the warm glow of CGA colors.
## Getting Started
Use the keyboard to navigate this vault:
- Press **Tab** to enter link navigation mode
- Use **Up/Down** arrows to cycle between links
- Press **Enter** to follow a link
- Press **Esc** or **Tab** again to exit link mode
- Press **Left arrow** or **Backspace** to go back
## Explore the Vault
Check out these pages to learn more:
- [[Features]] — Full feature breakdown with examples
- [[Navigation Guide]] — How to navigate like a SysOp
- [[Markdown Showcase]] — See all supported markdown constructs
- [[Directory]] — Browse all documents in this vault
- [[About]] — About BBS-MD and its design philosophy
- [[Changelog]] — Version history and release notes
## System Status
| Component | Status |
|----------------|------------|
| Vault Engine | Online |
| ANSI Renderer | Active |
| Link Resolver | Ready |
| File Watcher | Monitoring |
> "The BBS is dead. Long live the BBS."
> — Anonymous SysOp, 2026
-166
View File
@@ -1,166 +0,0 @@
---
description: See all supported markdown constructs in action
---
# Markdown Showcase
This page demonstrates every markdown construct that BBS-MD renders. Use it as a reference or a test page.
## Headers
Headers from H1 through H6 each have distinct styling. H1 and H2 get decorative underlines.
### This is H3
#### This is H4
##### This is H5
###### This is H6
## Text Formatting
Here's **bold text**, *italic text*, and ***bold italic*** combined. You can also use ~~strikethrough~~ for deleted content. Mix them freely: ***~~all at once~~***.
Inline `code spans` render in a distinct color for quick identification.
## Lists
### Unordered
- First level item
- Second level with different bullet
- Third level goes deeper
- Back to second level
- Another top-level item
### Ordered
1. First step
2. Second step
3. Third step
1. Sub-step A
2. Sub-step B
4. Fourth step
## Code Blocks
### Rust
```rust
use std::collections::HashMap;
struct BBS {
name: String,
pages: HashMap<String, Page>,
}
impl BBS {
fn new(name: &str) -> Self {
BBS {
name: name.to_string(),
pages: HashMap::new(),
}
}
fn add_page(&mut self, slug: &str, page: Page) {
self.pages.insert(slug.to_string(), page);
}
}
```
### Python
```python
class VaultReader:
def __init__(self, path: str):
self.path = path
self.pages = {}
def load_all(self):
for md_file in Path(self.path).glob("**/*.md"):
slug = md_file.stem
self.pages[slug] = md_file.read_text()
return self
```
### Shell
```bash
#!/bin/bash
# Deploy BBS-MD as a login shell
useradd -m -s /usr/local/bin/bbs-md bbsuser
mkdir -p /home/bbsuser/vault
cp -r ./demo-vault/* /home/bbsuser/vault/
echo "BBS user created. Connect via: ssh bbsuser@localhost"
```
### Plain (no language)
```
No syntax highlighting here.
Just plain monospaced text in a box.
Useful for ASCII diagrams or raw output.
```
## Blockquotes
> This is a simple blockquote. It gets a yellow pipe border on the left and gray text.
> Multiple levels of nesting work too:
>
> > Nested blockquote. The borders stack.
> >
> > > Three levels deep. Still readable.
## Tables
### Basic Table
| Planet | Moons | Ring System |
|---------|-------|-------------|
| Mercury | 0 | No |
| Venus | 0 | No |
| Earth | 1 | No |
| Mars | 2 | No |
| Jupiter | 95 | Yes |
| Saturn | 146 | Yes |
### Aligned Columns
| Left | Center | Right |
|:---------|:--------:|---------:|
| Alpha | Beta | Gamma |
| One | Two | Three |
| Short | Medium | Longtext |
## Horizontal Rule
Content above the rule.
---
Content below the rule.
## Links
### Wiki-Links
- [[Features]] — internal wiki-link to another vault page
- [[Directory]] — magic link to the directory listing
- [[Nonexistent Page]] — broken link shown in red strikethrough
### Standard Links
- [Features Page](features.md) — standard markdown link
- [Navigation](navigation-guide.md) — another standard link
## Images
Images render as text placeholders since we're in a terminal:
![A beautiful sunset over the digital frontier](sunset.png)
---
Back to [[index|home]].
-67
View File
@@ -1,67 +0,0 @@
---
description: How to navigate like a SysOp
---
# Navigation Guide
Master the keyboard and browse your vault like a true SysOp.
## Scrolling
| Key | Action |
|--------------|---------------------|
| `j` / Down | Scroll down one line |
| `k` / Up | Scroll up one line |
| `PgDn` | Scroll down one page |
| `PgUp` | Scroll up one page |
## Link Navigation
BBS-MD uses a **modal link navigation** system:
1. Press **Tab** to enter link mode
2. A highlight appears on the first link
3. Use **Up/Down** arrows (or `j`/`k`) to cycle between links
4. Press **Enter** to follow the selected link
5. Press **Tab** or **Esc** to exit link mode
> In link mode, the status bar updates to show link-specific hints. Watch the bottom of your screen for context.
## Page Navigation
| Key | Action |
|------------------|---------------------|
| Left / Backspace | Go back in history |
| Right | Go forward in history |
| Alt+Left | Go back (always) |
| Alt+Right | Go forward (always) |
History works like a web browser. Following a new link from a mid-history position forks the history — forward entries are discarded.
## Directory Page
The [[Directory]] page behaves differently:
- You're **always** in link mode on the directory page
- Up/Down arrows cycle through document entries
- Enter opens the selected document
- **Esc** navigates back to the previous page
- Tab has no effect (link mode can't be toggled off)
## Quitting
| Key | Action |
|----------------|-----------------------------------|
| `q` | Quit immediately (normal mode) |
| `Ctrl+C` x2 | Quit with confirmation (always) |
In **login shell mode**, `q` is disabled. You must use double `Ctrl+C` to disconnect.
## Tips
- The **breadcrumb** in the status bar shows your current location
- **File metadata** (last modified date and size) appears on the right side of the status bar
- Links that can't be resolved appear in ~~red strikethrough~~
- The vault is **live** — edit a file externally and the page refreshes automatically
Back to [[index|home]] or see [[Features]].
-8
View File
@@ -1,8 +0,0 @@
[1;36m ____ ____ ____ __ __ ____ [0m
[1;36m| __ )| __ )/ ___| | \/ | _ \ [0m
[1;36m| _ \| _ \\___ \ _____| |\/| | | | |[0m
[1;36m| |_) | |_) |___) |_____| | | | |_| |[0m
[1;36m|____/|____/|____/ |_| |_|____/ [0m
[0;33m───────────────────────────────────────[0m
[1;33m Retro BBS Markdown Vault Reader v1.0[0m
[0;33m───────────────────────────────────────[0m