1use minijinja::{context, AutoEscape, Environment};
4use ontocore_catalog::OntologyCatalog;
5use ontocore_core::{document_matches_entity, document_matches_ontology_id};
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ExportFormat {
12 Markdown,
13 Html,
14}
15
16#[derive(Debug, Clone)]
17pub struct ExportOptions {
18 pub output_dir: PathBuf,
19 pub format: ExportFormat,
20 pub ontology_id: Option<String>,
21}
22
23impl ExportOptions {
24 pub fn markdown(output_dir: impl Into<PathBuf>) -> Self {
25 Self { output_dir: output_dir.into(), format: ExportFormat::Markdown, ontology_id: None }
26 }
27
28 pub fn html(output_dir: impl Into<PathBuf>) -> Self {
29 Self { output_dir: output_dir.into(), format: ExportFormat::Html, ontology_id: None }
30 }
31
32 pub fn with_ontology_id(mut self, id: impl Into<String>) -> Self {
33 self.ontology_id = Some(id.into());
34 self
35 }
36}
37
38#[derive(Debug, thiserror::Error)]
39pub enum ExportError {
40 #[error("IO error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("template error: {0}")]
43 Template(#[from] minijinja::Error),
44}
45
46pub type Result<T> = std::result::Result<T, ExportError>;
47
48#[derive(Debug, Clone, serde::Serialize)]
49struct EntityDoc {
50 iri: String,
51 short_name: String,
52 kind: String,
53 labels: Vec<String>,
54 comments: Vec<String>,
55 parents: Vec<String>,
56}
57
58#[derive(Debug, Clone, serde::Serialize)]
59struct OntologyDoc {
60 id: String,
61 slug: String,
62 path: String,
63 imports: Vec<String>,
64 entities: Vec<EntityDoc>,
65}
66
67pub fn export_workspace(catalog: &OntologyCatalog, options: ExportOptions) -> Result<()> {
68 fs::create_dir_all(&options.output_dir)?;
69
70 let hierarchy = catalog.class_hierarchy();
71 let mut ontologies: Vec<OntologyDoc> = Vec::new();
72
73 for doc in &catalog.data().documents {
74 if let Some(filter) = &options.ontology_id {
75 if !document_matches_ontology_id(filter, doc) {
76 continue;
77 }
78 }
79 let mut entities = Vec::new();
80 for entity in &catalog.data().entities {
81 if !document_matches_entity(entity, doc) {
82 continue;
83 }
84 let parents = hierarchy.parents.get(&entity.iri).cloned().unwrap_or_default();
85 entities.push(EntityDoc {
86 iri: entity.iri.clone(),
87 short_name: entity.short_name.clone(),
88 kind: entity.kind.as_str().to_string(),
89 labels: entity.labels.clone(),
90 comments: entity.comments.clone(),
91 parents,
92 });
93 }
94 entities.sort_by(|a, b| a.short_name.cmp(&b.short_name));
95 ontologies.push(OntologyDoc {
96 id: doc.id.clone(),
97 slug: slugify(&doc.id),
98 path: doc.path.display().to_string(),
99 imports: doc.imports.clone(),
100 entities,
101 });
102 }
103
104 let index_name = match options.format {
105 ExportFormat::Markdown => "index.md",
106 ExportFormat::Html => "index.html",
107 };
108 let index_path = options.output_dir.join(index_name);
109 let index_body = render_index(&ontologies, options.format)?;
110 fs::write(index_path, index_body)?;
111
112 for ont in &ontologies {
113 let file_name = match options.format {
114 ExportFormat::Markdown => format!("{}.md", ont.slug),
115 ExportFormat::Html => format!("{}.html", ont.slug),
116 };
117 let body = render_ontology(ont, options.format)?;
118 fs::write(options.output_dir.join(file_name), body)?;
119 }
120
121 Ok(())
122}
123
124fn slugify(iri: &str) -> String {
125 iri.chars()
126 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
127 .collect::<String>()
128 .trim_matches('_')
129 .chars()
130 .take(80)
131 .collect()
132}
133
134fn render_index(ontologies: &[OntologyDoc], format: ExportFormat) -> Result<String> {
135 match format {
136 ExportFormat::Markdown => {
137 let mut md = String::from("# Ontology documentation\n\n");
138 for ont in ontologies {
139 md.push_str(&format!(
140 "- [{}]({}.md) — {} entities, {} imports\n",
141 ont.id,
142 ont.slug,
143 ont.entities.len(),
144 ont.imports.len()
145 ));
146 }
147 Ok(md)
148 }
149 ExportFormat::Html => {
150 let env = html_env()?;
151 let tmpl = env.get_template("index.html")?;
152 Ok(tmpl.render(context! { ontologies => ontologies })?)
153 }
154 }
155}
156
157fn render_ontology(ont: &OntologyDoc, format: ExportFormat) -> Result<String> {
158 match format {
159 ExportFormat::Markdown => {
160 let mut md = format!("# {}\n\n", ont.id);
161 md.push_str(&format!("Source: `{}`\n\n", ont.path));
162 if !ont.imports.is_empty() {
163 md.push_str("## Imports\n\n");
164 for imp in &ont.imports {
165 md.push_str(&format!("- <{imp}>\n"));
166 }
167 md.push('\n');
168 }
169 md.push_str("## Entities\n\n");
170 for entity in &ont.entities {
171 let label = entity.labels.first().map(|s| s.as_str()).unwrap_or(&entity.short_name);
172 md.push_str(&format!("### {label}\n\n"));
173 md.push_str(&format!("- IRI: `{}`\n", entity.iri));
174 md.push_str(&format!("- Kind: {}\n", entity.kind));
175 if !entity.comments.is_empty() {
176 md.push_str(&format!("- Comment: {}\n", entity.comments.join("; ")));
177 }
178 if !entity.parents.is_empty() {
179 md.push_str(&format!("- Parents: {}\n", entity.parents.join(", ")));
180 }
181 md.push('\n');
182 }
183 Ok(md)
184 }
185 ExportFormat::Html => {
186 let env = html_env()?;
187 let tmpl = env.get_template("ontology.html")?;
188 Ok(tmpl.render(context! { ont => ont })?)
189 }
190 }
191}
192
193fn html_env() -> Result<Environment<'static>> {
194 let mut env = Environment::new();
195 env.set_auto_escape_callback(|name| {
196 if name.ends_with(".html") {
197 AutoEscape::Html
198 } else {
199 AutoEscape::None
200 }
201 });
202 env.add_template(
203 "index.html",
204 r#"<!DOCTYPE html>
205<html><head><meta charset="utf-8"><title>Ontology docs</title></head>
206<body><h1>Ontology documentation</h1><ul>
207{% for ont in ontologies %}
208<li><a href="{{ ont.slug }}.html">{{ ont.id }}</a>
209 — {{ ont.entities | length }} entities</li>
210{% endfor %}
211</ul></body></html>"#,
212 )?;
213 env.add_template(
214 "ontology.html",
215 r#"<!DOCTYPE html>
216<html><head><meta charset="utf-8"><title>{{ ont.id }}</title></head>
217<body>
218<h1>{{ ont.id }}</h1>
219<p>Source: <code>{{ ont.path }}</code></p>
220{% if ont.imports %}<h2>Imports</h2><ul>{% for imp in ont.imports %}<li>{{ imp }}</li>{% endfor %}</ul>{% endif %}
221<h2>Entities</h2>
222<table border="1"><tr><th>Name</th><th>Kind</th><th>IRI</th></tr>
223{% for e in ont.entities %}
224<tr><td>{{ e.labels[0] if e.labels else e.short_name }}</td><td>{{ e.kind }}</td><td>{{ e.iri }}</td></tr>
225{% endfor %}
226</table>
227</body></html>"#,
228 )?;
229 Ok(env)
230}
231
232pub fn entity_kind_counts(catalog: &OntologyCatalog) -> BTreeMap<String, usize> {
233 let mut counts = BTreeMap::new();
234 for entity in &catalog.data().entities {
235 *counts.entry(entity.kind.as_str().to_string()).or_default() += 1;
236 }
237 counts
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use ontocore_catalog::IndexBuilder;
244 use std::path::Path;
245
246 #[test]
247 fn exports_markdown_for_fixtures() {
248 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
249 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
250 let dir = tempfile::tempdir().unwrap();
251 export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
252 assert!(dir.path().join("index.md").exists());
253 }
254
255 #[test]
256 fn exports_entities_for_owl_ontology_declarations() {
257 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
258 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
259 let example = catalog
260 .data()
261 .documents
262 .iter()
263 .find(|d| d.path.file_name().and_then(|n| n.to_str()) == Some("example.ttl"))
264 .expect("example.ttl indexed");
265 let entity_count =
266 catalog.data().entities.iter().filter(|e| document_matches_entity(e, example)).count();
267 assert!(entity_count > 0, "fixture entities should match example.ttl via ontology IRI");
268
269 let dir = tempfile::tempdir().unwrap();
270 export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
271 let index = fs::read_to_string(dir.path().join("index.md")).expect("index.md");
272 let doc_slug = slugify(&example.id);
273 let detail_path = dir.path().join(format!("{doc_slug}.md"));
274 assert!(detail_path.exists(), "expected per-ontology export file");
275 let detail = fs::read_to_string(detail_path).expect("ontology markdown");
276 assert!(detail.contains("## Entities"), "ontology page should list entities");
277 assert!(!detail.contains("## Entities\n\n\n#"), "entity section should not be empty");
278 assert!(
279 index.contains(&format!("{entity_count} entities")),
280 "index should report exported entity count for example.ttl"
281 );
282 }
283
284 #[test]
285 fn html_export_escapes_entity_labels() {
286 let ont = OntologyDoc {
287 id: "http://example.org/ex".to_string(),
288 slug: "ex".to_string(),
289 path: "evil.ttl".to_string(),
290 imports: vec![],
291 entities: vec![EntityDoc {
292 iri: "http://example.org/ex#Evil".to_string(),
293 short_name: "Evil".to_string(),
294 kind: "class".to_string(),
295 labels: vec!["<img src=x onerror=alert(1)>".to_string()],
296 comments: vec![],
297 parents: vec![],
298 }],
299 };
300 let html = render_ontology(&ont, ExportFormat::Html).expect("render html");
301 assert!(html.contains("<img"));
302 assert!(!html.contains("<img src=x onerror=alert(1)>"));
303 }
304}