1356 lines
47 KiB
Rust
1356 lines
47 KiB
Rust
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::thread;
|
|
|
|
use axum::Router;
|
|
use axum::extract::{Path as AxumPath, Query, State};
|
|
use axum::http::{StatusCode, Uri, header};
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
use axum::routing::get;
|
|
use serde::Deserialize;
|
|
use tokio::sync::oneshot;
|
|
|
|
use crate::db::{Database, queries};
|
|
use crate::engine::generation::PublishedPostSource;
|
|
use crate::engine::{EngineError, EngineResult};
|
|
use crate::model::{Post, PostStatus, pico_stylesheet_href};
|
|
use crate::render::{PostLanguageVariant, build_preview_response, select_post_language_variant};
|
|
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
|
|
|
pub const PREVIEW_HOST: &str = "127.0.0.1";
|
|
pub const PREVIEW_PORT: u16 = 4123;
|
|
|
|
#[derive(Debug)]
|
|
pub struct PreviewServerHandle {
|
|
shutdown: Option<oneshot::Sender<()>>,
|
|
thread: Option<thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl PreviewServerHandle {
|
|
pub fn stop(mut self) -> EngineResult<()> {
|
|
if let Some(shutdown) = self.shutdown.take() {
|
|
let _ = shutdown.send(());
|
|
}
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for PreviewServerHandle {
|
|
fn drop(&mut self) {
|
|
if let Some(shutdown) = self.shutdown.take() {
|
|
let _ = shutdown.send(());
|
|
}
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PreviewServerState {
|
|
db_path: PathBuf,
|
|
data_dir: PathBuf,
|
|
project_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
struct DraftPreviewQuery {
|
|
language: Option<String>,
|
|
theme: Option<String>,
|
|
mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
struct StylePreviewQuery {
|
|
theme: Option<String>,
|
|
mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum PreviewFeedKind {
|
|
Rss,
|
|
Atom,
|
|
}
|
|
|
|
pub fn start_preview_server(
|
|
db_path: PathBuf,
|
|
data_dir: PathBuf,
|
|
project_id: String,
|
|
) -> EngineResult<PreviewServerHandle> {
|
|
let state = PreviewServerState {
|
|
db_path,
|
|
data_dir,
|
|
project_id,
|
|
};
|
|
let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::AddrInUse {
|
|
EngineError::Conflict(format!(
|
|
"preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}"
|
|
))
|
|
} else {
|
|
EngineError::Io(error)
|
|
}
|
|
})?;
|
|
listener.set_nonblocking(true)?;
|
|
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
let thread = thread::spawn(move || {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("preview runtime");
|
|
runtime.block_on(async move {
|
|
let listener = tokio::net::TcpListener::from_std(listener).expect("preview listener");
|
|
let app = Router::new()
|
|
.route("/__draft/{post_id}", get(handle_draft_preview))
|
|
.route("/__style-preview", get(handle_style_preview))
|
|
.route("/", get(handle_preview_request))
|
|
.route("/{*path}", get(handle_preview_request))
|
|
.with_state(state);
|
|
let server = axum::serve(listener, app).with_graceful_shutdown(async {
|
|
let _ = shutdown_rx.await;
|
|
});
|
|
let _ = server.await;
|
|
});
|
|
});
|
|
|
|
Ok(PreviewServerHandle {
|
|
shutdown: Some(shutdown_tx),
|
|
thread: Some(thread),
|
|
})
|
|
}
|
|
|
|
async fn handle_preview_request(
|
|
State(state): State<PreviewServerState>,
|
|
Query(query): Query<StylePreviewQuery>,
|
|
uri: Uri,
|
|
) -> Response {
|
|
match render_preview_response(&state, uri.path(), None, Some(&query)) {
|
|
Ok(response) => response,
|
|
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
}
|
|
}
|
|
|
|
async fn handle_draft_preview(
|
|
State(state): State<PreviewServerState>,
|
|
AxumPath(post_id): AxumPath<String>,
|
|
Query(query): Query<DraftPreviewQuery>,
|
|
) -> Response {
|
|
let style = StylePreviewQuery {
|
|
theme: query.theme.clone(),
|
|
mode: query.mode.clone(),
|
|
};
|
|
match render_preview_response(
|
|
&state,
|
|
&format!("/__draft/{post_id}"),
|
|
query.language.as_deref(),
|
|
Some(&style),
|
|
) {
|
|
Ok(response) => response,
|
|
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
|
}
|
|
}
|
|
|
|
async fn handle_style_preview(
|
|
State(state): State<PreviewServerState>,
|
|
Query(query): Query<StylePreviewQuery>,
|
|
) -> Response {
|
|
let metadata = match crate::engine::meta::read_project_json(&state.data_dir) {
|
|
Ok(metadata) => metadata,
|
|
Err(error) => {
|
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string());
|
|
}
|
|
};
|
|
let theme = query.theme.as_deref().unwrap_or("default");
|
|
let stylesheet = pico_stylesheet_href(Some(theme));
|
|
let mode = match query.mode.as_deref().map(str::trim) {
|
|
Some("light") => Some("light"),
|
|
Some("dark") => Some("dark"),
|
|
_ => None,
|
|
};
|
|
let mode_attributes = mode
|
|
.map(|mode| format!(" data-theme=\"{mode}\" data-mode=\"{mode}\""))
|
|
.unwrap_or_default();
|
|
let project_name = escape_html(&metadata.name);
|
|
let language = escape_html(metadata.main_language.as_deref().unwrap_or("en"));
|
|
let description = escape_html(metadata.description.as_deref().unwrap_or(&metadata.name));
|
|
let html = format!(
|
|
"<!doctype html><html lang=\"{language}\"{mode_attributes}><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>{project_name}</title><link rel=\"stylesheet\" href=\"{stylesheet}\" /><link rel=\"stylesheet\" href=\"/assets/bds.css\" /></head><body><main class=\"container\"><nav><ul><li><strong>{project_name}</strong></li></ul></nav><article><header><h1>{project_name}</h1></header><p>{description}</p><progress value=\"70\" max=\"100\"></progress><input type=\"text\" value=\"{project_name}\" aria-label=\"{project_name}\" /><button type=\"button\">{project_name}</button></article></main></body></html>"
|
|
);
|
|
Html(html).into_response()
|
|
}
|
|
|
|
fn render_preview_response(
|
|
state: &PreviewServerState,
|
|
path: &str,
|
|
requested_language: Option<&str>,
|
|
style: Option<&StylePreviewQuery>,
|
|
) -> EngineResult<Response> {
|
|
if let Some(post_id) = path.strip_prefix("/__draft/") {
|
|
let html = apply_preview_style(
|
|
render_draft_preview(state, post_id, requested_language)?,
|
|
style,
|
|
);
|
|
return Ok(Html(html).into_response());
|
|
}
|
|
|
|
if let Some(file_response) = serve_project_file(&state.data_dir, path)? {
|
|
return Ok(file_response);
|
|
}
|
|
|
|
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
|
let db = Database::open(&state.db_path)?;
|
|
let preview_posts = collect_preview_posts(state)?;
|
|
if path == "/calendar.json" {
|
|
let posts = preview_posts
|
|
.iter()
|
|
.map(|source| source.post.clone())
|
|
.collect::<Vec<_>>();
|
|
let json = crate::render::build_calendar_json(&posts)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
return Ok((
|
|
StatusCode::OK,
|
|
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
|
|
json,
|
|
)
|
|
.into_response());
|
|
}
|
|
if let Some((language, kind)) = preview_feed_request(path, &metadata) {
|
|
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
|
let localized_posts = if language.eq_ignore_ascii_case(main_language) {
|
|
preview_posts.clone()
|
|
} else {
|
|
localized_preview_posts(
|
|
db.conn(),
|
|
&state.data_dir,
|
|
&preview_posts,
|
|
&language,
|
|
main_language,
|
|
)?
|
|
.into_iter()
|
|
.filter(|source| {
|
|
source
|
|
.post
|
|
.language
|
|
.as_deref()
|
|
.is_some_and(|post_language| post_language.eq_ignore_ascii_case(&language))
|
|
})
|
|
.collect()
|
|
};
|
|
let (content_type, xml) = match kind {
|
|
PreviewFeedKind::Rss => (
|
|
"application/rss+xml; charset=utf-8",
|
|
crate::engine::generation::build_rss_xml(&metadata, &localized_posts, &language),
|
|
),
|
|
PreviewFeedKind::Atom => (
|
|
"application/atom+xml; charset=utf-8",
|
|
crate::engine::generation::build_atom_xml(&metadata, &localized_posts, &language),
|
|
),
|
|
};
|
|
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, content_type)], xml).into_response());
|
|
}
|
|
let input_posts = preview_posts
|
|
.iter()
|
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
|
.collect::<Vec<_>>();
|
|
let response = build_preview_response(
|
|
db.conn(),
|
|
&state.data_dir,
|
|
&state.project_id,
|
|
&metadata,
|
|
&input_posts,
|
|
path,
|
|
)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK);
|
|
Ok((status, Html(apply_preview_style(response.html, style))).into_response())
|
|
}
|
|
|
|
fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> String {
|
|
let Some(style) = style else {
|
|
return html;
|
|
};
|
|
|
|
let mut styled = html;
|
|
if let Some(theme) = style.theme.as_deref().filter(|theme| !theme.is_empty()) {
|
|
styled = override_pico_stylesheet(&styled, &pico_stylesheet_href(Some(theme)));
|
|
}
|
|
if let Some(mode) = style.mode.as_deref() {
|
|
let mode = match mode.trim() {
|
|
"light" => Some("light"),
|
|
"dark" => Some("dark"),
|
|
_ => None,
|
|
};
|
|
styled = override_html_attribute(&styled, "data-theme", mode);
|
|
styled = override_html_attribute(&styled, "data-mode", mode);
|
|
}
|
|
styled
|
|
}
|
|
|
|
fn override_pico_stylesheet(html: &str, href: &str) -> String {
|
|
let Some(start) = html.find("/assets/pico") else {
|
|
return html.to_string();
|
|
};
|
|
let Some(end_offset) = html[start..].find(".min.css") else {
|
|
return html.to_string();
|
|
};
|
|
let end = start + end_offset + ".min.css".len();
|
|
format!("{}{}{}", &html[..start], href, &html[end..])
|
|
}
|
|
|
|
fn override_html_attribute(html: &str, attribute: &str, value: Option<&str>) -> String {
|
|
let Some(html_start) = html.find("<html") else {
|
|
return html.to_string();
|
|
};
|
|
let Some(tag_end_offset) = html[html_start..].find('>') else {
|
|
return html.to_string();
|
|
};
|
|
let tag_end = html_start + tag_end_offset;
|
|
let attribute_start_pattern = format!(" {attribute}=\"");
|
|
let existing = html[html_start..tag_end]
|
|
.find(&attribute_start_pattern)
|
|
.and_then(|offset| {
|
|
let start = html_start + offset;
|
|
html[start + attribute_start_pattern.len()..tag_end]
|
|
.find('"')
|
|
.map(|end_offset| {
|
|
(
|
|
start,
|
|
start + attribute_start_pattern.len() + end_offset + 1,
|
|
)
|
|
})
|
|
});
|
|
|
|
match (existing, value) {
|
|
(Some((start, end)), Some(value)) => format!(
|
|
"{} {attribute}=\"{}\"{}",
|
|
&html[..start],
|
|
value,
|
|
&html[end..]
|
|
),
|
|
(Some((start, end)), None) => format!("{}{}", &html[..start], &html[end..]),
|
|
(None, Some(value)) => format!(
|
|
"{} {attribute}=\"{}\"{}",
|
|
&html[..tag_end],
|
|
value,
|
|
&html[tag_end..]
|
|
),
|
|
(None, None) => html.to_string(),
|
|
}
|
|
}
|
|
|
|
fn escape_html(value: &str) -> String {
|
|
value
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
fn render_draft_preview(
|
|
state: &PreviewServerState,
|
|
post_id: &str,
|
|
requested_language: Option<&str>,
|
|
) -> EngineResult<String> {
|
|
let db = Database::open(&state.db_path)?;
|
|
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
|
let post = queries::post::get_post_by_id(db.conn(), post_id)?;
|
|
let canonical_language = post
|
|
.language
|
|
.as_deref()
|
|
.unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en"));
|
|
let target_language = requested_language.unwrap_or(canonical_language);
|
|
|
|
if target_language != canonical_language
|
|
&& let Ok(translation) =
|
|
queries::post_translation::get_post_translation_by_post_and_language(
|
|
db.conn(),
|
|
post_id,
|
|
target_language,
|
|
)
|
|
{
|
|
let mut translated_post = post.clone();
|
|
translated_post.title = translation.title.clone();
|
|
translated_post.excerpt = translation.excerpt.clone();
|
|
translated_post.language = Some(translation.language.clone());
|
|
translated_post.status = translation.status.clone();
|
|
translated_post.file_path = translation.file_path.clone();
|
|
translated_post.published_at = translation.published_at.or(post.published_at);
|
|
let body = load_translation_body(&state.data_dir, &translation)?;
|
|
let response = build_preview_response(
|
|
db.conn(),
|
|
&state.data_dir,
|
|
&state.project_id,
|
|
&metadata,
|
|
&[(translated_post, body)],
|
|
&crate::render::build_canonical_post_path(
|
|
&post,
|
|
target_language,
|
|
metadata.main_language.as_deref().unwrap_or("en"),
|
|
),
|
|
)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
return Ok(response.html);
|
|
}
|
|
|
|
let body = load_post_body(&state.data_dir, &post)?;
|
|
let response = build_preview_response(
|
|
db.conn(),
|
|
&state.data_dir,
|
|
&state.project_id,
|
|
&metadata,
|
|
&[(post.clone(), body)],
|
|
&crate::render::build_canonical_post_path(
|
|
&post,
|
|
canonical_language,
|
|
metadata.main_language.as_deref().unwrap_or("en"),
|
|
),
|
|
)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
Ok(response.html)
|
|
}
|
|
|
|
fn collect_preview_posts(state: &PreviewServerState) -> EngineResult<Vec<PublishedPostSource>> {
|
|
let db = Database::open(&state.db_path)?;
|
|
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
|
let mut preview_posts = Vec::new();
|
|
for post in posts
|
|
.into_iter()
|
|
.filter(|post| matches!(post.status, PostStatus::Draft | PostStatus::Published))
|
|
{
|
|
preview_posts.push(PublishedPostSource {
|
|
body_markdown: load_post_body(&state.data_dir, &post)?,
|
|
post,
|
|
});
|
|
}
|
|
preview_posts.sort_by(|left, right| {
|
|
right
|
|
.post
|
|
.created_at
|
|
.cmp(&left.post.created_at)
|
|
.then_with(|| right.post.published_at.cmp(&left.post.published_at))
|
|
.then_with(|| left.post.slug.cmp(&right.post.slug))
|
|
});
|
|
Ok(preview_posts)
|
|
}
|
|
|
|
fn preview_feed_request(
|
|
path: &str,
|
|
metadata: &crate::model::ProjectMetadata,
|
|
) -> Option<(String, PreviewFeedKind)> {
|
|
let segments = path
|
|
.trim_matches('/')
|
|
.split('/')
|
|
.filter(|segment| !segment.is_empty())
|
|
.collect::<Vec<_>>();
|
|
let (language, filename) = match segments.as_slice() {
|
|
[filename] => (metadata.main_language.as_deref().unwrap_or("en"), *filename),
|
|
[language, filename]
|
|
if metadata
|
|
.blog_languages
|
|
.iter()
|
|
.any(|candidate| candidate.eq_ignore_ascii_case(language))
|
|
&& !language
|
|
.eq_ignore_ascii_case(metadata.main_language.as_deref().unwrap_or("en")) =>
|
|
{
|
|
(*language, *filename)
|
|
}
|
|
_ => return None,
|
|
};
|
|
let kind = match filename {
|
|
"rss.xml" => PreviewFeedKind::Rss,
|
|
"atom.xml" => PreviewFeedKind::Atom,
|
|
_ => return None,
|
|
};
|
|
Some((language.to_string(), kind))
|
|
}
|
|
|
|
fn localized_preview_posts(
|
|
conn: &crate::db::DbConnection,
|
|
data_dir: &Path,
|
|
posts: &[PublishedPostSource],
|
|
language: &str,
|
|
main_language: &str,
|
|
) -> EngineResult<Vec<PublishedPostSource>> {
|
|
let mut localized = Vec::new();
|
|
for source in posts {
|
|
let translation = queries::post_translation::get_post_translation_by_post_and_language(
|
|
conn,
|
|
&source.post.id,
|
|
language,
|
|
)
|
|
.ok()
|
|
.filter(|translation| {
|
|
(translation.status == PostStatus::Draft && translation.content.is_some())
|
|
|| (!translation.file_path.trim().is_empty()
|
|
&& data_dir
|
|
.join(translation.file_path.trim_start_matches('/'))
|
|
.is_file())
|
|
});
|
|
match select_post_language_variant(
|
|
&source.post,
|
|
language,
|
|
main_language,
|
|
translation.is_some(),
|
|
) {
|
|
Some(PostLanguageVariant::Base) => localized.push(source.clone()),
|
|
Some(PostLanguageVariant::Translation) => {
|
|
let Some(translation) = translation else {
|
|
continue;
|
|
};
|
|
let mut translated_post = source.post.clone();
|
|
translated_post.id = translation.id.clone();
|
|
translated_post.title = translation.title.clone();
|
|
translated_post.excerpt = translation.excerpt.clone();
|
|
translated_post.language = Some(translation.language.clone());
|
|
translated_post.status = translation.status.clone();
|
|
translated_post.file_path = translation.file_path.clone();
|
|
translated_post.updated_at = translation.updated_at;
|
|
translated_post.published_at =
|
|
translation.published_at.or(source.post.published_at);
|
|
localized.push(PublishedPostSource {
|
|
post: translated_post,
|
|
body_markdown: load_translation_body(data_dir, &translation)?,
|
|
});
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
Ok(localized)
|
|
}
|
|
|
|
fn load_post_body(data_dir: &Path, post: &Post) -> EngineResult<String> {
|
|
if post.status == PostStatus::Draft
|
|
&& let Some(content) = &post.content
|
|
{
|
|
return Ok(content.clone());
|
|
}
|
|
if post.file_path.trim().is_empty() {
|
|
return Ok(String::new());
|
|
}
|
|
load_markdown_body(data_dir, &post.file_path, false)
|
|
}
|
|
|
|
fn load_translation_body(
|
|
data_dir: &Path,
|
|
translation: &crate::model::PostTranslation,
|
|
) -> EngineResult<String> {
|
|
if translation.status == PostStatus::Draft
|
|
&& let Some(content) = &translation.content
|
|
{
|
|
return Ok(content.clone());
|
|
}
|
|
if translation.file_path.trim().is_empty() {
|
|
return Ok(String::new());
|
|
}
|
|
load_markdown_body(data_dir, &translation.file_path, true)
|
|
}
|
|
|
|
fn load_markdown_body(
|
|
data_dir: &Path,
|
|
relative_path: &str,
|
|
translation: bool,
|
|
) -> EngineResult<String> {
|
|
let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?;
|
|
let body = if translation {
|
|
read_translation_file(&raw).map(|(_, body)| body)
|
|
} else {
|
|
read_post_file(&raw).map(|(_, body)| body)
|
|
}
|
|
.map_err(EngineError::Parse)?;
|
|
Ok(body)
|
|
}
|
|
|
|
fn serve_project_file(data_dir: &Path, path: &str) -> EngineResult<Option<Response>> {
|
|
if let Some(response) = serve_scoped_file(data_dir, path, "/media/", "media")? {
|
|
return Ok(Some(response));
|
|
}
|
|
if let Some(response) = serve_scoped_file(data_dir, path, "/assets/", "assets")? {
|
|
return Ok(Some(response));
|
|
}
|
|
if let Some(response) = serve_scoped_file(data_dir, path, "/images/", "images")? {
|
|
return Ok(Some(response));
|
|
}
|
|
if let Some(response) =
|
|
serve_scoped_file(&data_dir.join("html"), path, "/pagefind/", "pagefind")?
|
|
{
|
|
return Ok(Some(response));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn serve_scoped_file(
|
|
data_dir: &Path,
|
|
path: &str,
|
|
prefix: &str,
|
|
scope_dir: &str,
|
|
) -> EngineResult<Option<Response>> {
|
|
let Some(relative) = path.strip_prefix(prefix) else {
|
|
return Ok(None);
|
|
};
|
|
let scope_root = data_dir.join(scope_dir);
|
|
let candidate = scope_root.join(relative);
|
|
if !candidate.exists() || !candidate.is_file() {
|
|
let bundled_path = format!("{scope_dir}/{relative}");
|
|
if let Some(bytes) = crate::engine::site_assets::bundled_site_asset(&bundled_path) {
|
|
let mime = guess_content_type(Path::new(&bundled_path));
|
|
return Ok(Some(
|
|
(StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
|
|
));
|
|
}
|
|
return Ok(Some(error_response(
|
|
StatusCode::NOT_FOUND,
|
|
"preview asset not found",
|
|
)));
|
|
}
|
|
let canonical_candidate = candidate.canonicalize()?;
|
|
let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root);
|
|
if !canonical_candidate.starts_with(&canonical_scope_root) {
|
|
return Ok(Some(error_response(
|
|
StatusCode::NOT_FOUND,
|
|
"preview asset not found",
|
|
)));
|
|
}
|
|
let bytes = fs::read(&canonical_candidate)?;
|
|
let mime = guess_content_type(&canonical_candidate);
|
|
Ok(Some(
|
|
(StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(),
|
|
))
|
|
}
|
|
|
|
fn guess_content_type(path: &Path) -> &'static str {
|
|
match path
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.unwrap_or_default()
|
|
{
|
|
"css" => "text/css; charset=utf-8",
|
|
"js" => "application/javascript; charset=utf-8",
|
|
"json" => "application/json; charset=utf-8",
|
|
"svg" => "image/svg+xml",
|
|
"png" => "image/png",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|
|
|
|
fn error_response(status: StatusCode, message: &str) -> Response {
|
|
(
|
|
status,
|
|
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
|
message.to_string(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::queries;
|
|
use crate::engine::meta;
|
|
use crate::model::{Post, PostStatus, PostTranslation, Project, ProjectMetadata};
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
fn preview_port_guard() -> &'static Mutex<()> {
|
|
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
|
GUARD.get_or_init(|| Mutex::new(()))
|
|
}
|
|
|
|
fn setup_preview_fixture() -> (tempfile::TempDir, Database) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
|
std::fs::create_dir_all(dir.path().join("posts/2024/03")).unwrap();
|
|
std::fs::create_dir_all(dir.path().join("media")).unwrap();
|
|
|
|
meta::write_project_json(dir.path(), &make_metadata()).unwrap();
|
|
|
|
let db_path = dir.path().join("bds.db");
|
|
let db = Database::open(&db_path).unwrap();
|
|
db.migrate().unwrap();
|
|
queries::project::insert_project(
|
|
db.conn(),
|
|
&Project {
|
|
id: "project-1".into(),
|
|
name: "Blog".into(),
|
|
slug: "blog".into(),
|
|
description: None,
|
|
data_path: Some(dir.path().to_string_lossy().to_string()),
|
|
is_active: true,
|
|
created_at: 1_710_000_000_000,
|
|
updated_at: 1_710_000_000_000,
|
|
},
|
|
)
|
|
.unwrap();
|
|
(dir, db)
|
|
}
|
|
|
|
fn response_text(response: Response) -> String {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let bytes = runtime
|
|
.block_on(axum::body::to_bytes(response.into_body(), usize::MAX))
|
|
.unwrap();
|
|
String::from_utf8(bytes.to_vec()).unwrap()
|
|
}
|
|
|
|
fn make_metadata() -> ProjectMetadata {
|
|
ProjectMetadata {
|
|
name: "Blog".into(),
|
|
description: None,
|
|
public_url: Some("https://example.com".into()),
|
|
main_language: Some("en".into()),
|
|
default_author: None,
|
|
max_posts_per_page: 50,
|
|
image_import_concurrency: 4,
|
|
blogmark_category: None,
|
|
pico_theme: None,
|
|
semantic_similarity_enabled: false,
|
|
blog_languages: vec!["en".into(), "de".into()],
|
|
}
|
|
}
|
|
|
|
fn make_post() -> PublishedPostSource {
|
|
PublishedPostSource {
|
|
post: Post {
|
|
id: "post-1".into(),
|
|
project_id: "project-1".into(),
|
|
title: "Hello".into(),
|
|
slug: "hello".into(),
|
|
excerpt: None,
|
|
content: Some("Body".into()),
|
|
status: PostStatus::Published,
|
|
author: None,
|
|
language: Some("en".into()),
|
|
do_not_translate: false,
|
|
template_slug: None,
|
|
file_path: String::new(),
|
|
checksum: None,
|
|
tags: vec![],
|
|
categories: vec![],
|
|
published_title: None,
|
|
published_content: None,
|
|
published_tags: None,
|
|
published_categories: None,
|
|
published_excerpt: None,
|
|
created_at: 1_710_000_000_000,
|
|
updated_at: 1_710_000_000_000,
|
|
published_at: Some(1_710_000_000_000),
|
|
},
|
|
body_markdown: "Hello **world**".into(),
|
|
}
|
|
}
|
|
|
|
fn make_draft_post() -> Post {
|
|
Post {
|
|
id: "post-1".into(),
|
|
project_id: "project-1".into(),
|
|
title: "Hello".into(),
|
|
slug: "hello".into(),
|
|
excerpt: Some("Excerpt".into()),
|
|
content: Some("Draft **body**".into()),
|
|
status: PostStatus::Draft,
|
|
author: None,
|
|
language: Some("en".into()),
|
|
do_not_translate: false,
|
|
template_slug: None,
|
|
file_path: "posts/2024/03/hello.md".into(),
|
|
checksum: None,
|
|
tags: vec![],
|
|
categories: vec![],
|
|
published_title: None,
|
|
published_content: None,
|
|
published_tags: None,
|
|
published_categories: None,
|
|
published_excerpt: None,
|
|
created_at: 1_710_000_000_000,
|
|
updated_at: 1_710_000_000_000,
|
|
published_at: Some(1_710_000_000_000),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn root_preview_renders_index_page() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let html = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&make_metadata(),
|
|
&[(make_post().post, make_post().body_markdown)],
|
|
"/",
|
|
)
|
|
.unwrap()
|
|
.html;
|
|
assert!(html.contains("post-list"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_renders_single_post_for_canonical_path() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let source = make_post();
|
|
let html = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&make_metadata(),
|
|
&[(source.post, source.body_markdown)],
|
|
"/2024/03/09/hello",
|
|
)
|
|
.unwrap()
|
|
.html;
|
|
assert!(html.contains("<h1>Hello</h1>"));
|
|
assert!(html.contains("<strong>world</strong>"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_renders_page_category_post_at_flat_path() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let mut source = make_post();
|
|
source.post.categories = vec!["page".into()];
|
|
let response = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&make_metadata(),
|
|
&[(source.post, source.body_markdown)],
|
|
"/hello",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status_code, 200);
|
|
assert!(response.html.contains("<h1>Hello</h1>"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_renders_language_prefixed_single_post() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
|
std::fs::write(
|
|
dir.path().join("posts/2024/03/hello.de.md"),
|
|
"---\ntranslationFor: post-1\nlanguage: de\ntitle: Hallo\n---\nHallo **welt**",
|
|
)
|
|
.ok();
|
|
let db = Database::open_in_memory().unwrap();
|
|
let source = make_post();
|
|
let html = build_preview_response(
|
|
db.conn(),
|
|
dir.path(),
|
|
"project-1",
|
|
&make_metadata(),
|
|
&[(source.post, source.body_markdown)],
|
|
"/de/2024/03/09/hello",
|
|
)
|
|
.unwrap()
|
|
.html;
|
|
assert!(html.contains("lang=\"de\""));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_server_serves_draft_post_from_localhost() {
|
|
let _guard = preview_port_guard().lock().unwrap();
|
|
let (dir, db) = setup_preview_fixture();
|
|
queries::post::insert_post(db.conn(), &make_draft_post()).unwrap();
|
|
|
|
let server = start_preview_server(
|
|
dir.path().join("bds.db"),
|
|
dir.path().to_path_buf(),
|
|
"project-1".into(),
|
|
)
|
|
.unwrap();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let mut body = None;
|
|
for _ in 0..20 {
|
|
if let Ok(response) = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1"
|
|
))
|
|
.send()
|
|
&& response.status().is_success()
|
|
{
|
|
body = Some(response.text().unwrap());
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
}
|
|
server.stop().unwrap();
|
|
|
|
let body = body.expect("draft preview response");
|
|
assert!(body.contains("<h1>Hello</h1>"));
|
|
assert!(body.contains("<strong>body</strong>"));
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_preview_routes_overlay_drafts_and_use_files_for_published_posts() {
|
|
let (dir, db) = setup_preview_fixture();
|
|
let draft = make_draft_post();
|
|
queries::post::insert_post(db.conn(), &draft).unwrap();
|
|
queries::post_translation::insert_post_translation(
|
|
db.conn(),
|
|
&PostTranslation {
|
|
id: "translation-1".into(),
|
|
project_id: "project-1".into(),
|
|
translation_for: draft.id.clone(),
|
|
language: "de".into(),
|
|
title: "Hallo".into(),
|
|
excerpt: None,
|
|
content: Some("Deutscher **Entwurf**".into()),
|
|
status: PostStatus::Draft,
|
|
file_path: String::new(),
|
|
checksum: None,
|
|
created_at: draft.created_at,
|
|
updated_at: draft.updated_at,
|
|
published_at: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let mut published = make_draft_post();
|
|
published.id = "post-2".into();
|
|
published.title = "Published from file".into();
|
|
published.slug = "published-from-file".into();
|
|
published.status = PostStatus::Published;
|
|
published.content = Some("Stale database body".into());
|
|
published.file_path = "posts/2024/03/published-from-file.md".into();
|
|
queries::post::insert_post(db.conn(), &published).unwrap();
|
|
std::fs::write(
|
|
dir.path().join(&published.file_path),
|
|
crate::util::frontmatter::write_post_file(&published, "Filesystem **body**"),
|
|
)
|
|
.unwrap();
|
|
|
|
let state = PreviewServerState {
|
|
db_path: dir.path().join("bds.db"),
|
|
data_dir: dir.path().to_path_buf(),
|
|
project_id: "project-1".into(),
|
|
};
|
|
let draft_path = crate::render::build_canonical_post_path(&draft, "en", "en");
|
|
let published_path = crate::render::build_canonical_post_path(&published, "en", "en");
|
|
let translated_draft_path = crate::render::build_canonical_post_path(&draft, "de", "en");
|
|
|
|
let draft_response = render_preview_response(&state, &draft_path, None, None).unwrap();
|
|
let published_response =
|
|
render_preview_response(&state, &published_path, None, None).unwrap();
|
|
let index_response = render_preview_response(&state, "/", None, None).unwrap();
|
|
let translated_draft_response =
|
|
render_preview_response(&state, &translated_draft_path, None, None).unwrap();
|
|
let calendar_response =
|
|
render_preview_response(&state, "/calendar.json", None, None).unwrap();
|
|
let rss_response = render_preview_response(&state, "/rss.xml", None, None).unwrap();
|
|
let translated_atom_response =
|
|
render_preview_response(&state, "/de/atom.xml", None, None).unwrap();
|
|
let draft_html = response_text(draft_response);
|
|
let published_html = response_text(published_response);
|
|
let index_html = response_text(index_response);
|
|
let translated_draft_html = response_text(translated_draft_response);
|
|
let calendar_json = response_text(calendar_response);
|
|
let rss_xml = response_text(rss_response);
|
|
let translated_atom_xml = response_text(translated_atom_response);
|
|
|
|
assert!(draft_html.contains("Draft <strong>body</strong>"));
|
|
assert!(published_html.contains("Filesystem <strong>body</strong>"));
|
|
assert!(!published_html.contains("Stale database body"));
|
|
assert!(index_html.contains("Hello"));
|
|
assert!(index_html.contains("Published from file"));
|
|
assert!(translated_draft_html.contains("Deutscher <strong>Entwurf</strong>"));
|
|
assert!(calendar_json.contains("2024"));
|
|
assert!(rss_xml.contains("<rss"));
|
|
assert!(rss_xml.contains("<title>Hello</title>"));
|
|
assert!(rss_xml.contains("<title>Published from file</title>"));
|
|
assert!(translated_atom_xml.contains("<feed"));
|
|
assert!(translated_atom_xml.contains("<title>Hallo</title>"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_server_blocks_media_path_traversal() {
|
|
let _guard = preview_port_guard().lock().unwrap();
|
|
let (dir, _db) = setup_preview_fixture();
|
|
std::fs::write(dir.path().join("outside.txt"), "nope").unwrap();
|
|
std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap();
|
|
|
|
let server = start_preview_server(
|
|
dir.path().join("bds.db"),
|
|
dir.path().to_path_buf(),
|
|
"project-1".into(),
|
|
)
|
|
.unwrap();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let response = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"
|
|
))
|
|
.send()
|
|
.unwrap();
|
|
server.stop().unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn preview_server_serves_media_and_asset_files() {
|
|
let _guard = preview_port_guard().lock().unwrap();
|
|
let (dir, _db) = setup_preview_fixture();
|
|
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
|
|
std::fs::create_dir_all(dir.path().join("images")).unwrap();
|
|
std::fs::create_dir_all(dir.path().join("html/pagefind")).unwrap();
|
|
std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap();
|
|
std::fs::write(dir.path().join("assets/site.css"), "body { color: red; }").unwrap();
|
|
std::fs::write(dir.path().join("images/custom.svg"), "<svg></svg>").unwrap();
|
|
std::fs::write(
|
|
dir.path().join("html/pagefind/pagefind-ui.js"),
|
|
"window.pagefind = true;",
|
|
)
|
|
.unwrap();
|
|
|
|
let server = start_preview_server(
|
|
dir.path().join("bds.db"),
|
|
dir.path().to_path_buf(),
|
|
"project-1".into(),
|
|
)
|
|
.unwrap();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let media = client
|
|
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/ok.txt"))
|
|
.send()
|
|
.unwrap();
|
|
let asset = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"
|
|
))
|
|
.send()
|
|
.unwrap();
|
|
let media_body = media.text().unwrap();
|
|
let asset_body = asset.text().unwrap();
|
|
let image_body = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/images/custom.svg"
|
|
))
|
|
.send()
|
|
.unwrap()
|
|
.text()
|
|
.unwrap();
|
|
let pagefind_body = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/pagefind/pagefind-ui.js"
|
|
))
|
|
.send()
|
|
.unwrap()
|
|
.text()
|
|
.unwrap();
|
|
server.stop().unwrap();
|
|
|
|
assert_eq!(media_body, "ok");
|
|
assert!(asset_body.contains("color: red"));
|
|
assert!(image_body.contains("<svg>"));
|
|
assert!(pagefind_body.contains("window.pagefind"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_server_serves_style_preview() {
|
|
let _guard = preview_port_guard().lock().unwrap();
|
|
let (dir, _db) = setup_preview_fixture();
|
|
|
|
let server = start_preview_server(
|
|
dir.path().join("bds.db"),
|
|
dir.path().to_path_buf(),
|
|
"project-1".into(),
|
|
)
|
|
.unwrap();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let response = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=amber&mode=dark"
|
|
))
|
|
.send()
|
|
.unwrap();
|
|
let body = response.text().unwrap();
|
|
let stylesheet = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/pico.amber.min.css"
|
|
))
|
|
.send()
|
|
.unwrap();
|
|
assert!(stylesheet.status().is_success());
|
|
let stylesheet = stylesheet.text().unwrap();
|
|
server.stop().unwrap();
|
|
|
|
assert!(body.contains("<article>"));
|
|
assert!(body.contains("Blog"));
|
|
assert!(body.contains("href=\"/assets/pico.amber.min.css\""));
|
|
assert!(body.contains("data-theme=\"dark\""));
|
|
assert!(body.contains("data-mode=\"dark\""));
|
|
assert!(!body.contains("data-theme=\"amber\""));
|
|
assert!(stylesheet.contains("--pico-primary-background:#ffbf00"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_server_applies_theme_query_to_rendered_pages() {
|
|
let _guard = preview_port_guard().lock().unwrap();
|
|
let (dir, _db) = setup_preview_fixture();
|
|
|
|
let server = start_preview_server(
|
|
dir.path().join("bds.db"),
|
|
dir.path().to_path_buf(),
|
|
"project-1".into(),
|
|
)
|
|
.unwrap();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let response = client
|
|
.get(format!(
|
|
"http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=amber&mode=light"
|
|
))
|
|
.send()
|
|
.unwrap();
|
|
let body = response.text().unwrap();
|
|
server.stop().unwrap();
|
|
|
|
assert!(body.contains("href=\"/assets/pico.amber.min.css\""));
|
|
assert!(body.contains("data-theme=\"light\""));
|
|
assert!(body.contains("data-mode=\"light\""));
|
|
assert!(!body.contains("data-theme=\"amber\""));
|
|
}
|
|
|
|
#[test]
|
|
fn auto_preview_mode_removes_forced_color_scheme_and_invalid_theme_falls_back() {
|
|
let html = "<html data-theme=\"dark\" data-mode=\"dark\"><head><link rel=\"stylesheet\" href=\"/assets/pico.blue.min.css\"></head></html>".to_string();
|
|
let styled = apply_preview_style(
|
|
html,
|
|
Some(&StylePreviewQuery {
|
|
theme: Some("../../secret".into()),
|
|
mode: Some("auto".into()),
|
|
}),
|
|
);
|
|
|
|
assert!(styled.contains("href=\"/assets/pico.min.css\""));
|
|
assert!(!styled.contains("data-theme="));
|
|
assert!(!styled.contains("data-mode="));
|
|
assert!(!styled.contains("secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_respects_category_list_visibility_and_show_title_rules() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
|
crate::engine::meta::write_category_meta_json(
|
|
dir.path(),
|
|
&std::collections::HashMap::from([
|
|
(
|
|
"hidden".to_string(),
|
|
crate::model::CategorySettings {
|
|
render_in_lists: false,
|
|
show_title: true,
|
|
post_template_slug: None,
|
|
list_template_slug: None,
|
|
},
|
|
),
|
|
(
|
|
"featured".to_string(),
|
|
crate::model::CategorySettings {
|
|
render_in_lists: true,
|
|
show_title: false,
|
|
post_template_slug: None,
|
|
list_template_slug: None,
|
|
},
|
|
),
|
|
]),
|
|
)
|
|
.unwrap();
|
|
|
|
let db = Database::open_in_memory().unwrap();
|
|
let mut hidden = make_post();
|
|
hidden.post.title = "Hidden Post".into();
|
|
hidden.post.slug = "hidden-post".into();
|
|
hidden.post.categories = vec!["hidden".into()];
|
|
hidden.body_markdown = "Hidden body".into();
|
|
|
|
let mut featured = make_post();
|
|
featured.post.title = "Featured Post".into();
|
|
featured.post.slug = "featured-post".into();
|
|
featured.post.categories = vec!["featured".into()];
|
|
featured.body_markdown = "Featured body".into();
|
|
|
|
let hidden_response = build_preview_response(
|
|
db.conn(),
|
|
dir.path(),
|
|
"project-1",
|
|
&make_metadata(),
|
|
&[
|
|
(hidden.post.clone(), hidden.body_markdown.clone()),
|
|
(featured.post.clone(), featured.body_markdown.clone()),
|
|
],
|
|
"/category/hidden",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(hidden_response.status_code, 404);
|
|
|
|
let featured_response = build_preview_response(
|
|
db.conn(),
|
|
dir.path(),
|
|
"project-1",
|
|
&make_metadata(),
|
|
&[
|
|
(hidden.post, hidden.body_markdown),
|
|
(featured.post, featured.body_markdown),
|
|
],
|
|
"/category/featured",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(featured_response.status_code, 200);
|
|
assert!(featured_response.html.contains("Featured body"));
|
|
assert!(!featured_response.html.contains("<h2 class=\"post-title\""));
|
|
assert!(!featured_response.html.contains("Featured Post"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_renders_tag_archive_pagination_and_date_routes() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let mut metadata = make_metadata();
|
|
metadata.max_posts_per_page = 1;
|
|
|
|
let mut oldest = make_post();
|
|
oldest.post.id = "post-1".into();
|
|
oldest.post.slug = "alpha".into();
|
|
oldest.post.title = "Alpha".into();
|
|
oldest.post.tags = vec!["Rust".into()];
|
|
oldest.post.published_at = Some(1_709_568_000_000);
|
|
oldest.post.created_at = 1_709_568_000_000;
|
|
oldest.body_markdown = "Alpha body".into();
|
|
|
|
let mut newest = make_post();
|
|
newest.post.id = "post-2".into();
|
|
newest.post.slug = "beta".into();
|
|
newest.post.title = "Beta".into();
|
|
newest.post.tags = vec!["Rust".into()];
|
|
newest.post.published_at = Some(1_710_086_400_000);
|
|
newest.post.created_at = 1_710_086_400_000;
|
|
newest.body_markdown = "Beta body".into();
|
|
|
|
let mut april = make_post();
|
|
april.post.id = "post-3".into();
|
|
april.post.slug = "gamma".into();
|
|
april.post.title = "Gamma".into();
|
|
april.post.tags = vec!["Preview".into()];
|
|
april.post.published_at = Some(1_712_016_000_000);
|
|
april.post.created_at = 1_712_016_000_000;
|
|
april.body_markdown = "Gamma body".into();
|
|
|
|
let posts = vec![
|
|
(oldest.post.clone(), oldest.body_markdown.clone()),
|
|
(newest.post.clone(), newest.body_markdown.clone()),
|
|
(april.post.clone(), april.body_markdown.clone()),
|
|
];
|
|
|
|
let tag_first = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&metadata,
|
|
&posts,
|
|
"/tag/rust",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(tag_first.status_code, 200);
|
|
assert!(tag_first.html.contains("Beta body"));
|
|
assert!(!tag_first.html.contains("Alpha body"));
|
|
|
|
let tag_second = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&metadata,
|
|
&posts,
|
|
"/tag/rust/page/2",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(tag_second.status_code, 200);
|
|
assert!(tag_second.html.contains("Alpha body"));
|
|
assert!(!tag_second.html.contains("Beta body"));
|
|
|
|
let year_archive = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&metadata,
|
|
&posts,
|
|
"/2024",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(year_archive.status_code, 200);
|
|
assert!(year_archive.html.contains("Gamma body"));
|
|
assert!(!year_archive.html.contains("Beta body"));
|
|
|
|
let year_archive_page_two = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&metadata,
|
|
&posts,
|
|
"/2024/page/2",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(year_archive_page_two.status_code, 200);
|
|
assert!(year_archive_page_two.html.contains("Beta body"));
|
|
|
|
let month_archive = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&metadata,
|
|
&posts,
|
|
"/2024/03",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(month_archive.status_code, 200);
|
|
assert!(month_archive.html.contains("Beta body"));
|
|
assert!(!month_archive.html.contains("Alpha body"));
|
|
assert!(!month_archive.html.contains("Gamma body"));
|
|
|
|
let month_archive_page_two = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&metadata,
|
|
&posts,
|
|
"/2024/03/page/2",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(month_archive_page_two.status_code, 200);
|
|
assert!(month_archive_page_two.html.contains("Alpha body"));
|
|
assert!(!month_archive_page_two.html.contains("Beta body"));
|
|
}
|
|
|
|
#[test]
|
|
fn preview_returns_not_found_template_for_unknown_routes() {
|
|
let db = Database::open_in_memory().unwrap();
|
|
let response = build_preview_response(
|
|
db.conn(),
|
|
Path::new("."),
|
|
"project-1",
|
|
&make_metadata(),
|
|
&[(make_post().post, make_post().body_markdown)],
|
|
"/missing/route",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status_code, 404);
|
|
assert!(
|
|
response
|
|
.html
|
|
.contains("No rendered page exists for /missing/route")
|
|
);
|
|
}
|
|
}
|