feat: more completion for M4

This commit is contained in:
2026-04-13 21:20:41 +02:00
parent eca5fafc1c
commit a96a6ee31c
15 changed files with 527 additions and 46 deletions

View File

@@ -302,7 +302,7 @@ pub fn run_one_shot(
let settings = load_ai_settings(conn, offline_mode)?;
let endpoint = active_endpoint(conn, offline_mode)?;
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 payload = json!({
"model": model,
@@ -313,7 +313,7 @@ pub fn run_one_shot(
},
{
"role": "user",
"content": prompt,
"content": user_content,
}
],
"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 {
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
"Suggest tags and categories for this post: {}",
serde_json::to_string(&request.content)?
)),
).into()),
OneShotOperation::AnalyzePost => Ok(format!(
"Analyze this post and suggest title, excerpt, and slug: {}",
serde_json::to_string(&request.content)?
)),
).into()),
OneShotOperation::DetectLanguage => Ok(format!(
"Detect the language of this text: {}",
serde_json::to_string(&request.content)?
)),
).into()),
OneShotOperation::TranslatePost { target_language } => Ok(format!(
"Translate this post to {}: {}",
target_language,
serde_json::to_string(&request.content)?
)),
OneShotOperation::AnalyzeImage => Ok(format!(
"Analyze this image metadata and return title, alt, and caption suggestions: {}",
serde_json::to_string(&request.content)?
)),
).into()),
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
"Translate this media metadata to {}: {}",
target_language,
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) {
match operation {
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 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();

View File

@@ -11,7 +11,7 @@ use crate::db::queries;
use crate::engine::site_assets::write_bundled_site_assets;
use crate::engine::validate_site::SiteValidationReport;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, ProjectMetadata};
use crate::model::{CategorySettings, Post, ProjectMetadata};
use crate::render::{
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
build_site_render_artifacts, render_markdown_to_html, write_generated_bytes,
@@ -49,11 +49,14 @@ pub fn generate_starter_site(
_language: &str,
) -> EngineResult<GenerationReport> {
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
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.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()))?;
for page in &artifacts.pages {
@@ -67,12 +70,12 @@ pub fn generate_starter_site(
output_dir,
project_id,
"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,
)?;
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()) {
String::new()
} else {
@@ -142,6 +145,8 @@ pub fn apply_validation_sections(
let section_set = sections.iter().copied().collect::<HashSet<_>>();
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
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
@@ -170,7 +175,7 @@ pub fn apply_validation_sections(
output_dir,
project_id,
"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,
)?;
@@ -178,7 +183,7 @@ pub fn apply_validation_sections(
let localized_posts = localized_sources(
conn,
&data_dir,
posts,
&list_posts,
&render_language,
metadata,
)?;
@@ -511,6 +516,27 @@ fn localized_sources(
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 {
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
let last_build = posts

View File

@@ -607,6 +607,38 @@ mod tests {
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]
fn preview_server_serves_style_preview() {
let _guard = preview_port_guard().lock().unwrap();
@@ -659,4 +691,73 @@ mod tests {
assert!(body.contains("data-theme=\"nightfall\""));
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"));
}
}

View File

@@ -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/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: "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<()> {

View File

@@ -52,10 +52,17 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
"vimeo" => Some(render_vimeo(&args)),
"photo_archive" => Some(render_photo_archive(&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> {
let mut tokens = Vec::new();
let mut current = String::new();

View File

@@ -54,7 +54,8 @@ pub struct PreviewRenderResult {
struct TemplateBundle {
post_templates: Vec<Template>,
template_source_by_slug: HashMap<String, String>,
list_template: String,
list_template_sources: HashMap<String, String>,
default_list_template: String,
not_found_template: String,
partials: HashMap<String, String>,
}
@@ -71,6 +72,7 @@ struct RouteSpec {
url_path: String,
page_title: String,
archive_context: Option<Value>,
list_template_slug: Option<String>,
posts: Vec<RenderPostRecord>,
current_page: usize,
total_pages: usize,
@@ -99,7 +101,8 @@ pub fn build_site_render_artifacts(
let mut artifacts = SiteRenderArtifacts::default();
for language in languages {
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 menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes
@@ -109,8 +112,9 @@ pub fn build_site_render_artifacts(
route,
metadata,
&language,
&localized_posts,
&localized_list_posts,
&tags,
&category_settings,
&menu_items,
&post_data_json_by_id,
&bundle,
@@ -206,8 +210,9 @@ fn load_template_bundle(
let templates = queries::template::list_templates_by_project(conn, project_id).unwrap_or_default();
let mut template_source_by_slug = HashMap::new();
let mut post_templates = Vec::new();
let mut list_template_sources = HashMap::new();
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();
for template in templates {
@@ -223,8 +228,12 @@ fn load_template_bundle(
post_templates.push(hydrated);
}
TemplateKind::List => {
if template.slug == "list" || list_template == STARTER_POST_LIST_TEMPLATE {
list_template = source;
list_template_sources.insert(template.slug.clone(), source.clone());
if template.slug == "list"
|| template.slug == "post-list"
|| default_list_template == STARTER_POST_LIST_TEMPLATE
{
default_list_template = source;
}
}
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") {
post_templates.push(Template {
id: "starter-post-template".to_string(),
@@ -263,7 +279,8 @@ fn load_template_bundle(
Ok(TemplateBundle {
post_templates,
template_source_by_slug,
list_template,
list_template_sources,
default_list_template,
not_found_template,
partials,
})
@@ -330,10 +347,18 @@ fn build_language_routes(
metadata: &ProjectMetadata,
language: &str,
tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>,
) -> Vec<RouteSpec> {
let per_page = metadata.max_posts_per_page.max(1) as usize;
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 tag_posts: BTreeMap<String, Vec<RenderPostRecord>> = BTreeMap::new();
@@ -361,6 +386,9 @@ fn build_language_routes(
format!("{}/category/{slug}", language_root_prefix(language, metadata)),
category.clone(),
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)),
display_name.clone(),
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}", metadata.name),
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}", metadata.name),
Some(json!({"kind": "month", "year": year, "month": month})),
None,
));
}
@@ -409,6 +440,7 @@ fn paginated_route_specs(
base_path: String,
page_title: String,
archive_context: Option<Value>,
list_template_slug: Option<String>,
) -> Vec<RouteSpec> {
let total_items = posts.len();
let total_pages = total_items.max(1).div_ceil(per_page.max(1));
@@ -436,6 +468,7 @@ fn paginated_route_specs(
url_path,
page_title: page_title.clone(),
archive_context: archive_context.clone(),
list_template_slug: list_template_slug.clone(),
posts: slice,
current_page,
total_pages: total_pages.max(1),
@@ -452,6 +485,7 @@ fn render_list_route(
language: &str,
posts: &[RenderPostRecord],
tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>,
menu_items: &[Value],
post_data_json_by_id: &HashMap<String, Value>,
bundle: &TemplateBundle,
@@ -459,6 +493,11 @@ fn render_list_route(
let main_language = main_language(metadata);
let canonical_map = canonical_post_path_by_slug(posts, language, main_language);
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!({
"language": language,
"language_prefix": language_prefix(language, main_language),
@@ -474,7 +513,7 @@ fn render_list_route(
"show_archive_range_heading": false,
"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))),
"day_blocks": build_day_blocks(&route.posts),
"day_blocks": build_day_blocks(&route.posts, category_settings),
"is_list_page": route.current_page > 1,
"is_first_page": route.current_page == 1,
"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,
});
Ok(render_liquid_template(&bundle.list_template, &bundle.partials, &context)?)
Ok(render_liquid_template(list_template, &bundle.partials, &context)?)
}
#[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 current_key = String::new();
let mut current_posts = Vec::new();
@@ -730,12 +772,13 @@ fn build_day_blocks(posts: &[RenderPostRecord]) -> Vec<Value> {
}
current_key = key;
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!({
"id": record.post.id,
"title": record.post.title,
"slug": record.post.slug,
"content": record.post.excerpt.clone().unwrap_or_else(|| record.body_markdown.clone()),
"show_title": true,
"content": resolve_list_content(record, category_settings),
"show_title": show_title,
}));
}
@@ -750,6 +793,58 @@ fn build_day_blocks(posts: &[RenderPostRecord]) -> Vec<Value> {
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(
posts: &[RenderPostRecord],
language: &str,