feat: first take at M4
This commit is contained in:
128
crates/bds-core/tests/m4_generation_engine.rs
Normal file
128
crates/bds-core/tests/m4_generation_engine.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use bds_core::db::queries::project::insert_project;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::generation::{PublishedPostSource, generate_starter_site};
|
||||
use bds_core::model::{Post, PostStatus, Project, ProjectMetadata};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project() -> Project {
|
||||
Project {
|
||||
id: "p1".into(),
|
||||
name: "Blog".into(),
|
||||
slug: "blog".into(),
|
||||
description: None,
|
||||
data_path: None,
|
||||
is_active: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_metadata() -> ProjectMetadata {
|
||||
ProjectMetadata {
|
||||
name: "Blog".into(),
|
||||
description: Some("desc".into()),
|
||||
public_url: Some("https://example.com".into()),
|
||||
main_language: Some("en".into()),
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: vec!["en".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_post(slug: &str, published_at: i64) -> Post {
|
||||
Post {
|
||||
id: format!("post-{slug}"),
|
||||
project_id: "p1".into(),
|
||||
title: slug.into(),
|
||||
slug: slug.into(),
|
||||
excerpt: None,
|
||||
content: Some("Body".into()),
|
||||
status: PostStatus::Published,
|
||||
author: Some("alice".into()),
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: String::new(),
|
||||
checksum: None,
|
||||
tags: vec!["rust".into()],
|
||||
categories: vec!["article".into()],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: published_at,
|
||||
updated_at: published_at,
|
||||
published_at: Some(published_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_project()).unwrap();
|
||||
(db, TempDir::new().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
let (db, dir) = setup();
|
||||
let metadata = make_metadata();
|
||||
let posts = vec![
|
||||
PublishedPostSource {
|
||||
post: make_post("hello", 1_710_000_000_000),
|
||||
body_markdown: "Hello **world**".into(),
|
||||
},
|
||||
PublishedPostSource {
|
||||
post: make_post("next", 1_710_086_400_000),
|
||||
body_markdown: "Next post".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
|
||||
assert!(report.written_paths.contains(&"index.html".to_string()));
|
||||
assert!(report.written_paths.contains(&"calendar.json".to_string()));
|
||||
assert!(report.written_paths.contains(&"rss.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string()));
|
||||
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string()));
|
||||
|
||||
assert!(dir.path().join("index.html").exists());
|
||||
assert!(dir.path().join("rss.xml").exists());
|
||||
assert!(dir.path().join("feed.xml").exists());
|
||||
assert!(dir.path().join("atom.xml").exists());
|
||||
assert!(dir.path().join("sitemap.xml").exists());
|
||||
assert!(dir.path().join("2024/03/09/hello/index.html").exists());
|
||||
|
||||
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
|
||||
assert!(rss.contains("<rss version=\"2.0\""));
|
||||
assert!(rss.contains("https://example.com/2024/03/09/hello"));
|
||||
|
||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("https://example.com/2024/03/09/hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
||||
let (db, dir) = setup();
|
||||
let metadata = make_metadata();
|
||||
let posts = vec![PublishedPostSource {
|
||||
post: make_post("hello", 1_710_000_000_000),
|
||||
body_markdown: "Hello **world**".into(),
|
||||
}];
|
||||
|
||||
let first = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
let second = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
|
||||
assert!(!first.written_paths.is_empty());
|
||||
assert!(second.skipped_paths.contains(&"index.html".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
|
||||
}
|
||||
108
crates/bds-core/tests/m4_generation_primitives.rs
Normal file
108
crates/bds-core/tests/m4_generation_primitives.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::fs;
|
||||
|
||||
use bds_core::db::queries::generated_file_hash::get_generated_file_hash;
|
||||
use bds_core::db::queries::project::insert_project;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::model::{Post, PostStatus, Project};
|
||||
use bds_core::render::{
|
||||
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
||||
write_generated_file,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_project() -> Project {
|
||||
Project {
|
||||
id: "p1".into(),
|
||||
name: "Blog".into(),
|
||||
slug: "blog".into(),
|
||||
description: None,
|
||||
data_path: None,
|
||||
is_active: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_post(slug: &str, published_at: i64) -> Post {
|
||||
Post {
|
||||
id: format!("post-{slug}"),
|
||||
project_id: "p1".into(),
|
||||
title: slug.into(),
|
||||
slug: slug.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: published_at,
|
||||
updated_at: published_at,
|
||||
published_at: Some(published_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (Database, TempDir) {
|
||||
let mut db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
insert_project(db.conn(), &make_project()).unwrap();
|
||||
(db, TempDir::new().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_write_skips_unchanged_content() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
|
||||
let third = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "changed").unwrap();
|
||||
|
||||
assert_eq!(first, GeneratedWriteOutcome::Written);
|
||||
assert_eq!(second, GeneratedWriteOutcome::SkippedUnchanged);
|
||||
assert_eq!(third, GeneratedWriteOutcome::Written);
|
||||
|
||||
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
|
||||
assert_eq!(stored.relative_path, "index.html");
|
||||
assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_generation_paths_include_language_prefixed_variants() {
|
||||
let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]);
|
||||
assert!(paths.contains(&"index.html".to_string()));
|
||||
assert!(paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(paths.contains(&"feed.xml".to_string()));
|
||||
assert!(paths.contains(&"atom.xml".to_string()));
|
||||
assert!(paths.contains(&"calendar.json".to_string()));
|
||||
assert!(paths.contains(&"de/index.html".to_string()));
|
||||
assert!(paths.contains(&"de/feed.xml".to_string()));
|
||||
assert!(paths.contains(&"de/atom.xml".to_string()));
|
||||
assert!(paths.contains(&"fr/index.html".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_json_groups_posts_by_year_month_day() {
|
||||
let posts = vec![
|
||||
make_post("a", 1_710_000_000_000),
|
||||
make_post("b", 1_710_000_000_000),
|
||||
make_post("c", 1_712_678_400_000),
|
||||
];
|
||||
|
||||
let json = build_calendar_json(&posts).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed["years"]["2024"], 3);
|
||||
assert_eq!(parsed["months"]["2024-03"], 2);
|
||||
assert_eq!(parsed["months"]["2024-04"], 1);
|
||||
assert_eq!(parsed["days"]["2024-03-09"], 2);
|
||||
assert_eq!(parsed["days"]["2024-04-09"], 1);
|
||||
}
|
||||
117
crates/bds-core/tests/m4_page_renderer.rs
Normal file
117
crates/bds-core/tests/m4_page_renderer.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use bds_core::render::render_liquid_template;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SinglePostContext {
|
||||
language: String,
|
||||
language_prefix: String,
|
||||
page_title: String,
|
||||
pico_stylesheet_href: Option<String>,
|
||||
html_theme_attribute: Option<String>,
|
||||
alternate_links: Vec<serde_json::Value>,
|
||||
blog_languages: Vec<serde_json::Value>,
|
||||
menu_items: Vec<serde_json::Value>,
|
||||
calendar_initial_year: i32,
|
||||
calendar_initial_month: i32,
|
||||
post: serde_json::Value,
|
||||
post_categories: Vec<String>,
|
||||
post_tags: Vec<String>,
|
||||
tag_color_by_name: HashMap<String, String>,
|
||||
backlinks: Vec<serde_json::Value>,
|
||||
canonical_post_path_by_slug: HashMap<String, String>,
|
||||
canonical_media_path_by_source_path: HashMap<String, String>,
|
||||
post_data_json_by_id: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn i18n_filter_renders_partial_content_language() {
|
||||
let template = "{% render 'partials/label', label: 'render.archive', language: language %}";
|
||||
let partials = HashMap::from([(
|
||||
"partials/label".to_string(),
|
||||
"{{ label | i18n: language }}".to_string(),
|
||||
)]);
|
||||
let context = serde_json::json!({ "language": "de" });
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert_eq!(rendered, "Archiv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_filter_rewrites_post_and_media_urls() {
|
||||
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
|
||||
let partials = HashMap::new();
|
||||
let context = serde_json::json!({
|
||||
"body": "[Post](/posts/hello) ",
|
||||
"canonical_post_path_by_slug": {"hello": "/2024/03/09/hello"},
|
||||
"canonical_media_path_by_source_path": {"media/2024/03/pic.png": "/assets/pic.png"},
|
||||
"language": "en",
|
||||
"language_prefix": ""
|
||||
});
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert!(rendered.contains("href=\"/2024/03/09/hello\""));
|
||||
assert!(rendered.contains("src=\"/assets/pic.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_template_renders_with_partials() {
|
||||
let template = include_str!("../../../assets/starter-templates/single-post.liquid");
|
||||
let partials = HashMap::from([
|
||||
(
|
||||
"partials/head".to_string(),
|
||||
include_str!("../../../assets/starter-templates/partials/head.liquid").to_string(),
|
||||
),
|
||||
(
|
||||
"partials/menu".to_string(),
|
||||
include_str!("../../../assets/starter-templates/partials/menu.liquid").to_string(),
|
||||
),
|
||||
(
|
||||
"partials/language-switcher".to_string(),
|
||||
include_str!("../../../assets/starter-templates/partials/language-switcher.liquid").to_string(),
|
||||
),
|
||||
(
|
||||
"partials/menu-items".to_string(),
|
||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
let context = SinglePostContext {
|
||||
language: "en".into(),
|
||||
language_prefix: String::new(),
|
||||
page_title: "Hello".into(),
|
||||
pico_stylesheet_href: None,
|
||||
html_theme_attribute: None,
|
||||
alternate_links: vec![],
|
||||
blog_languages: vec![serde_json::json!({
|
||||
"is_current": true,
|
||||
"code": "en",
|
||||
"flag": "GB",
|
||||
"href": "/",
|
||||
"href_prefix": ""
|
||||
})],
|
||||
menu_items: vec![],
|
||||
calendar_initial_year: 2024,
|
||||
calendar_initial_month: 3,
|
||||
post: serde_json::json!({
|
||||
"id": "post-1",
|
||||
"title": "Hello",
|
||||
"content": "A **world** post with [link](/posts/hello).",
|
||||
}),
|
||||
post_categories: vec![],
|
||||
post_tags: vec![],
|
||||
tag_color_by_name: HashMap::new(),
|
||||
backlinks: vec![],
|
||||
canonical_post_path_by_slug: HashMap::from([("hello".into(), "/2024/03/09/hello".into())]),
|
||||
canonical_media_path_by_source_path: HashMap::new(),
|
||||
post_data_json_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||
assert!(rendered.contains("<h1>Hello</h1>"));
|
||||
assert!(rendered.contains("<strong>world</strong>"));
|
||||
assert!(rendered.contains("href=\"/2024/03/09/hello\""));
|
||||
assert!(rendered.contains("data-pagefind-body"));
|
||||
}
|
||||
174
crates/bds-core/tests/m4_render.rs
Normal file
174
crates/bds-core/tests/m4_render.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus};
|
||||
use bds_core::render::{
|
||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
|
||||
render_markdown_to_html, resolve_post_template,
|
||||
};
|
||||
|
||||
fn make_post() -> Post {
|
||||
Post {
|
||||
id: "post-1".into(),
|
||||
project_id: "project-1".into(),
|
||||
title: "Post".into(),
|
||||
slug: "post".into(),
|
||||
excerpt: None,
|
||||
content: Some("# Hello".into()),
|
||||
status: PostStatus::Published,
|
||||
author: None,
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: "posts/2026/04/10/post.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,
|
||||
updated_at: 1,
|
||||
published_at: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_template(slug: &str) -> Template {
|
||||
Template {
|
||||
id: format!("template-{slug}"),
|
||||
project_id: "project-1".into(),
|
||||
slug: slug.into(),
|
||||
title: slug.into(),
|
||||
kind: TemplateKind::Post,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: format!("templates/{slug}.liquid"),
|
||||
status: TemplateStatus::Published,
|
||||
content: Some(format!("template:{slug}")),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tag(name: &str, post_template_slug: Option<&str>) -> Tag {
|
||||
Tag {
|
||||
id: format!("tag-{name}"),
|
||||
project_id: "project-1".into(),
|
||||
name: name.into(),
|
||||
color: None,
|
||||
post_template_slug: post_template_slug.map(ToOwned::to_owned),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_lookup_prefers_post_specific_template() {
|
||||
let mut post = make_post();
|
||||
post.template_slug = Some("custom".into());
|
||||
post.tags = vec!["rust".into()];
|
||||
post.categories = vec!["article".into()];
|
||||
|
||||
let templates = vec![make_template("post"), make_template("tag-template"), make_template("custom")];
|
||||
let tags = vec![make_tag("rust", Some("tag-template"))];
|
||||
let mut categories = HashMap::new();
|
||||
categories.insert(
|
||||
"article".into(),
|
||||
RenderCategorySettings {
|
||||
post_template_slug: Some("category-template".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &categories,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.slug, "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_lookup_falls_back_to_tag_then_category_then_default() {
|
||||
let mut post = make_post();
|
||||
post.tags = vec!["rust".into()];
|
||||
post.categories = vec!["article".into()];
|
||||
|
||||
let templates = vec![
|
||||
make_template("post"),
|
||||
make_template("tag-template"),
|
||||
make_template("category-template"),
|
||||
];
|
||||
let tags = vec![make_tag("rust", Some("tag-template"))];
|
||||
let mut categories = HashMap::new();
|
||||
categories.insert(
|
||||
"article".into(),
|
||||
RenderCategorySettings {
|
||||
post_template_slug: Some("category-template".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &categories,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(resolved.slug, "tag-template");
|
||||
|
||||
let category_post = Post {
|
||||
tags: vec![],
|
||||
..post.clone()
|
||||
};
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &category_post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &categories,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(resolved.slug, "category-template");
|
||||
|
||||
let default_post = Post {
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
..post
|
||||
};
|
||||
let empty_categories = HashMap::new();
|
||||
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||
post: &default_post,
|
||||
templates: &templates,
|
||||
tags: &tags,
|
||||
category_settings: &empty_categories,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(resolved.slug, "post");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_lookup_errors_when_explicit_template_missing() {
|
||||
let mut post = make_post();
|
||||
post.template_slug = Some("missing".into());
|
||||
let templates = vec![make_template("post")];
|
||||
|
||||
let err = resolve_post_template(RenderTemplateLookup {
|
||||
post: &post,
|
||||
templates: &templates,
|
||||
tags: &[],
|
||||
category_settings: &HashMap::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, TemplateLookupError::MissingExplicitTemplate("missing".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_render_produces_html() {
|
||||
let html = render_markdown_to_html("# Hello\n\nA paragraph with **bold** text.");
|
||||
assert!(html.contains("<h1>Hello</h1>"));
|
||||
assert!(html.contains("<strong>bold</strong>"));
|
||||
}
|
||||
Reference in New Issue
Block a user