use crate::cmd::SsgConfig;
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::PluginContext;
use serde_json::{json, Value};
use std::fs;
pub fn write_ai_plugin_json(
ctx: &PluginContext,
cfg: &SsgConfig,
) -> Result<(), SsgError> {
let well_known = ctx.site_dir.join(".well-known");
fs::create_dir_all(&well_known).with_path(&well_known)?;
let path = well_known.join("ai-plugin.json");
let manifest = build_manifest(cfg);
let body = serde_json::to_string_pretty(&manifest)
.map_err(|e| SsgError::io(e, &path))?;
fs::write(&path, body).with_path(&path)?;
Ok(())
}
#[must_use]
pub fn build_manifest(cfg: &SsgConfig) -> Value {
let base_url = cfg.base_url.trim_end_matches('/').to_string();
let human_name = if cfg.site_title.is_empty() {
cfg.site_name.clone()
} else {
cfg.site_title.clone()
};
let model_name = slugify_for_model(&cfg.site_name);
let description = if cfg.site_description.is_empty() {
format!("Content from {}", &human_name)
} else {
cfg.site_description.clone()
};
let openapi_url = if base_url.is_empty() {
"/openapi.yaml".to_string()
} else {
format!("{base_url}/openapi.yaml")
};
let logo_url = if base_url.is_empty() {
"/favicon.ico".to_string()
} else {
format!("{base_url}/favicon.ico")
};
let legal_url = if base_url.is_empty() {
"/legal".to_string()
} else {
format!("{base_url}/legal")
};
let contact_email = derive_contact_email(&base_url);
json!({
"schema_version": "v1",
"name_for_human": human_name,
"name_for_model": model_name,
"description_for_human": description,
"description_for_model": description_for_model(&human_name, cfg),
"auth": { "type": "none" },
"api": {
"type": "openapi",
"url": openapi_url,
"is_user_authenticated": false,
},
"logo_url": logo_url,
"contact_email": contact_email,
"legal_info_url": legal_url,
})
}
fn description_for_model(human_name: &str, cfg: &SsgConfig) -> String {
if !cfg.site_description.is_empty() {
return cfg.site_description.clone();
}
format!(
"Plugin for accessing content from {human_name}. \
Use this plugin to search and retrieve content from \
{human_name}."
)
}
fn derive_contact_email(base_url: &str) -> String {
if let Some(host) = host_from_url(base_url) {
format!("support@{host}")
} else {
"support@example.invalid".to_string()
}
}
fn host_from_url(url: &str) -> Option<String> {
let without_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let host = without_scheme.split('/').next()?;
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
fn slugify_for_model(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
for lower in c.to_lowercase() {
out.push(lower);
}
} else if c == '_' || c == ' ' || c == '-' {
out.push('_');
}
}
let collapsed: String = out
.chars()
.fold(String::new(), |mut acc, c| {
if c == '_' && acc.ends_with('_') {
} else {
acc.push(c);
}
acc
})
.trim_matches('_')
.to_string();
if collapsed.is_empty() {
"site".to_string()
} else if collapsed.len() > 50 {
collapsed[..50].to_string()
} else {
collapsed
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cmd::{ImageConfig, SsgConfig};
use std::path::PathBuf;
fn cfg() -> SsgConfig {
SsgConfig {
site_name: "Example Site".to_string(),
site_title: "Example".to_string(),
site_description: "A demo".to_string(),
base_url: "https://example.com".to_string(),
language: "en".to_string(),
content_dir: PathBuf::from("content"),
output_dir: PathBuf::from("build"),
template_dir: PathBuf::from("templates"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
image: ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
}
}
#[test]
fn manifest_has_all_required_keys() {
let m = build_manifest(&cfg());
for key in [
"schema_version",
"name_for_human",
"name_for_model",
"description_for_human",
"description_for_model",
"auth",
"api",
] {
assert!(m.get(key).is_some(), "missing key: {key}");
assert!(!m[key].is_null(), "key {key} is null");
}
}
#[test]
fn schema_version_is_v1() {
let m = build_manifest(&cfg());
assert_eq!(m["schema_version"], "v1");
}
#[test]
fn auth_is_none() {
let m = build_manifest(&cfg());
assert_eq!(m["auth"]["type"], "none");
}
#[test]
fn api_uses_openapi_url() {
let m = build_manifest(&cfg());
assert_eq!(m["api"]["type"], "openapi");
assert_eq!(m["api"]["url"], "https://example.com/openapi.yaml");
}
#[test]
fn name_for_model_is_slug_safe() {
let mut c = cfg();
c.site_name = "Hello World!".to_string();
let m = build_manifest(&c);
let model_name = m["name_for_model"].as_str().unwrap();
assert!(
model_name.chars().all(|ch| ch.is_ascii_lowercase()
|| ch.is_ascii_digit()
|| ch == '_'),
"name_for_model must be [a-z0-9_]+, got {model_name:?}"
);
}
#[test]
fn name_for_model_collapses_underscores() {
assert_eq!(slugify_for_model("Hello World!!"), "hello_world");
assert_eq!(slugify_for_model("foo - bar"), "foo_bar");
}
#[test]
fn name_for_model_falls_back_to_site_when_all_dropped() {
assert_eq!(slugify_for_model("!!!"), "site");
assert_eq!(slugify_for_model(""), "site");
}
#[test]
fn falls_back_to_site_name_when_title_empty() {
let mut c = cfg();
c.site_title = String::new();
let m = build_manifest(&c);
assert_eq!(m["name_for_human"], "Example Site");
}
#[test]
fn description_for_model_returns_site_description_verbatim_when_present() {
let m = build_manifest(&cfg());
let desc = m["description_for_model"].as_str().unwrap();
assert_eq!(desc, "A demo");
}
#[test]
fn description_for_model_synthesises_invocation_hint_when_empty() {
let mut c = cfg();
c.site_description = String::new();
let m = build_manifest(&c);
let desc = m["description_for_model"].as_str().unwrap();
assert!(
desc.contains("Use this plugin"),
"synthesised description should hint at when to invoke, got {desc:?}"
);
assert!(
desc.contains(c.site_title.as_str())
|| desc.contains(c.site_name.as_str()),
"synthesised description should mention the site, got {desc:?}"
);
}
#[test]
fn host_extraction_is_lenient() {
assert_eq!(
host_from_url("https://example.com/foo"),
Some("example.com".to_string())
);
assert_eq!(
host_from_url("http://example.com"),
Some("example.com".to_string())
);
assert_eq!(host_from_url("notaurl"), None);
assert_eq!(host_from_url(""), None);
}
#[test]
fn contact_email_falls_back_when_no_host() {
let mut c = cfg();
c.base_url = String::new();
let m = build_manifest(&c);
let email = m["contact_email"].as_str().unwrap();
assert!(email.contains('@'), "contact_email must contain @");
}
#[test]
fn manifest_is_valid_json() {
let m = build_manifest(&cfg());
let s = serde_json::to_string_pretty(&m).unwrap();
let parsed: Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["schema_version"], "v1");
}
#[test]
fn synthesises_empty_description_safely() {
let mut c = cfg();
c.site_description = String::new();
let m = build_manifest(&c);
let h = m["description_for_human"].as_str().unwrap();
let model = m["description_for_model"].as_str().unwrap();
assert!(!h.is_empty());
assert!(!model.is_empty());
}
}