diff --git a/src/content.rs b/src/content.rs index fa2b66c..fcd8111 100644 --- a/src/content.rs +++ b/src/content.rs @@ -6,6 +6,12 @@ use serde::Serialize; use std::fs; use std::path::{Path, PathBuf}; +/// Default weight for content items in navigation and listings. +pub const DEFAULT_WEIGHT: i64 = 50; + +/// High default weight for content that should appear last (e.g., projects). +pub const DEFAULT_WEIGHT_HIGH: i64 = 99; + /// The type of content being processed. #[derive(Debug, Clone, PartialEq)] pub enum ContentKind { @@ -212,7 +218,7 @@ pub fn discover_nav(content_dir: &Path) -> Result> { .nav_label .unwrap_or(content.frontmatter.title), path: format!("/{}.html", slug), - weight: content.frontmatter.weight.unwrap_or(50), + weight: content.frontmatter.weight.unwrap_or(DEFAULT_WEIGHT), children: Vec::new(), }); } @@ -245,7 +251,7 @@ pub fn discover_nav(content_dir: &Path) -> Result> { .map(|item| NavItem { label: item.frontmatter.nav_label.unwrap_or(item.frontmatter.title), path: format!("/{}/{}.html", dir_name, item.slug), - weight: item.frontmatter.weight.unwrap_or(50), + weight: item.frontmatter.weight.unwrap_or(DEFAULT_WEIGHT), children: Vec::new(), }) .collect(); @@ -260,7 +266,7 @@ pub fn discover_nav(content_dir: &Path) -> Result> { .nav_label .unwrap_or(content.frontmatter.title), path: format!("/{}/index.html", dir_name), - weight: content.frontmatter.weight.unwrap_or(50), + weight: content.frontmatter.weight.unwrap_or(DEFAULT_WEIGHT), children, }); } @@ -358,8 +364,8 @@ pub fn discover_sections(content_dir: &Path) -> Result> { // Sort by weight sections.sort_by(|a, b| { - let wa = a.index.frontmatter.weight.unwrap_or(50); - let wb = b.index.frontmatter.weight.unwrap_or(50); + let wa = a.index.frontmatter.weight.unwrap_or(DEFAULT_WEIGHT); + let wb = b.index.frontmatter.weight.unwrap_or(DEFAULT_WEIGHT); wa.cmp(&wb) }); diff --git a/src/escape.rs b/src/escape.rs new file mode 100644 index 0000000..ba0ba5a --- /dev/null +++ b/src/escape.rs @@ -0,0 +1,63 @@ +//! Text escaping utilities for HTML and XML output. + +/// Escape HTML special characters for safe embedding in HTML content. +/// +/// Escapes: `&`, `<`, `>`, `"` +pub fn html_escape(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + html_escape_into(&mut result, s); + result +} + +/// Escape HTML characters into an existing string. +/// +/// This is more efficient when building output incrementally. +pub fn html_escape_into(out: &mut String, s: &str) { + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(c), + } + } +} + +/// Escape XML special characters for safe embedding in XML documents. +/// +/// Escapes: `&`, `<`, `>`, `"`, `'` +pub fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_html_escape() { + assert_eq!(html_escape("Hello & World"), "Hello & World"); + assert_eq!(html_escape(""), "<tag>"); + assert_eq!(html_escape("\"quoted\""), ""quoted""); + } + + #[test] + fn test_html_escape_into() { + let mut buf = String::new(); + html_escape_into(&mut buf, "a < b"); + assert_eq!(buf, "a < b"); + } + + #[test] + fn test_xml_escape() { + assert_eq!(xml_escape("Hello & World"), "Hello & World"); + assert_eq!(xml_escape(""), "<tag>"); + assert_eq!(xml_escape("\"quoted\""), ""quoted""); + assert_eq!(xml_escape("it's"), "it's"); + } +} diff --git a/src/feed.rs b/src/feed.rs index d65d390..a182c72 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -2,6 +2,7 @@ use crate::config::SiteConfig; use crate::content::SiteManifest; +use crate::escape::xml_escape; use std::path::Path; /// Generate an Atom 1.0 feed from blog posts in the manifest. @@ -80,15 +81,6 @@ pub fn generate_atom_feed( ) } -/// Escape XML special characters. -fn xml_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/highlight.rs b/src/highlight.rs index 7eff4a2..ed90ace 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::sync::LazyLock; use std::time::Duration; +use crate::escape::{html_escape, html_escape_into}; use ropey::RopeSlice; use tree_house::highlighter::{Highlight, HighlightEvent, Highlighter}; use tree_house::{ @@ -624,26 +625,6 @@ fn render_html<'a>(source: &str, mut highlighter: Highlighter<'a, 'a, SukrLoader html } -/// Simple HTML escape for fallback. -fn html_escape(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - html_escape_into(&mut result, s); - result -} - -/// Escape HTML characters into an existing string. -fn html_escape_into(out: &mut String, s: &str) { - for c in s.chars() { - match c { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - _ => out.push(c), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 312c6f0..0a1474a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod config; mod content; mod css; mod error; +mod escape; mod feed; mod highlight; mod math; @@ -14,7 +15,7 @@ mod render; mod sitemap; mod template_engine; -use crate::content::{Content, ContentKind, NavItem}; +use crate::content::{Content, ContentKind, DEFAULT_WEIGHT, DEFAULT_WEIGHT_HIGH, NavItem}; use crate::error::{Error, Result}; use crate::template_engine::{ContentContext, TemplateEngine}; use std::fs; @@ -123,8 +124,8 @@ fn run(config_path: &Path) -> Result<()> { items.sort_by(|a, b| { a.frontmatter .weight - .unwrap_or(99) - .cmp(&b.frontmatter.weight.unwrap_or(99)) + .unwrap_or(DEFAULT_WEIGHT_HIGH) + .cmp(&b.frontmatter.weight.unwrap_or(DEFAULT_WEIGHT_HIGH)) }); } _ => { @@ -132,8 +133,8 @@ fn run(config_path: &Path) -> Result<()> { items.sort_by(|a, b| { a.frontmatter .weight - .unwrap_or(50) - .cmp(&b.frontmatter.weight.unwrap_or(50)) + .unwrap_or(DEFAULT_WEIGHT) + .cmp(&b.frontmatter.weight.unwrap_or(DEFAULT_WEIGHT)) .then_with(|| a.frontmatter.title.cmp(&b.frontmatter.title)) }); } diff --git a/src/render.rs b/src/render.rs index f6fdd51..c14920b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,5 +1,6 @@ //! Markdown to HTML rendering via pulldown-cmark with syntax highlighting. +use crate::escape::html_escape; use crate::highlight::{highlight_code, Language}; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use serde::Serialize; @@ -247,13 +248,6 @@ pub fn markdown_to_html(markdown: &str) -> (String, Vec) { (html_output, anchors) } -fn html_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - /// Convert heading text to a URL-friendly slug ID. fn slugify(text: &str) -> String { text.to_lowercase() diff --git a/src/sitemap.rs b/src/sitemap.rs index 959b094..7fcc547 100644 --- a/src/sitemap.rs +++ b/src/sitemap.rs @@ -2,6 +2,7 @@ use crate::config::SiteConfig; use crate::content::SiteManifest; +use crate::escape::xml_escape; use std::path::Path; /// A URL entry for the sitemap. @@ -88,15 +89,6 @@ fn build_sitemap_xml(entries: &[SitemapEntry]) -> String { ) } -/// Escape XML special characters. -fn xml_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - #[cfg(test)] mod tests { use super::*;