feat: more completion for M4
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -837,6 +837,7 @@ name = "bds-ui"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"bds-core",
|
"bds-core",
|
||||||
"bds-editor",
|
"bds-editor",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ license = "MIT"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Foundation
|
# Foundation
|
||||||
|
base64 = "0.22"
|
||||||
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
rusqlite = { version = "0.33", features = ["bundled", "vtab"] }
|
||||||
refinery = { version = "0.8", features = ["rusqlite"] }
|
refinery = { version = "0.8", features = ["rusqlite"] }
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ pub fn run_one_shot(
|
|||||||
let settings = load_ai_settings(conn, offline_mode)?;
|
let settings = load_ai_settings(conn, offline_mode)?;
|
||||||
let endpoint = active_endpoint(conn, offline_mode)?;
|
let endpoint = active_endpoint(conn, offline_mode)?;
|
||||||
let model = select_model(&settings, &endpoint, &request.operation)?;
|
let model = select_model(&settings, &endpoint, &request.operation)?;
|
||||||
let prompt = build_one_shot_prompt(request)?;
|
let user_content = build_one_shot_user_content(request)?;
|
||||||
let schema = response_schema(&request.operation);
|
let schema = response_schema(&request.operation);
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -313,7 +313,7 @@ pub fn run_one_shot(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": prompt,
|
"content": user_content,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"response_format": {
|
"response_format": {
|
||||||
@@ -412,37 +412,63 @@ fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_one_shot_prompt(request: &OneShotRequest) -> EngineResult<String> {
|
fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value> {
|
||||||
match &request.operation {
|
match &request.operation {
|
||||||
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
||||||
"Suggest tags and categories for this post: {}",
|
"Suggest tags and categories for this post: {}",
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
)),
|
).into()),
|
||||||
OneShotOperation::AnalyzePost => Ok(format!(
|
OneShotOperation::AnalyzePost => Ok(format!(
|
||||||
"Analyze this post and suggest title, excerpt, and slug: {}",
|
"Analyze this post and suggest title, excerpt, and slug: {}",
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
)),
|
).into()),
|
||||||
OneShotOperation::DetectLanguage => Ok(format!(
|
OneShotOperation::DetectLanguage => Ok(format!(
|
||||||
"Detect the language of this text: {}",
|
"Detect the language of this text: {}",
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
)),
|
).into()),
|
||||||
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
||||||
"Translate this post to {}: {}",
|
"Translate this post to {}: {}",
|
||||||
target_language,
|
target_language,
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
)),
|
).into()),
|
||||||
OneShotOperation::AnalyzeImage => Ok(format!(
|
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
|
||||||
"Analyze this image metadata and return title, alt, and caption suggestions: {}",
|
|
||||||
serde_json::to_string(&request.content)?
|
|
||||||
)),
|
|
||||||
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
||||||
"Translate this media metadata to {}: {}",
|
"Translate this media metadata to {}: {}",
|
||||||
target_language,
|
target_language,
|
||||||
serde_json::to_string(&request.content)?
|
serde_json::to_string(&request.content)?
|
||||||
)),
|
).into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_image_analysis_user_content(content: &Value) -> EngineResult<Value> {
|
||||||
|
let image_data_url = content
|
||||||
|
.get("image_data_url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| EngineError::Validation("image analysis requires image data".to_string()))?;
|
||||||
|
|
||||||
|
let mut metadata = content.clone();
|
||||||
|
if let Some(object) = metadata.as_object_mut() {
|
||||||
|
object.remove("image_data_url");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!([
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!(
|
||||||
|
"Analyze this image and return title, alt, and caption suggestions. Metadata: {}",
|
||||||
|
serde_json::to_string(&metadata)?
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": image_data_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
|
fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
|
||||||
match operation {
|
match operation {
|
||||||
OneShotOperation::AnalyzeTaxonomy => (
|
OneShotOperation::AnalyzeTaxonomy => (
|
||||||
@@ -731,6 +757,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_analysis_user_content_is_multimodal() {
|
||||||
|
let content = build_image_analysis_user_content(&json!({
|
||||||
|
"title": "Existing title",
|
||||||
|
"alt": "Existing alt",
|
||||||
|
"image_data_url": "data:image/jpeg;base64,abc123"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let parts = content.as_array().unwrap();
|
||||||
|
assert_eq!(parts.len(), 2);
|
||||||
|
assert_eq!(parts[0]["type"], "text");
|
||||||
|
assert!(parts[0]["text"].as_str().unwrap().contains("Existing title"));
|
||||||
|
assert_eq!(parts[1]["type"], "image_url");
|
||||||
|
assert_eq!(parts[1]["image_url"]["url"], "data:image/jpeg;base64,abc123");
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static) -> String {
|
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static) -> String {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::db::queries;
|
|||||||
use crate::engine::site_assets::write_bundled_site_assets;
|
use crate::engine::site_assets::write_bundled_site_assets;
|
||||||
use crate::engine::validate_site::SiteValidationReport;
|
use crate::engine::validate_site::SiteValidationReport;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{Post, ProjectMetadata};
|
use crate::model::{CategorySettings, Post, ProjectMetadata};
|
||||||
use crate::render::{
|
use crate::render::{
|
||||||
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
|
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
|
||||||
build_site_render_artifacts, render_markdown_to_html, write_generated_bytes,
|
build_site_render_artifacts, render_markdown_to_html, write_generated_bytes,
|
||||||
@@ -49,11 +49,14 @@ pub fn generate_starter_site(
|
|||||||
_language: &str,
|
_language: &str,
|
||||||
) -> EngineResult<GenerationReport> {
|
) -> EngineResult<GenerationReport> {
|
||||||
let mut report = GenerationReport::default();
|
let mut report = GenerationReport::default();
|
||||||
|
let data_dir = project_data_dir(output_dir);
|
||||||
|
let category_settings = load_category_settings(&data_dir);
|
||||||
|
let list_posts = filter_posts_for_lists(posts, &category_settings);
|
||||||
let input_posts = posts
|
let input_posts = posts
|
||||||
.iter()
|
.iter()
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let artifacts = build_site_render_artifacts(conn, output_dir.parent().unwrap_or(output_dir), project_id, metadata, &input_posts)
|
let artifacts = build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
|
||||||
for page in &artifacts.pages {
|
for page in &artifacts.pages {
|
||||||
@@ -67,12 +70,12 @@ pub fn generate_starter_site(
|
|||||||
output_dir,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
"calendar.json",
|
"calendar.json",
|
||||||
&build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
for render_language in render_languages(metadata) {
|
for render_language in render_languages(metadata) {
|
||||||
let localized_posts = localized_sources(conn, output_dir.parent().unwrap_or(output_dir), posts, &render_language, metadata)?;
|
let localized_posts = localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
|
||||||
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
@@ -142,6 +145,8 @@ pub fn apply_validation_sections(
|
|||||||
|
|
||||||
let section_set = sections.iter().copied().collect::<HashSet<_>>();
|
let section_set = sections.iter().copied().collect::<HashSet<_>>();
|
||||||
let data_dir = project_data_dir(output_dir);
|
let data_dir = project_data_dir(output_dir);
|
||||||
|
let category_settings = load_category_settings(&data_dir);
|
||||||
|
let list_posts = filter_posts_for_lists(posts, &category_settings);
|
||||||
let input_posts = posts
|
let input_posts = posts
|
||||||
.iter()
|
.iter()
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||||
@@ -170,7 +175,7 @@ pub fn apply_validation_sections(
|
|||||||
output_dir,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
"calendar.json",
|
"calendar.json",
|
||||||
&build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||||
&mut report,
|
&mut report,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -178,7 +183,7 @@ pub fn apply_validation_sections(
|
|||||||
let localized_posts = localized_sources(
|
let localized_posts = localized_sources(
|
||||||
conn,
|
conn,
|
||||||
&data_dir,
|
&data_dir,
|
||||||
posts,
|
&list_posts,
|
||||||
&render_language,
|
&render_language,
|
||||||
metadata,
|
metadata,
|
||||||
)?;
|
)?;
|
||||||
@@ -511,6 +516,27 @@ fn localized_sources(
|
|||||||
Ok(localized)
|
Ok(localized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_category_settings(data_dir: &Path) -> HashMap<String, CategorySettings> {
|
||||||
|
crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_posts_for_lists(
|
||||||
|
posts: &[PublishedPostSource],
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
|
) -> Vec<PublishedPostSource> {
|
||||||
|
posts.iter()
|
||||||
|
.filter(|source| {
|
||||||
|
!source.post.categories.iter().any(|category| {
|
||||||
|
category_settings
|
||||||
|
.get(category)
|
||||||
|
.map(|settings| !settings.render_in_lists)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||||
let last_build = posts
|
let last_build = posts
|
||||||
|
|||||||
@@ -607,6 +607,38 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
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::write(dir.path().join("media/ok.txt"), "ok").unwrap();
|
||||||
|
std::fs::write(dir.path().join("assets/site.css"), "body { color: red; }").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();
|
||||||
|
server.stop().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(media_body, "ok");
|
||||||
|
assert!(asset_body.contains("color: red"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preview_server_serves_style_preview() {
|
fn preview_server_serves_style_preview() {
|
||||||
let _guard = preview_port_guard().lock().unwrap();
|
let _guard = preview_port_guard().lock().unwrap();
|
||||||
@@ -659,4 +691,73 @@ mod tests {
|
|||||||
assert!(body.contains("data-theme=\"nightfall\""));
|
assert!(body.contains("data-theme=\"nightfall\""));
|
||||||
assert!(body.contains("data-mode=\"dark\""));
|
assert!(body.contains("data-mode=\"dark\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,10 @@ const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
|||||||
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") },
|
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") },
|
||||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") },
|
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") },
|
||||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") },
|
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") },
|
||||||
|
BundledSiteAsset { relative_path: "images/close.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png") },
|
||||||
|
BundledSiteAsset { relative_path: "images/loading.gif", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif") },
|
||||||
|
BundledSiteAsset { relative_path: "images/next.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png") },
|
||||||
|
BundledSiteAsset { relative_path: "images/prev.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png") },
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||||
|
|||||||
@@ -52,10 +52,17 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
|
|||||||
"vimeo" => Some(render_vimeo(&args)),
|
"vimeo" => Some(render_vimeo(&args)),
|
||||||
"photo_archive" => Some(render_photo_archive(&args, context)),
|
"photo_archive" => Some(render_photo_archive(&args, context)),
|
||||||
"tag_cloud" => Some(render_tag_cloud(&args, context)),
|
"tag_cloud" => Some(render_tag_cloud(&args, context)),
|
||||||
_ => None,
|
_ => Some(unsupported_macro(name)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unsupported_macro(name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"<section class=\"macro-unsupported\"><p>Unsupported macro: <code>{}</code></p></section>",
|
||||||
|
escape_html_attr(name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
||||||
let mut tokens = Vec::new();
|
let mut tokens = Vec::new();
|
||||||
let mut current = String::new();
|
let mut current = String::new();
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ pub struct PreviewRenderResult {
|
|||||||
struct TemplateBundle {
|
struct TemplateBundle {
|
||||||
post_templates: Vec<Template>,
|
post_templates: Vec<Template>,
|
||||||
template_source_by_slug: HashMap<String, String>,
|
template_source_by_slug: HashMap<String, String>,
|
||||||
list_template: String,
|
list_template_sources: HashMap<String, String>,
|
||||||
|
default_list_template: String,
|
||||||
not_found_template: String,
|
not_found_template: String,
|
||||||
partials: HashMap<String, String>,
|
partials: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,7 @@ struct RouteSpec {
|
|||||||
url_path: String,
|
url_path: String,
|
||||||
page_title: String,
|
page_title: String,
|
||||||
archive_context: Option<Value>,
|
archive_context: Option<Value>,
|
||||||
|
list_template_slug: Option<String>,
|
||||||
posts: Vec<RenderPostRecord>,
|
posts: Vec<RenderPostRecord>,
|
||||||
current_page: usize,
|
current_page: usize,
|
||||||
total_pages: usize,
|
total_pages: usize,
|
||||||
@@ -99,7 +101,8 @@ pub fn build_site_render_artifacts(
|
|||||||
let mut artifacts = SiteRenderArtifacts::default();
|
let mut artifacts = SiteRenderArtifacts::default();
|
||||||
for language in languages {
|
for language in languages {
|
||||||
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
|
let localized_posts = load_language_posts(conn, data_dir, published_posts, &language, &main_language)?;
|
||||||
let routes = build_language_routes(&localized_posts, metadata, &language, &tags);
|
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
|
||||||
|
let routes = build_language_routes(&localized_list_posts, metadata, &language, &tags, &category_settings);
|
||||||
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
|
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts);
|
||||||
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
|
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
|
||||||
let rendered_list_pages = routes
|
let rendered_list_pages = routes
|
||||||
@@ -109,8 +112,9 @@ pub fn build_site_render_artifacts(
|
|||||||
route,
|
route,
|
||||||
metadata,
|
metadata,
|
||||||
&language,
|
&language,
|
||||||
&localized_posts,
|
&localized_list_posts,
|
||||||
&tags,
|
&tags,
|
||||||
|
&category_settings,
|
||||||
&menu_items,
|
&menu_items,
|
||||||
&post_data_json_by_id,
|
&post_data_json_by_id,
|
||||||
&bundle,
|
&bundle,
|
||||||
@@ -206,8 +210,9 @@ fn load_template_bundle(
|
|||||||
let templates = queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
|
let templates = queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
|
||||||
let mut template_source_by_slug = HashMap::new();
|
let mut template_source_by_slug = HashMap::new();
|
||||||
let mut post_templates = Vec::new();
|
let mut post_templates = Vec::new();
|
||||||
|
let mut list_template_sources = HashMap::new();
|
||||||
let mut partials = starter_partials();
|
let mut partials = starter_partials();
|
||||||
let mut list_template = STARTER_POST_LIST_TEMPLATE.to_string();
|
let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string();
|
||||||
let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string();
|
let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string();
|
||||||
|
|
||||||
for template in templates {
|
for template in templates {
|
||||||
@@ -223,8 +228,12 @@ fn load_template_bundle(
|
|||||||
post_templates.push(hydrated);
|
post_templates.push(hydrated);
|
||||||
}
|
}
|
||||||
TemplateKind::List => {
|
TemplateKind::List => {
|
||||||
if template.slug == "list" || list_template == STARTER_POST_LIST_TEMPLATE {
|
list_template_sources.insert(template.slug.clone(), source.clone());
|
||||||
list_template = source;
|
if template.slug == "list"
|
||||||
|
|| template.slug == "post-list"
|
||||||
|
|| default_list_template == STARTER_POST_LIST_TEMPLATE
|
||||||
|
{
|
||||||
|
default_list_template = source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TemplateKind::NotFound => {
|
TemplateKind::NotFound => {
|
||||||
@@ -242,6 +251,13 @@ fn load_template_bundle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
list_template_sources
|
||||||
|
.entry("post-list".to_string())
|
||||||
|
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
|
||||||
|
list_template_sources
|
||||||
|
.entry("list".to_string())
|
||||||
|
.or_insert_with(|| STARTER_POST_LIST_TEMPLATE.to_string());
|
||||||
|
|
||||||
if !post_templates.iter().any(|template| template.slug == "post") {
|
if !post_templates.iter().any(|template| template.slug == "post") {
|
||||||
post_templates.push(Template {
|
post_templates.push(Template {
|
||||||
id: "starter-post-template".to_string(),
|
id: "starter-post-template".to_string(),
|
||||||
@@ -263,7 +279,8 @@ fn load_template_bundle(
|
|||||||
Ok(TemplateBundle {
|
Ok(TemplateBundle {
|
||||||
post_templates,
|
post_templates,
|
||||||
template_source_by_slug,
|
template_source_by_slug,
|
||||||
list_template,
|
list_template_sources,
|
||||||
|
default_list_template,
|
||||||
not_found_template,
|
not_found_template,
|
||||||
partials,
|
partials,
|
||||||
})
|
})
|
||||||
@@ -330,10 +347,18 @@ fn build_language_routes(
|
|||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
language: &str,
|
language: &str,
|
||||||
tags: &[Tag],
|
tags: &[Tag],
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
) -> Vec<RouteSpec> {
|
) -> Vec<RouteSpec> {
|
||||||
let per_page = metadata.max_posts_per_page.max(1) as usize;
|
let per_page = metadata.max_posts_per_page.max(1) as usize;
|
||||||
let mut routes = Vec::new();
|
let mut routes = Vec::new();
|
||||||
routes.extend(paginated_route_specs(posts, per_page, language_root_prefix(language, metadata), metadata.name.clone(), None));
|
routes.extend(paginated_route_specs(
|
||||||
|
posts,
|
||||||
|
per_page,
|
||||||
|
language_root_prefix(language, metadata),
|
||||||
|
metadata.name.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
let mut category_posts: BTreeMap<String, Vec<RenderPostRecord>> = BTreeMap::new();
|
let mut category_posts: BTreeMap<String, Vec<RenderPostRecord>> = BTreeMap::new();
|
||||||
let mut tag_posts: BTreeMap<String, Vec<RenderPostRecord>> = BTreeMap::new();
|
let mut tag_posts: BTreeMap<String, Vec<RenderPostRecord>> = BTreeMap::new();
|
||||||
@@ -361,6 +386,9 @@ fn build_language_routes(
|
|||||||
format!("{}/category/{slug}", language_root_prefix(language, metadata)),
|
format!("{}/category/{slug}", language_root_prefix(language, metadata)),
|
||||||
category.clone(),
|
category.clone(),
|
||||||
Some(json!({"kind": "category", "name": category})),
|
Some(json!({"kind": "category", "name": category})),
|
||||||
|
category_settings
|
||||||
|
.get(&category)
|
||||||
|
.and_then(|settings| settings.list_template_slug.clone()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +405,7 @@ fn build_language_routes(
|
|||||||
format!("{}/tag/{slug}", language_root_prefix(language, metadata)),
|
format!("{}/tag/{slug}", language_root_prefix(language, metadata)),
|
||||||
display_name.clone(),
|
display_name.clone(),
|
||||||
Some(json!({"kind": "tag", "name": display_name})),
|
Some(json!({"kind": "tag", "name": display_name})),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +416,7 @@ fn build_language_routes(
|
|||||||
format!("{}/{year}", language_root_prefix(language, metadata)),
|
format!("{}/{year}", language_root_prefix(language, metadata)),
|
||||||
format!("{} {year}", metadata.name),
|
format!("{} {year}", metadata.name),
|
||||||
Some(json!({"kind": "year", "year": year})),
|
Some(json!({"kind": "year", "year": year})),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +427,7 @@ fn build_language_routes(
|
|||||||
format!("{}/{year}/{month:02}", language_root_prefix(language, metadata)),
|
format!("{}/{year}/{month:02}", language_root_prefix(language, metadata)),
|
||||||
format!("{} {year}-{month:02}", metadata.name),
|
format!("{} {year}-{month:02}", metadata.name),
|
||||||
Some(json!({"kind": "month", "year": year, "month": month})),
|
Some(json!({"kind": "month", "year": year, "month": month})),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +440,7 @@ fn paginated_route_specs(
|
|||||||
base_path: String,
|
base_path: String,
|
||||||
page_title: String,
|
page_title: String,
|
||||||
archive_context: Option<Value>,
|
archive_context: Option<Value>,
|
||||||
|
list_template_slug: Option<String>,
|
||||||
) -> Vec<RouteSpec> {
|
) -> Vec<RouteSpec> {
|
||||||
let total_items = posts.len();
|
let total_items = posts.len();
|
||||||
let total_pages = total_items.max(1).div_ceil(per_page.max(1));
|
let total_pages = total_items.max(1).div_ceil(per_page.max(1));
|
||||||
@@ -436,6 +468,7 @@ fn paginated_route_specs(
|
|||||||
url_path,
|
url_path,
|
||||||
page_title: page_title.clone(),
|
page_title: page_title.clone(),
|
||||||
archive_context: archive_context.clone(),
|
archive_context: archive_context.clone(),
|
||||||
|
list_template_slug: list_template_slug.clone(),
|
||||||
posts: slice,
|
posts: slice,
|
||||||
current_page,
|
current_page,
|
||||||
total_pages: total_pages.max(1),
|
total_pages: total_pages.max(1),
|
||||||
@@ -452,6 +485,7 @@ fn render_list_route(
|
|||||||
language: &str,
|
language: &str,
|
||||||
posts: &[RenderPostRecord],
|
posts: &[RenderPostRecord],
|
||||||
tags: &[Tag],
|
tags: &[Tag],
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
menu_items: &[Value],
|
menu_items: &[Value],
|
||||||
post_data_json_by_id: &HashMap<String, Value>,
|
post_data_json_by_id: &HashMap<String, Value>,
|
||||||
bundle: &TemplateBundle,
|
bundle: &TemplateBundle,
|
||||||
@@ -459,6 +493,11 @@ fn render_list_route(
|
|||||||
let main_language = main_language(metadata);
|
let main_language = main_language(metadata);
|
||||||
let canonical_map = canonical_post_path_by_slug(posts, language, main_language);
|
let canonical_map = canonical_post_path_by_slug(posts, language, main_language);
|
||||||
let taxonomy_counts = build_taxonomy_counts(posts, tags);
|
let taxonomy_counts = build_taxonomy_counts(posts, tags);
|
||||||
|
let list_template = route
|
||||||
|
.list_template_slug
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|slug| bundle.list_template_sources.get(slug))
|
||||||
|
.unwrap_or(&bundle.default_list_template);
|
||||||
let context = json!({
|
let context = json!({
|
||||||
"language": language,
|
"language": language,
|
||||||
"language_prefix": language_prefix(language, main_language),
|
"language_prefix": language_prefix(language, main_language),
|
||||||
@@ -474,7 +513,7 @@ fn render_list_route(
|
|||||||
"show_archive_range_heading": false,
|
"show_archive_range_heading": false,
|
||||||
"min_date": route.posts.last().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
|
"min_date": route.posts.last().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
|
||||||
"max_date": route.posts.first().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
|
"max_date": route.posts.first().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
|
||||||
"day_blocks": build_day_blocks(&route.posts),
|
"day_blocks": build_day_blocks(&route.posts, category_settings),
|
||||||
"is_list_page": route.current_page > 1,
|
"is_list_page": route.current_page > 1,
|
||||||
"is_first_page": route.current_page == 1,
|
"is_first_page": route.current_page == 1,
|
||||||
"is_last_page": route.current_page >= route.total_pages,
|
"is_last_page": route.current_page >= route.total_pages,
|
||||||
@@ -497,7 +536,7 @@ fn render_list_route(
|
|||||||
"not_found_back_label": serde_json::Value::Null,
|
"not_found_back_label": serde_json::Value::Null,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(render_liquid_template(&bundle.list_template, &bundle.partials, &context)?)
|
Ok(render_liquid_template(list_template, &bundle.partials, &context)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -707,7 +746,10 @@ fn route_href(route: &RouteSpec, page: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_day_blocks(posts: &[RenderPostRecord]) -> Vec<Value> {
|
fn build_day_blocks(
|
||||||
|
posts: &[RenderPostRecord],
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
|
) -> Vec<Value> {
|
||||||
let mut blocks = Vec::new();
|
let mut blocks = Vec::new();
|
||||||
let mut current_key = String::new();
|
let mut current_key = String::new();
|
||||||
let mut current_posts = Vec::new();
|
let mut current_posts = Vec::new();
|
||||||
@@ -730,12 +772,13 @@ fn build_day_blocks(posts: &[RenderPostRecord]) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
current_key = key;
|
current_key = key;
|
||||||
current_label = format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year());
|
current_label = format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year());
|
||||||
|
let show_title = should_show_list_title(&record.post, category_settings);
|
||||||
current_posts.push(json!({
|
current_posts.push(json!({
|
||||||
"id": record.post.id,
|
"id": record.post.id,
|
||||||
"title": record.post.title,
|
"title": record.post.title,
|
||||||
"slug": record.post.slug,
|
"slug": record.post.slug,
|
||||||
"content": record.post.excerpt.clone().unwrap_or_else(|| record.body_markdown.clone()),
|
"content": resolve_list_content(record, category_settings),
|
||||||
"show_title": true,
|
"show_title": show_title,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +793,58 @@ fn build_day_blocks(posts: &[RenderPostRecord]) -> Vec<Value> {
|
|||||||
blocks
|
blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn filter_posts_for_lists(
|
||||||
|
posts: &[RenderPostRecord],
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
|
) -> Vec<RenderPostRecord> {
|
||||||
|
posts.iter()
|
||||||
|
.filter(|record| !is_post_excluded_from_lists(&record.post, category_settings))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_post_excluded_from_lists(
|
||||||
|
post: &Post,
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
|
) -> bool {
|
||||||
|
post.categories.iter().any(|category| {
|
||||||
|
category_settings
|
||||||
|
.get(category)
|
||||||
|
.map(|settings| !settings.render_in_lists)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_show_list_title(
|
||||||
|
post: &Post,
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
|
) -> bool {
|
||||||
|
if post.categories.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
!post.categories.iter().any(|category| {
|
||||||
|
category_settings
|
||||||
|
.get(category)
|
||||||
|
.map(|settings| !settings.show_title)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_list_content(
|
||||||
|
record: &RenderPostRecord,
|
||||||
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
|
) -> String {
|
||||||
|
let show_title = should_show_list_title(&record.post, category_settings);
|
||||||
|
let excerpt = record.post.excerpt.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
|
||||||
|
if show_title && !excerpt.is_empty() {
|
||||||
|
record.post.excerpt.clone().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
record.body_markdown.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn canonical_post_path_by_slug(
|
fn canonical_post_path_by_slug(
|
||||||
posts: &[RenderPostRecord],
|
posts: &[RenderPostRecord],
|
||||||
language: &str,
|
language: &str,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use bds_core::db::queries::project::insert_project;
|
use bds_core::db::queries::project::insert_project;
|
||||||
|
use bds_core::db::queries::template::insert_template;
|
||||||
use bds_core::db::Database;
|
use bds_core::db::Database;
|
||||||
use bds_core::engine::generation::{
|
use bds_core::engine::generation::{
|
||||||
PublishedPostSource, apply_validation_sections, generate_starter_site,
|
PublishedPostSource, apply_validation_sections, generate_starter_site,
|
||||||
sections_from_validation_report,
|
sections_from_validation_report,
|
||||||
};
|
};
|
||||||
|
use bds_core::engine::meta::write_category_meta_json;
|
||||||
use bds_core::engine::validate_site::validate_site;
|
use bds_core::engine::validate_site::validate_site;
|
||||||
use bds_core::model::{Post, PostStatus, Project, ProjectMetadata};
|
use bds_core::model::{CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind, TemplateStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn make_project() -> Project {
|
fn make_project() -> Project {
|
||||||
@@ -64,6 +68,23 @@ fn make_post(slug: &str, published_at: i64) -> Post {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_list_template(slug: &str, content: &str) -> Template {
|
||||||
|
Template {
|
||||||
|
id: format!("template-{slug}"),
|
||||||
|
project_id: "p1".into(),
|
||||||
|
slug: slug.into(),
|
||||||
|
title: slug.into(),
|
||||||
|
kind: TemplateKind::List,
|
||||||
|
enabled: true,
|
||||||
|
version: 1,
|
||||||
|
file_path: String::new(),
|
||||||
|
status: TemplateStatus::Published,
|
||||||
|
content: Some(content.into()),
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
let mut db = Database::open_in_memory().unwrap();
|
let mut db = Database::open_in_memory().unwrap();
|
||||||
db.migrate().unwrap();
|
db.migrate().unwrap();
|
||||||
@@ -147,6 +168,92 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
|
|||||||
assert!(sitemap.contains("https://example.com/category/article"));
|
assert!(sitemap.contains("https://example.com/category/article"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_respects_category_list_settings_and_writes_bundled_images() {
|
||||||
|
let (db, dir) = setup();
|
||||||
|
insert_template(
|
||||||
|
db.conn(),
|
||||||
|
&make_list_template(
|
||||||
|
"featured-list",
|
||||||
|
"FEATURED:{% for day_block in day_blocks %}{% for post in day_block.posts %}[{{ post.title }}|{{ post.show_title }}]{% endfor %}{% endfor %}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_category_meta_json(
|
||||||
|
dir.path(),
|
||||||
|
&HashMap::from([
|
||||||
|
(
|
||||||
|
"hidden".to_string(),
|
||||||
|
CategorySettings {
|
||||||
|
render_in_lists: false,
|
||||||
|
show_title: true,
|
||||||
|
post_template_slug: None,
|
||||||
|
list_template_slug: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"featured".to_string(),
|
||||||
|
CategorySettings {
|
||||||
|
render_in_lists: true,
|
||||||
|
show_title: false,
|
||||||
|
post_template_slug: None,
|
||||||
|
list_template_slug: Some("featured-list".to_string()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut hidden_post = make_post("hidden-post", 1_710_000_000_000);
|
||||||
|
hidden_post.title = "Hidden Post".into();
|
||||||
|
hidden_post.categories = vec!["hidden".into()];
|
||||||
|
|
||||||
|
let mut featured_post = make_post("featured-post", 1_710_086_400_000);
|
||||||
|
featured_post.title = "Featured Post".into();
|
||||||
|
featured_post.categories = vec!["featured".into()];
|
||||||
|
|
||||||
|
let posts = vec![
|
||||||
|
PublishedPostSource {
|
||||||
|
post: hidden_post,
|
||||||
|
body_markdown: "Hidden body".into(),
|
||||||
|
},
|
||||||
|
PublishedPostSource {
|
||||||
|
post: featured_post,
|
||||||
|
body_markdown: "Featured body".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let report = generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap();
|
||||||
|
|
||||||
|
for asset in [
|
||||||
|
"images/close.png",
|
||||||
|
"images/loading.gif",
|
||||||
|
"images/next.png",
|
||||||
|
"images/prev.png",
|
||||||
|
] {
|
||||||
|
assert!(report.written_paths.contains(&asset.to_string()));
|
||||||
|
assert!(dir.path().join(asset).exists(), "missing bundled image {asset}");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!report.written_paths.contains(&"category/hidden/index.html".to_string()));
|
||||||
|
assert!(report.written_paths.contains(&"category/featured/index.html".to_string()));
|
||||||
|
|
||||||
|
let index_html = std::fs::read_to_string(dir.path().join("index.html")).unwrap();
|
||||||
|
assert!(!index_html.contains("Hidden Post"));
|
||||||
|
assert!(index_html.contains("[Featured Post|false]"));
|
||||||
|
|
||||||
|
let featured_html = std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
|
||||||
|
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
|
||||||
|
|
||||||
|
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
|
||||||
|
assert!(!feed.contains("hidden-post"));
|
||||||
|
assert!(feed.contains("featured-post"));
|
||||||
|
|
||||||
|
let calendar = std::fs::read_to_string(dir.path().join("calendar.json")).unwrap();
|
||||||
|
assert!(!calendar.contains("2024-03-09"));
|
||||||
|
assert!(calendar.contains("2024-03-10"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -91,6 +91,24 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() {
|
|||||||
assert!(rendered.contains("data-tag-cloud=\"true\""));
|
assert!(rendered.contains("data-tag-cloud=\"true\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_filter_marks_unsupported_macros_visibly() {
|
||||||
|
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": "[[custom_block foo=bar]]",
|
||||||
|
"canonical_post_path_by_slug": {},
|
||||||
|
"canonical_media_path_by_source_path": {},
|
||||||
|
"language": "en",
|
||||||
|
"language_prefix": ""
|
||||||
|
});
|
||||||
|
|
||||||
|
let rendered = render_liquid_template(template, &partials, &context).unwrap();
|
||||||
|
assert!(rendered.contains("macro-unsupported"));
|
||||||
|
assert!(rendered.contains("Unsupported macro"));
|
||||||
|
assert!(rendered.contains("custom_block"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn starter_single_post_template_renders_with_partials() {
|
fn starter_single_post_template_renders_with_partials() {
|
||||||
let template = include_str!("../../../assets/starter-templates/single-post.liquid");
|
let template = include_str!("../../../assets/starter-templates/single-post.liquid");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ iced = { workspace = true }
|
|||||||
muda = { workspace = true }
|
muda = { workspace = true }
|
||||||
rfd = { workspace = true }
|
rfd = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
open = { workspace = true }
|
open = { workspace = true }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use iced::{Element, Subscription, Task, window};
|
use iced::{Element, Subscription, Task, window};
|
||||||
use rusqlite::Error as SqlError;
|
use rusqlite::Error as SqlError;
|
||||||
@@ -6815,9 +6816,20 @@ impl BdsApp {
|
|||||||
self.notify(ToastLevel::Error, "database unavailable");
|
self.notify(ToastLevel::Error, "database unavailable");
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
|
let Some(data_dir) = &self.data_dir else {
|
||||||
|
self.notify(ToastLevel::Error, "project data directory unavailable");
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
|
let image_data_url = match build_ai_image_data_url(data_dir, &state.media_id, &state.file_path, &state.mime_type) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
self.notify(ToastLevel::Error, &error);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
};
|
||||||
let request = ai::OneShotRequest {
|
let request = ai::OneShotRequest {
|
||||||
operation: ai::OneShotOperation::AnalyzeImage,
|
operation: ai::OneShotOperation::AnalyzeImage,
|
||||||
content: json!({
|
content: json!({
|
||||||
@@ -6826,6 +6838,7 @@ impl BdsApp {
|
|||||||
"caption": state.caption,
|
"caption": state.caption,
|
||||||
"filename": state.original_name,
|
"filename": state.original_name,
|
||||||
"mime_type": state.mime_type,
|
"mime_type": state.mime_type,
|
||||||
|
"image_data_url": image_data_url,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||||
@@ -7043,6 +7056,35 @@ fn content_sample(content: &str, max_len: usize) -> String {
|
|||||||
content.chars().take(max_len).collect()
|
content.chars().take(max_len).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_ai_image_data_url(
|
||||||
|
data_dir: &Path,
|
||||||
|
media_id: &str,
|
||||||
|
file_path: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if !mime_type.starts_with("image/") {
|
||||||
|
return Err("AI image analysis requires an image".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_path = data_dir.join(file_path.trim_start_matches('/'));
|
||||||
|
let thumbnail_relative = bds_core::util::thumbnail_path(media_id, "ai", "jpg");
|
||||||
|
let thumbnail_path = data_dir.join(&thumbnail_relative);
|
||||||
|
|
||||||
|
if !thumbnail_path.exists() {
|
||||||
|
bds_core::util::thumbnail::generate_all_thumbnails(
|
||||||
|
&source_path,
|
||||||
|
&data_dir.join("thumbnails"),
|
||||||
|
media_id,
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("failed to generate AI thumbnail: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&thumbnail_path)
|
||||||
|
.map_err(|error| format!("failed to read AI thumbnail: {error}"))?;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
Ok(format!("data:image/jpeg;base64,{encoded}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn split_csv_values(value: &str) -> Vec<String> {
|
fn split_csv_values(value: &str) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ pub fn view<'a>(
|
|||||||
state: &'a MediaEditorState,
|
state: &'a MediaEditorState,
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
data_dir: Option<&Path>,
|
data_dir: Option<&Path>,
|
||||||
|
ai_enabled: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let header = inputs::toolbar(
|
let header = inputs::toolbar(
|
||||||
vec![
|
vec![
|
||||||
@@ -230,18 +231,18 @@ pub fn view<'a>(
|
|||||||
vec![
|
vec![
|
||||||
if state.mime_type.starts_with("image/") {
|
if state.mime_type.starts_with("image/") {
|
||||||
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi))
|
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new(0, 0).into()
|
||||||
},
|
},
|
||||||
button(text(t(locale, "editor.detectLanguage")).size(13))
|
button(text(t(locale, "editor.detectLanguage")).size(13))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::DetectLanguage))
|
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.into(),
|
.into(),
|
||||||
button(text(t(locale, "editor.translate")).size(13))
|
button(text(t(locale, "editor.translate")).size(13))
|
||||||
.on_press(Message::MediaEditor(MediaEditorMsg::TranslateMetadata))
|
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.into(),
|
.into(),
|
||||||
button(text(t(locale, "common.save")).size(13))
|
button(text(t(locale, "common.save")).size(13))
|
||||||
|
|||||||
@@ -372,6 +372,7 @@ pub fn view<'a>(
|
|||||||
state: &'a PostEditorState,
|
state: &'a PostEditorState,
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
word_wrap: bool,
|
word_wrap: bool,
|
||||||
|
ai_enabled: bool,
|
||||||
preview_widget: Option<Element<'a, Message>>,
|
preview_widget: Option<Element<'a, Message>>,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let on_translation = state.active_language != state.canonical_language;
|
let on_translation = state.active_language != state.canonical_language;
|
||||||
@@ -389,7 +390,7 @@ pub fn view<'a>(
|
|||||||
.size(13)
|
.size(13)
|
||||||
.shaping(Shaping::Advanced),
|
.shaping(Shaping::Advanced),
|
||||||
)
|
)
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::ToggleQuickActions))
|
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::ToggleQuickActions)))
|
||||||
.padding([6, 16])
|
.padding([6, 16])
|
||||||
.style(status_bar::dropdown_trigger)
|
.style(status_bar::dropdown_trigger)
|
||||||
.into();
|
.into();
|
||||||
@@ -479,10 +480,10 @@ pub fn view<'a>(
|
|||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi),
|
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, ai_enabled),
|
||||||
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy),
|
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, ai_enabled),
|
||||||
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate),
|
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate, ai_enabled),
|
||||||
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage),
|
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, ai_enabled),
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
)
|
)
|
||||||
@@ -574,7 +575,7 @@ pub fn view<'a>(
|
|||||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||||
);
|
);
|
||||||
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
||||||
.on_press(Message::PostEditor(PostEditorMsg::DetectLanguage))
|
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
|
||||||
.padding([6, 12]);
|
.padding([6, 12]);
|
||||||
let template_input = inputs::labeled_input(
|
let template_input = inputs::labeled_input(
|
||||||
&t(locale, "editor.templateSlug"),
|
&t(locale, "editor.templateSlug"),
|
||||||
@@ -979,10 +980,10 @@ fn mode_button<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg) -> Element<'a, Message> {
|
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg, enabled: bool) -> Element<'a, Message> {
|
||||||
let _ = locale;
|
let _ = locale;
|
||||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||||
.on_press(Message::PostEditor(msg))
|
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
||||||
.padding([6, 12])
|
.padding([6, 12])
|
||||||
.style(status_bar::dropdown_item)
|
.style(status_bar::dropdown_item)
|
||||||
.width(Length::Fixed(220.0))
|
.width(Length::Fixed(220.0))
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ pub fn view<'a>(
|
|||||||
tabs,
|
tabs,
|
||||||
active_tab,
|
active_tab,
|
||||||
locale,
|
locale,
|
||||||
|
offline_mode,
|
||||||
data_dir,
|
data_dir,
|
||||||
post_preview_widget,
|
post_preview_widget,
|
||||||
post_editors,
|
post_editors,
|
||||||
@@ -347,6 +348,7 @@ fn route_content_area<'a>(
|
|||||||
tabs: &'a [Tab],
|
tabs: &'a [Tab],
|
||||||
active_tab: Option<&'a str>,
|
active_tab: Option<&'a str>,
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
|
offline_mode: bool,
|
||||||
data_dir: Option<&'a Path>,
|
data_dir: Option<&'a Path>,
|
||||||
post_preview_widget: Option<Element<'a, Message>>,
|
post_preview_widget: Option<Element<'a, Message>>,
|
||||||
post_editors: &'a HashMap<String, PostEditorState>,
|
post_editors: &'a HashMap<String, PostEditorState>,
|
||||||
@@ -376,14 +378,14 @@ fn route_content_area<'a>(
|
|||||||
ContentRoute::Post(tab_id) => {
|
ContentRoute::Post(tab_id) => {
|
||||||
if let Some(state) = post_editors.get(tab_id) {
|
if let Some(state) = post_editors.get(tab_id) {
|
||||||
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
||||||
post_editor::view(state, locale, wrap, post_preview_widget)
|
post_editor::view(state, locale, wrap, is_ai_enabled(settings_state, offline_mode), post_preview_widget)
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContentRoute::Media(tab_id) => {
|
ContentRoute::Media(tab_id) => {
|
||||||
if let Some(state) = media_editors.get(tab_id) {
|
if let Some(state) = media_editors.get(tab_id) {
|
||||||
media_editor::view(state, locale, data_dir)
|
media_editor::view(state, locale, data_dir, is_ai_enabled(settings_state, offline_mode))
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
@@ -488,6 +490,20 @@ fn route_kind<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool) -> bool {
|
||||||
|
let Some(state) = settings_state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if offline_mode {
|
||||||
|
!state.airplane_endpoint_url.trim().is_empty() && !state.airplane_endpoint_model.trim().is_empty()
|
||||||
|
} else {
|
||||||
|
!state.online_endpoint_url.trim().is_empty()
|
||||||
|
&& !state.online_endpoint_model.trim().is_empty()
|
||||||
|
&& (state.online_api_key_configured || !state.online_api_key_input.trim().is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -597,6 +613,23 @@ mod tests {
|
|||||||
_ => panic!("expected site validation route"),
|
_ => panic!("expected site validation route"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_actions_require_configured_endpoint_for_current_mode() {
|
||||||
|
let mut settings = SettingsViewState::default();
|
||||||
|
assert!(!is_ai_enabled(Some(&settings), false));
|
||||||
|
assert!(!is_ai_enabled(Some(&settings), true));
|
||||||
|
|
||||||
|
settings.online_endpoint_url = "https://api.example.com/v1".to_string();
|
||||||
|
settings.online_endpoint_model = "gpt-4.1-mini".to_string();
|
||||||
|
settings.online_api_key_configured = true;
|
||||||
|
assert!(is_ai_enabled(Some(&settings), false));
|
||||||
|
assert!(!is_ai_enabled(Some(&settings), true));
|
||||||
|
|
||||||
|
settings.airplane_endpoint_url = "http://localhost:11434/v1".to_string();
|
||||||
|
settings.airplane_endpoint_model = "llama3.2".to_string();
|
||||||
|
assert!(is_ai_enabled(Some(&settings), true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||||
|
|||||||
Reference in New Issue
Block a user