Files
wiki-maker/src/rendering.rs
2026-02-26 19:06:41 +01:00

256 lines
7.8 KiB
Rust

use axum::{
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse},
};
use pulldown_cmark::{
CodeBlockKind, CowStr, Event, LinkType, Options, Parser as MarkdownParser, Tag, TagEnd, html,
};
use pulldown_cmark_escape::escape_html;
use std::path::PathBuf;
use std::sync::Arc;
use syntect::html::highlighted_html_for_string;
use tera::Context;
use crate::{
AppState, SYNTAX_SET, TEMPLATES, THEME_SET, WikiConfig, changelog, get_nav_links,
get_summary_data,
};
pub struct Renderer<'a> {
inner: MarkdownParser<'a>,
is_static: bool,
}
impl<'a> Renderer<'a> {
pub fn new(inner: MarkdownParser<'a>, is_static: bool) -> Self {
Self { inner, is_static }
}
}
impl<'a> Iterator for Renderer<'a> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Self::Item> {
let event = self.inner.next()?;
match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) if !matches!(link_type, LinkType::Email) && self.is_static => {
let mut new_url = dest_url.to_string();
if !new_url.contains("://")
&& !new_url.starts_with("mailto:")
&& !new_url.starts_with('#')
&& !new_url.ends_with(".html")
{
new_url.push_str(".html");
}
Some(Event::Start(Tag::Link {
link_type,
dest_url: CowStr::Boxed(new_url.into_boxed_str()),
title,
id,
}))
}
Event::Start(Tag::CodeBlock(kind)) => {
let mut code_content = String::new();
while let Some(inner_event) = self.inner.next() {
match inner_event {
Event::End(TagEnd::CodeBlock) => break,
Event::Text(code) => code_content.push_str(&code),
_ => {}
}
}
let lang = match kind {
CodeBlockKind::Indented => "text",
CodeBlockKind::Fenced(ref language) => language.as_ref(),
};
let rendered_html = render_code_to_html(&code_content, lang);
let mut escaped_code = String::new();
let _ = escape_html(&mut escaped_code, &code_content);
let rendered_html =
rendered_html.replace("<pre", &format!("<pre data-code=\"{}\"", escaped_code));
Some(Event::Html(CowStr::Boxed(rendered_html.into_boxed_str())))
}
_ => return Some(event),
}
}
}
pub fn render_code_to_html(code: &str, lang: &str) -> String {
let syntax = SYNTAX_SET
.find_syntax_by_token(lang)
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["Catppuccin Macchiato"];
highlighted_html_for_string(code, &SYNTAX_SET, syntax, theme)
.unwrap_or_else(|_| format!("<pre><code>{}</code></pre>", code))
}
#[derive(serde::Serialize)]
struct InfoboxItem {
key: String,
value: String,
}
pub async fn render_wiki_page(
filename: &str,
docs_dir: &PathBuf,
no_navigation: bool,
is_static: bool,
) -> Result<String, String> {
let toml_path = docs_dir
.join(filename)
.canonicalize()
.map_err(|_| "Not found")?;
let canonical_root = docs_dir
.canonicalize()
.map_err(|_| "Server Error: Invalid Root")?;
if !toml_path.starts_with(&canonical_root) {
return Err("Access Denied".to_string());
}
let toml_content = tokio::fs::read_to_string(&toml_path)
.await
.map_err(|_| "Page configuration not found".to_string())?;
let config: WikiConfig =
toml::from_str(&toml_content).map_err(|e| format!("Invalid TOML configuration: {}", e))?;
let markdown_content = if let Some(md_file) = &config.content_file {
let md_path = docs_dir.join(md_file);
tokio::fs::read_to_string(&md_path)
.await
.unwrap_or_else(|_| {
"# Content missing\nThe linked markdown file could not be found.".to_string()
})
} else {
String::new()
};
let mut options = Options::empty();
options.insert(
Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS,
);
let parser = MarkdownParser::new_ext(&markdown_content, options);
let renderer = Renderer::new(parser, is_static);
let mut html_output = String::new();
html::push_html(&mut html_output, renderer);
let (mut prev, mut next) = if no_navigation {
(None, None)
} else {
get_nav_links(docs_dir, filename)
};
if is_static {
prev = prev.map(|s| {
if s == "." {
"index.html".to_string()
} else {
s.replace(".toml", ".html")
}
});
next = next.map(|s| s.replace(".toml", ".html"));
}
let infobox_list: Vec<InfoboxItem> = match config.infobox {
Some(map) => map
.into_iter()
.map(|(k, v)| InfoboxItem { key: k, value: v })
.collect(),
None => Vec::new(),
};
let main_image_path = config.image.as_ref().map(|img| format!("images/{}", img));
let mut context = Context::new();
context.insert("title", &config.title);
context.insert("content", &html_output);
context.insert("infobox", &infobox_list);
context.insert("main_image", &main_image_path);
context.insert("prev_page", &prev);
context.insert("next_page", &next);
context.insert("no_navigation", &no_navigation);
context.insert("is_static", &is_static);
TEMPLATES
.render("page.html", &context)
.map_err(|e| format!("Template Error: {}", e))
}
pub async fn render_summary_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
if state.no_navigation {
return (StatusCode::NOT_FOUND, "Disabled").into_response();
}
let pages = get_summary_data(&state.docs_dir, false).await;
let changelog_entries = changelog::load_changelog(&state.docs_dir).await;
let mut context = Context::new();
context.insert("title", "Wiki Index");
context.insert("files", &pages);
context.insert("changelog", &changelog_entries);
context.insert("is_static", &false);
match TEMPLATES.render("home.html", &context) {
Ok(rendered) => Html(rendered).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn render_page_handler(
State(state): State<Arc<AppState>>,
Path(page): Path<String>,
) -> impl IntoResponse {
let filename = if page.ends_with(".toml") {
page
} else {
format!("{}.toml", page)
};
match render_wiki_page(&filename, &state.docs_dir, state.no_navigation, false).await {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::NOT_FOUND, format!("<h1>404</h1><p>{}</p>", e)).into_response(),
}
}
pub async fn render_changelog_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
if state.no_navigation {
return (StatusCode::NOT_FOUND, "Disabled").into_response();
}
let changelog_entries = changelog::load_changelog(&state.docs_dir).await;
let mut context = Context::new();
context.insert("title", "Changelog");
context.insert("changelog", &changelog_entries);
context.insert("is_static", &false);
context.insert("no_navigation", &false);
match TEMPLATES.render("changelog.html", &context) {
Ok(rendered) => Html(rendered).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}