use std::collections::HashMap;
use std::path::{Path, PathBuf};
const MARKER: &str = "/*__TINYVIEW__*/ null /*__TINYVIEW__*/";
const TEXT_HTML: &str = include_str!("templates/text.html");
const MINIMAL_HTML: &str = include_str!("templates/minimal.html");
const MARKDOWN_HTML: &str = include_str!("templates/markdown.html");
const CODE_HTML: &str = include_str!("templates/code.html");
const MERMAID_HTML: &str = include_str!("templates/mermaid.html");
const MARKED_JS: &str = include_str!("templates/vendor/marked.min.js");
const HLJS_JS: &str = include_str!("templates/vendor/highlight.min.js");
const HLJS_CSS: &str = include_str!("templates/vendor/hljs-theme.css");
const MERMAID_JS: &str = include_str!("templates/vendor/mermaid.min.js");
const LIB_MARKED: &str = "/*__TINYVIEW_MARKED__*/";
const LIB_HLJS: &str = "/*__TINYVIEW_HLJS__*/";
const LIB_HLJS_CSS: &str = "/*__TINYVIEW_HLJS_CSS__*/";
const LIB_MERMAID: &str = "/*__TINYVIEW_MERMAID__*/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateRef {
Raw,
Text,
Minimal,
Markdown,
Mermaid,
Code,
User(PathBuf),
}
pub struct InjectData<'a> {
pub input: &'a str,
pub params: &'a HashMap<String, String>,
pub title: &'a str,
pub path: Option<&'a Path>,
}
#[derive(Debug)]
pub enum RenderError {
UserTemplateRead(std::io::Error),
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RenderError::UserTemplateRead(e) => {
write!(f, "failed to read user template: {e}")
}
}
}
}
impl std::error::Error for RenderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RenderError::UserTemplateRead(e) => Some(e),
}
}
}
pub fn resolve(
explicit: Option<&str>,
input_path: Option<&Path>,
extension_map: Option<&HashMap<String, String>>,
default_template: Option<&str>,
) -> TemplateRef {
if let Some(name) = explicit {
return name_to_ref(name);
}
if let (Some(path), Some(map)) = (input_path, extension_map) {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if let Some(name) = map.get(ext).or_else(|| map.get(&ext.to_ascii_lowercase())) {
return name_to_ref(name);
}
}
}
if let Some(name) = default_template {
return name_to_ref(name);
}
TemplateRef::Raw
}
fn name_to_ref(name: &str) -> TemplateRef {
match name {
"raw" => TemplateRef::Raw,
"text" => TemplateRef::Text,
"minimal" => TemplateRef::Minimal,
"markdown" => TemplateRef::Markdown,
"mermaid" => TemplateRef::Mermaid,
"code" => TemplateRef::Code,
other => {
let mut p = PathBuf::from(other);
p.set_extension("html");
TemplateRef::User(p)
}
}
}
pub fn render(tpl: &TemplateRef, data: &InjectData<'_>) -> Result<String, RenderError> {
let source: std::borrow::Cow<'_, str> = match tpl {
TemplateRef::Raw => {
panic!("template::render must not be called for TemplateRef::Raw");
}
TemplateRef::Text => std::borrow::Cow::Borrowed(TEXT_HTML),
TemplateRef::Minimal => std::borrow::Cow::Borrowed(MINIMAL_HTML),
TemplateRef::Markdown => std::borrow::Cow::Owned(
MARKDOWN_HTML
.replace(LIB_HLJS_CSS, HLJS_CSS)
.replace(LIB_MARKED, MARKED_JS)
.replace(LIB_HLJS, HLJS_JS),
),
TemplateRef::Code => std::borrow::Cow::Owned(
CODE_HTML
.replace(LIB_HLJS_CSS, HLJS_CSS)
.replace(LIB_HLJS, HLJS_JS),
),
TemplateRef::Mermaid => {
std::borrow::Cow::Owned(MERMAID_HTML.replace(LIB_MERMAID, MERMAID_JS))
}
TemplateRef::User(path) => match std::fs::read_to_string(path) {
Ok(s) => std::borrow::Cow::Owned(s),
Err(e) => return Err(RenderError::UserTemplateRead(e)),
},
};
let literal = build_literal(data);
Ok(substitute_marker(&source, &literal))
}
fn build_literal(data: &InjectData<'_>) -> String {
let payload = serde_json::json!({
"input": data.input,
"params": data.params,
"title": data.title,
"path": data.path.and_then(|p| p.to_str()),
});
payload
.to_string()
.replace('<', "\\u003c") .replace('>', "\\u003e") .replace('\u{2028}', "\\u2028") .replace('\u{2029}', "\\u2029") }
fn substitute_marker(source: &str, literal: &str) -> String {
if let Some(idx) = source.find(MARKER) {
let mut out = String::with_capacity(source.len() + literal.len());
out.push_str(&source[..idx]);
out.push_str(literal);
out.push_str(&source[idx + MARKER.len()..]);
out
} else {
eprintln!(
"tinyview: warning: template has no `{}` marker; injection skipped",
MARKER
);
source.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_map() -> HashMap<String, String> {
HashMap::new()
}
#[test]
fn resolve_explicit_wins_over_everything() {
let mut ext: HashMap<String, String> = HashMap::new();
ext.insert("md".to_string(), "markdown".to_string());
let got = resolve(
Some("text"),
Some(Path::new("README.md")),
Some(&ext),
Some("minimal"),
);
assert_eq!(got, TemplateRef::Text);
}
#[test]
fn resolve_extension_used_when_no_explicit() {
let mut ext: HashMap<String, String> = HashMap::new();
ext.insert("md".to_string(), "minimal".to_string());
let got = resolve(None, Some(Path::new("notes.md")), Some(&ext), Some("text"));
assert_eq!(got, TemplateRef::Minimal);
}
#[test]
fn resolve_default_used_when_no_extension_match() {
let ext: HashMap<String, String> = HashMap::new();
let got = resolve(None, Some(Path::new("a.unknown")), Some(&ext), Some("text"));
assert_eq!(got, TemplateRef::Text);
}
#[test]
fn resolve_falls_back_to_raw() {
let got = resolve(None, None, None, None);
assert_eq!(got, TemplateRef::Raw);
}
#[test]
fn resolve_unknown_name_becomes_user_template() {
let got = resolve(Some("custom-layout"), None, None, None);
assert_eq!(got, TemplateRef::User(PathBuf::from("custom-layout.html")));
}
#[test]
fn render_substitutes_marker_once() {
let params = empty_map();
let data = InjectData {
input: "<h1>Hi</h1>",
params: ¶ms,
title: "tinyview",
path: None,
};
let out = render(&TemplateRef::Text, &data).expect("render ok");
assert!(!out.contains(MARKER), "marker should have been substituted");
let literal = build_literal(&data);
assert!(
!literal.contains('<') && !literal.contains('>'),
"raw angle brackets leaked into injected literal: {literal}"
);
let parsed: serde_json::Value =
serde_json::from_str(&literal).expect("injected literal is valid JSON");
assert_eq!(parsed["input"], "<h1>Hi</h1>");
assert!(out.contains(r#""title":"tinyview""#));
assert!(out.contains(r#""path":null"#));
}
#[test]
fn render_minimal_includes_params() {
let mut params = HashMap::new();
params.insert("theme".to_string(), "github".to_string());
let data = InjectData {
input: "hello",
params: ¶ms,
title: "t",
path: Some(Path::new("/tmp/x.md")),
};
let out = render(&TemplateRef::Minimal, &data).expect("render ok");
assert!(out.contains(r#""theme":"github""#));
assert!(out.contains(r#""path":"/tmp/x.md""#));
assert!(!out.contains(MARKER));
}
#[test]
fn render_marker_missing_returns_source_unchanged() {
let dir = std::env::temp_dir();
let path = dir.join("tinyview_test_no_marker.html");
let html = "<html><body>no marker here</body></html>";
std::fs::write(&path, html).expect("write tmp template");
let params = empty_map();
let data = InjectData {
input: "x",
params: ¶ms,
title: "t",
path: None,
};
let out = render(&TemplateRef::User(path.clone()), &data).expect("render ok");
assert_eq!(out, html);
let _ = std::fs::remove_file(path);
}
#[test]
fn resolve_optional_builtin_names() {
assert_eq!(
resolve(Some("markdown"), None, None, None),
TemplateRef::Markdown
);
assert_eq!(
resolve(Some("mermaid"), None, None, None),
TemplateRef::Mermaid
);
assert_eq!(resolve(Some("code"), None, None, None), TemplateRef::Code);
}
#[test]
fn render_markdown_inlines_libs_and_marker() {
let params = empty_map();
let data = InjectData {
input: "Hello markdown",
params: ¶ms,
title: "tinyview",
path: Some(Path::new("README.md")),
};
let out = render(&TemplateRef::Markdown, &data).expect("render ok");
assert!(!out.contains(MARKER));
assert!(!out.contains(LIB_MARKED));
assert!(!out.contains(LIB_HLJS));
assert!(!out.contains(LIB_HLJS_CSS));
assert!(out.contains("marked"), "marked.js not inlined");
assert!(out.contains("hljs"), "highlight.js not inlined");
assert!(out.contains(r#""input":"Hello markdown""#));
assert!(!out.contains("<script src="));
assert!(!out.contains("<link "));
}
#[test]
fn render_code_inlines_hljs() {
let mut params = HashMap::new();
params.insert("lang".to_string(), "rust".to_string());
let data = InjectData {
input: "fn main() {}",
params: ¶ms,
title: "t",
path: None,
};
let out = render(&TemplateRef::Code, &data).expect("render ok");
assert!(!out.contains(MARKER));
assert!(!out.contains(LIB_HLJS));
assert!(!out.contains(LIB_HLJS_CSS));
assert!(out.contains("hljs"), "highlight.js not inlined");
assert!(out.contains(r#""lang":"rust""#));
}
#[test]
fn render_mermaid_inlines_lib() {
let params = empty_map();
let data = InjectData {
input: "graph TD; A-->B",
params: ¶ms,
title: "t",
path: None,
};
let out = render(&TemplateRef::Mermaid, &data).expect("render ok");
assert!(!out.contains(MARKER));
assert!(!out.contains(LIB_MERMAID));
assert!(out.contains("mermaid"), "mermaid.js not inlined");
let literal = build_literal(&data);
assert!(!literal.contains('>'), "raw `>` leaked: {literal}");
let parsed: serde_json::Value =
serde_json::from_str(&literal).expect("injected literal is valid JSON");
assert_eq!(parsed["input"], "graph TD; A-->B");
assert!(!out.contains("<script src="));
}
#[test]
fn build_literal_escapes_script_close_tag() {
let params = empty_map();
let data = InjectData {
input: "before</script>after",
params: ¶ms,
title: "t",
path: None,
};
let literal = build_literal(&data);
assert!(
!literal.contains("</script>") && !literal.contains('<'),
"raw `</script>` leaked into literal: {literal}"
);
let parsed: serde_json::Value =
serde_json::from_str(&literal).expect("injected literal is valid JSON");
assert_eq!(parsed["input"], "before</script>after");
}
#[test]
fn build_literal_escapes_js_line_terminators() {
let ls = char::from_u32(0x2028).unwrap(); let ps = char::from_u32(0x2029).unwrap(); let input = format!("line1{ls}line2{ps}line3");
let params = empty_map();
let data = InjectData {
input: &input,
params: ¶ms,
title: "t",
path: None,
};
let literal = build_literal(&data);
assert!(
!literal.contains(ls) && !literal.contains(ps),
"raw JS line terminator leaked into literal: {literal}"
);
let parsed: serde_json::Value =
serde_json::from_str(&literal).expect("injected literal is valid JSON");
assert_eq!(parsed["input"], input);
}
#[test]
#[should_panic(expected = "must not be called for TemplateRef::Raw")]
fn render_panics_on_raw() {
let params = empty_map();
let data = InjectData {
input: "x",
params: ¶ms,
title: "t",
path: None,
};
let _ = render(&TemplateRef::Raw, &data);
}
}