1use minijinja::{context, AutoEscape, Environment};
4use ontocore_catalog::OntologyCatalog;
5use ontocore_core::{document_for_entity, document_matches_ontology_id, EntityKind};
6use std::collections::{BTreeMap, BTreeSet};
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 let mut used_slugs = BTreeSet::new();
73
74 for doc in &catalog.data().documents {
75 if let Some(filter) = &options.ontology_id {
76 if !document_matches_ontology_id(filter, doc) {
77 continue;
78 }
79 }
80 let mut entities = Vec::new();
81 for entity in &catalog.data().entities {
82 if document_for_entity(&catalog.data().documents, entity).is_none_or(|d| d.id != doc.id)
83 {
84 continue;
85 }
86 let parents = hierarchy.parents.get(&entity.iri).cloned().unwrap_or_default();
87 entities.push(EntityDoc {
88 iri: entity.iri.clone(),
89 short_name: entity.short_name.clone(),
90 kind: entity.kind.as_str().to_string(),
91 labels: entity.labels.clone(),
92 comments: entity.comments.clone(),
93 parents,
94 });
95 }
96 entities.sort_by(|a, b| a.short_name.cmp(&b.short_name));
97 ontologies.push(OntologyDoc {
98 id: doc.id.clone(),
99 slug: allocate_slug(&doc.id, &mut used_slugs),
100 path: doc.path.display().to_string(),
101 imports: doc.imports.clone(),
102 entities,
103 });
104 }
105
106 let index_name = match options.format {
107 ExportFormat::Markdown => "index.md",
108 ExportFormat::Html => "index.html",
109 };
110 let index_path = options.output_dir.join(index_name);
111 let index_body = render_index(&ontologies, &hierarchy, catalog, options.format)?;
112 fs::write(index_path, index_body)?;
113
114 for ont in &ontologies {
115 let file_name = match options.format {
116 ExportFormat::Markdown => format!("{}.md", ont.slug),
117 ExportFormat::Html => format!("{}.html", ont.slug),
118 };
119 let body = render_ontology(ont, options.format)?;
120 fs::write(options.output_dir.join(file_name), body)?;
121 }
122
123 Ok(())
124}
125
126fn slugify(iri: &str) -> String {
127 let slug: String = iri
128 .chars()
129 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
130 .collect::<String>()
131 .trim_matches('_')
132 .chars()
133 .take(80)
134 .collect();
135 if slug.is_empty() {
136 "ontology".to_string()
137 } else {
138 slug
139 }
140}
141
142fn iri_tag(iri: &str) -> String {
144 let mut hash: u64 = 0xcbf29ce484222325;
145 for byte in iri.as_bytes() {
146 hash ^= u64::from(*byte);
147 hash = hash.wrapping_mul(0x100000001b3);
148 }
149 format!("{hash:016x}")[..8].to_string()
150}
151
152fn allocate_slug(iri: &str, used: &mut BTreeSet<String>) -> String {
154 let base = slugify(iri);
155 if used.insert(base.clone()) {
156 return base;
157 }
158 let tag = iri_tag(iri);
159 let truncated: String = base.chars().take(71).collect();
160 let mut candidate = format!("{truncated}_{tag}");
161 let mut n = 2u32;
162 while !used.insert(candidate.clone()) {
163 candidate = format!("{truncated}_{tag}_{n}");
164 n = n.saturating_add(1);
165 }
166 candidate
167}
168
169fn render_index(
170 ontologies: &[OntologyDoc],
171 hierarchy: &ontocore_catalog::ClassHierarchy,
172 catalog: &OntologyCatalog,
173 format: ExportFormat,
174) -> Result<String> {
175 match format {
176 ExportFormat::Markdown => {
177 let mut md = String::from("# Ontology documentation\n\n");
178 for ont in ontologies {
179 md.push_str(&format!(
180 "- [{}]({}.md) — {} entities, {} imports\n",
181 ont.id,
182 ont.slug,
183 ont.entities.len(),
184 ont.imports.len()
185 ));
186 }
187 md.push_str("\n## Class hierarchy\n\n");
188 md.push_str(&render_class_hierarchy(catalog, hierarchy));
189 md.push_str("\n## Property index\n\n");
190 md.push_str(&render_property_index(catalog));
191 Ok(md)
192 }
193 ExportFormat::Html => {
194 let env = html_env()?;
195 let tmpl = env.get_template("index.html")?;
196 Ok(tmpl.render(context! { ontologies => ontologies })?)
197 }
198 }
199}
200
201pub fn render_class_hierarchy(
202 catalog: &OntologyCatalog,
203 hierarchy: &ontocore_catalog::ClassHierarchy,
204) -> String {
205 let mut nodes: BTreeSet<&str> = BTreeSet::new();
206 for iri in hierarchy.parents.keys().chain(hierarchy.children.keys()) {
207 nodes.insert(iri.as_str());
208 }
209 let mut roots: Vec<&str> = nodes
210 .into_iter()
211 .filter(|iri| hierarchy.parents.get(*iri).map(|p| p.is_empty()).unwrap_or(true))
212 .collect();
213 roots.sort();
214 let mut out = String::new();
215 for root in roots {
216 render_hierarchy_node(catalog, hierarchy, root, 0, &mut out);
217 }
218 if out.is_empty() {
219 out.push_str("_No class hierarchy available._\n");
220 }
221 out
222}
223
224fn render_hierarchy_node(
225 catalog: &OntologyCatalog,
226 hierarchy: &ontocore_catalog::ClassHierarchy,
227 iri: &str,
228 depth: usize,
229 out: &mut String,
230) {
231 let label = catalog
232 .data()
233 .entities
234 .iter()
235 .find(|e| e.iri == iri)
236 .and_then(|e| e.labels.first())
237 .map(|s| s.as_str())
238 .unwrap_or(iri);
239 out.push_str(&format!("{}{} (`{}`)\n", " ".repeat(depth), label, iri));
240 let mut children: Vec<&str> = hierarchy
241 .children
242 .get(iri)
243 .map(|v| v.iter().map(|s| s.as_str()).collect())
244 .unwrap_or_default();
245 children.sort();
246 for child in children {
247 render_hierarchy_node(catalog, hierarchy, child, depth + 1, out);
248 }
249}
250
251pub fn render_property_index(catalog: &OntologyCatalog) -> String {
252 let mut object_props = Vec::new();
253 let mut data_props = Vec::new();
254 let mut annotation_props = Vec::new();
255 for entity in &catalog.data().entities {
256 match entity.kind {
257 EntityKind::ObjectProperty => object_props.push(entity),
258 EntityKind::DataProperty => data_props.push(entity),
259 EntityKind::AnnotationProperty => annotation_props.push(entity),
260 _ => {}
261 }
262 }
263 let mut out = String::new();
264 for (title, props) in [
265 ("Object properties", &object_props),
266 ("Data properties", &data_props),
267 ("Annotation properties", &annotation_props),
268 ] {
269 if props.is_empty() {
270 continue;
271 }
272 out.push_str(&format!("### {title}\n\n"));
273 let mut sorted = props.clone();
274 sorted.sort_by(|a, b| a.short_name.cmp(&b.short_name));
275 for prop in sorted {
276 let label = prop.labels.first().map(|s| s.as_str()).unwrap_or(&prop.short_name);
277 out.push_str(&format!("- **{label}** — `{}`\n", prop.iri));
278 }
279 out.push('\n');
280 }
281 if out.is_empty() {
282 out.push_str("_No properties indexed._\n");
283 }
284 out
285}
286
287fn render_ontology(ont: &OntologyDoc, format: ExportFormat) -> Result<String> {
288 match format {
289 ExportFormat::Markdown => {
290 let mut md = format!("# {}\n\n", ont.id);
291 md.push_str(&format!("Source: `{}`\n\n", ont.path));
292 if !ont.imports.is_empty() {
293 md.push_str("## Imports\n\n");
294 for imp in &ont.imports {
295 md.push_str(&format!("- <{imp}>\n"));
296 }
297 md.push('\n');
298 }
299 md.push_str("## Entities\n\n");
300 for entity in &ont.entities {
301 let label = entity.labels.first().map(|s| s.as_str()).unwrap_or(&entity.short_name);
302 md.push_str(&format!("### {label}\n\n"));
303 md.push_str(&format!("- IRI: `{}`\n", entity.iri));
304 md.push_str(&format!("- Kind: {}\n", entity.kind));
305 if !entity.comments.is_empty() {
306 md.push_str(&format!("- Comment: {}\n", entity.comments.join("; ")));
307 }
308 if !entity.parents.is_empty() {
309 md.push_str(&format!("- Parents: {}\n", entity.parents.join(", ")));
310 }
311 md.push('\n');
312 }
313 Ok(md)
314 }
315 ExportFormat::Html => {
316 let env = html_env()?;
317 let tmpl = env.get_template("ontology.html")?;
318 Ok(tmpl.render(context! { ont => ont })?)
319 }
320 }
321}
322
323fn html_env() -> Result<Environment<'static>> {
324 let mut env = Environment::new();
325 env.set_auto_escape_callback(|name| {
326 if name.ends_with(".html") {
327 AutoEscape::Html
328 } else {
329 AutoEscape::None
330 }
331 });
332 env.add_template(
333 "index.html",
334 r#"<!DOCTYPE html>
335<html><head><meta charset="utf-8"><title>Ontology docs</title></head>
336<body><h1>Ontology documentation</h1><ul>
337{% for ont in ontologies %}
338<li><a href="{{ ont.slug }}.html">{{ ont.id }}</a>
339 — {{ ont.entities | length }} entities</li>
340{% endfor %}
341</ul></body></html>"#,
342 )?;
343 env.add_template(
344 "ontology.html",
345 r#"<!DOCTYPE html>
346<html><head><meta charset="utf-8"><title>{{ ont.id }}</title></head>
347<body>
348<h1>{{ ont.id }}</h1>
349<p>Source: <code>{{ ont.path }}</code></p>
350{% if ont.imports %}<h2>Imports</h2><ul>{% for imp in ont.imports %}<li>{{ imp }}</li>{% endfor %}</ul>{% endif %}
351<h2>Entities</h2>
352<table border="1"><tr><th>Name</th><th>Kind</th><th>IRI</th></tr>
353{% for e in ont.entities %}
354<tr><td>{{ e.labels[0] if e.labels else e.short_name }}</td><td>{{ e.kind }}</td><td>{{ e.iri }}</td></tr>
355{% endfor %}
356</table>
357</body></html>"#,
358 )?;
359 Ok(env)
360}
361
362pub fn entity_kind_counts(catalog: &OntologyCatalog) -> BTreeMap<String, usize> {
363 let mut counts = BTreeMap::new();
364 for entity in &catalog.data().entities {
365 *counts.entry(entity.kind.as_str().to_string()).or_default() += 1;
366 }
367 counts
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use ontocore_catalog::IndexBuilder;
374 use std::path::Path;
375
376 #[test]
377 fn exports_markdown_for_fixtures() {
378 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
379 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
380 let dir = tempfile::tempdir().unwrap();
381 export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
382 assert!(dir.path().join("index.md").exists());
383 }
384
385 #[test]
386 fn exports_entities_for_owl_ontology_declarations() {
387 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
388 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
389 let example = catalog
390 .data()
391 .documents
392 .iter()
393 .find(|d| d.path.file_name().and_then(|n| n.to_str()) == Some("example.ttl"))
394 .expect("example.ttl indexed");
395 let entity_count = catalog
396 .data()
397 .entities
398 .iter()
399 .filter(|e| {
400 document_for_entity(&catalog.data().documents, e)
401 .is_some_and(|d| d.id == example.id)
402 })
403 .count();
404 assert!(entity_count > 0, "fixture entities should match example.ttl via ontology IRI");
405
406 let dir = tempfile::tempdir().unwrap();
407 export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
408 let index = fs::read_to_string(dir.path().join("index.md")).expect("index.md");
409 let doc_slug = allocate_slug(&example.id, &mut BTreeSet::new());
410 let detail_path = dir.path().join(format!("{doc_slug}.md"));
411 assert!(detail_path.exists(), "expected per-ontology export file");
412 let detail = fs::read_to_string(detail_path).expect("ontology markdown");
413 assert!(detail.contains("## Entities"), "ontology page should list entities");
414 assert!(!detail.contains("## Entities\n\n\n#"), "entity section should not be empty");
415 assert!(
416 index.contains(&format!("{entity_count} entities")),
417 "index should report exported entity count for example.ttl"
418 );
419 }
420
421 #[test]
422 fn slugify_collisions_are_disambiguated() {
423 assert_eq!(slugify("http://a.com/x/1"), slugify("http://a.com/x_1"));
424 let mut used = BTreeSet::new();
425 let first = allocate_slug("http://a.com/x/1", &mut used);
426 let second = allocate_slug("http://a.com/x_1", &mut used);
427 assert_ne!(first, second);
428 assert_eq!(first, slugify("http://a.com/x/1"));
429 assert!(second.contains('_'), "collision should append a disambiguating tag");
430 assert_eq!(used.len(), 2);
431 }
432
433 #[test]
434 fn export_keeps_both_files_when_ontology_ids_slug_collide() {
435 let dir = tempfile::tempdir().unwrap();
436 let a = dir.path().join("a.ttl");
437 let b = dir.path().join("b.ttl");
438 fs::write(
440 &a,
441 concat!(
442 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
443 "<http://a.com/x/1> a owl:Ontology .\n",
444 "<http://a.com/x/1#A> a owl:Class .\n",
445 ),
446 )
447 .unwrap();
448 fs::write(
449 &b,
450 concat!(
451 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
452 "<http://a.com/x_1> a owl:Ontology .\n",
453 "<http://a.com/x_1#B> a owl:Class .\n",
454 ),
455 )
456 .unwrap();
457
458 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("index");
459 let out = tempfile::tempdir().unwrap();
460 export_workspace(&catalog, ExportOptions::markdown(out.path())).expect("export");
461
462 let md_files: Vec<_> = fs::read_dir(out.path())
463 .unwrap()
464 .filter_map(|e| e.ok())
465 .map(|e| e.file_name().to_string_lossy().into_owned())
466 .filter(|n| n.ends_with(".md") && n != "index.md")
467 .collect();
468 assert_eq!(
469 md_files.len(),
470 2,
471 "both colliding ontology IRIs must produce distinct export files, got {md_files:?}"
472 );
473
474 let bodies: Vec<_> = md_files
475 .iter()
476 .map(|name| fs::read_to_string(out.path().join(name)).expect("read"))
477 .collect();
478 assert!(bodies.iter().any(|b| b.contains("http://a.com/x/1")));
479 assert!(bodies.iter().any(|b| b.contains("http://a.com/x_1")));
480 }
481
482 #[test]
483 fn markdown_index_includes_hierarchy_and_property_sections() {
484 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
485 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
486 let dir = tempfile::tempdir().unwrap();
487 export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
488 let index = fs::read_to_string(dir.path().join("index.md")).expect("index.md");
489 assert!(index.contains("## Class hierarchy"), "index should include hierarchy");
490 assert!(index.contains("## Property index"), "index should include properties");
491 }
492
493 #[test]
494 fn render_class_hierarchy_lists_fixture_classes() {
495 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
496 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
497 let hierarchy = catalog.class_hierarchy();
498 let md = render_class_hierarchy(&catalog, &hierarchy);
499 assert!(md.contains("Person") || md.contains("people#Person"));
500 }
501
502 #[test]
503 fn render_property_index_lists_object_properties() {
504 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
505 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
506 let md = render_property_index(&catalog);
507 assert!(md.contains("Object properties"));
508 assert!(md.contains("worksFor") || md.contains("works for"));
509 }
510
511 #[test]
512 fn markdown_export_includes_person_entity_page() {
513 let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
514 let catalog = IndexBuilder::new().workspace(&fixtures).build().expect("index");
515 let dir = tempfile::tempdir().unwrap();
516 export_workspace(&catalog, ExportOptions::markdown(dir.path())).expect("export");
517 let mut found_person = false;
518 for entry in fs::read_dir(dir.path()).unwrap() {
519 let path = entry.unwrap().path();
520 if path.extension().and_then(|e| e.to_str()) != Some("md") {
521 continue;
522 }
523 let body = fs::read_to_string(&path).unwrap();
524 if body.contains("Person") && body.contains("http://example.org/people#Person") {
525 found_person = true;
526 break;
527 }
528 }
529 assert!(found_person, "expected a markdown page documenting Person");
530 }
531}