Coverage for lode / builder.py: 87%
146 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-10-05 16:02 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-10-05 16:02 +0000
1# lode/builder.py
2import re
3import shutil
4from pathlib import Path
5from urllib.parse import quote
6from jinja2 import Environment, FileSystemLoader, select_autoescape
7import importlib.resources
8from rdflib import URIRef
9from rdflib.namespace import split_uri
11# internal modules
12from lode.viewer.base_viewer import SERIALIZATION_FORMATS
13from lode.helpers.html_utils import minify as _minify
14from lode.models.model import Model
17_SER = [(f["fmt"], f["ext"]) for f in SERIALIZATION_FORMATS]
19def _get_template_env(static_path: str = "static") -> Environment:
20 try:
21 pkg_path = importlib.resources.files("lode").joinpath("templates")
22 template_dir = str(pkg_path)
23 except Exception:
24 template_dir = str(Path(__file__).parent / "templates")
26 env = Environment(
27 loader=FileSystemLoader(template_dir),
28 autoescape=select_autoescape(["html"]),
29 )
31 def url_for(name: str, path: str = "") -> str:
32 if name == "static":
33 return f"{static_path}/{path}"
34 return "/"
36 env.globals["url_for"] = url_for
37 return env
39def _prefix_map(graph):
40 return {str(ns): (p or 'default') for p, ns in graph.namespaces()}
42def _safe_segment(seg: str) -> str:
43 """Restrict a slug path segment to a filesystem-safe whitelist.
45 Prefix and local name come from IRIs in the loaded ontology, i.e. from
46 user-controlled input: path separators, '..', NUL etc. must never reach
47 the filesystem (path injection). An empty result means "no usable local
48 part" and is treated by callers as a namespace IRI (page skipped).
49 """
50 seg = re.sub(r"[^A-Za-z0-9._-]", "-", seg).strip("-.")
51 if not seg or set(seg) == {"."}:
52 return ""
53 return seg[:100]
56def _rel_slug(uri, graph):
57 prefix, ns, local = graph.namespace_manager.compute_qname(URIRef(str(uri)), generate=True)
58 prefix = _safe_segment(prefix or "default") or "default"
59 return f"{prefix}/{_safe_segment(local)}"
62def _local_name(uri: str) -> str:
63 return uri.split('#', 1)[1] if '#' in uri else uri.rstrip('/').rsplit('/', 1)[-1]
65def _copy_static(out_dir: Path) -> None:
66 static_src = Path(__file__).parent / "static"
67 static_dst = out_dir / "static"
68 if static_src.exists() and not static_dst.exists():
69 shutil.copytree(static_src, static_dst)
71def build_html(viewer, out_dir: Path, lang: str = "en", reader=None) -> None:
72 from collections import defaultdict
74 out_dir.mkdir(parents=True, exist_ok=True)
75 _copy_static(out_dir)
77 ontology_ns = viewer._get_ontology_ns()
78 graph = reader._graph if reader else viewer.reader._graph
80 grouped = defaultdict(list)
81 for inst in viewer.get_all_instances():
82 grouped[type(inst).__name__].append(inst)
84 toc_config = viewer.get_toc_config() if hasattr(viewer, "get_toc_config") else []
86 # URI that will actually get a file (same criterion as the loop below)
87 materialized = set()
88 for class_key, _sid, _t in toc_config:
89 for inst in grouped.get(class_key, []):
90 u = inst.get_has_identifier()
91 if not u:
92 continue
93 if not (u.startswith(ontology_ns) or viewer._has_local_triples(u)):
94 continue
95 if _rel_slug(u, graph).rsplit("/", 1)[-1] == "": # namespace IRI, no local
96 continue
97 materialized.add(str(u))
99 def _keep(uri):
100 return str(uri) in materialized
102 def resource_url_resource(uri, section=None):
103 if not _keep(uri):
104 return uri
105 rel = _rel_slug(uri, graph)
106 if rel.rsplit("/", 1)[-1] == "": # namespace IRI
107 return "../../index.html"
108 return f"../{rel}.html"
110 def resource_url_index(uri, section=None):
111 if not _keep(uri):
112 return uri
113 rel = _rel_slug(uri, graph)
114 if rel.rsplit("/", 1)[-1] == "":
115 return "index.html"
116 return f"resources/{rel}.html"
118 env_index = _get_template_env(static_path="static")
119 env_resource = _get_template_env(static_path="../../static")
121 if reader is not None:
122 for fmt, ext in _SER:
123 out = reader._graph.serialize(format=fmt)
124 if isinstance(out, bytes):
125 out = out.decode("utf-8")
126 (out_dir / f"ontology.{ext}").write_text(out, encoding="utf-8")
128 template_index = env_index.get_template("viewer.html")
129 template_resource = env_resource.get_template("viewer.html")
131 toc_config = viewer.get_toc_config() if hasattr(viewer, "get_toc_config") else []
133 # --- index.html ---
134 data = viewer.get_view_data(language=lang)
135 data["request"] = _FakeRequest("/")
136 data["is_static"] = True
137 data["resource_url"] = resource_url_index
138 data["export_base"] = "ontology"
139 (out_dir / "index.html").write_text(_minify(template_index.render(**data)), encoding="utf-8")
140 print(" [build] index.html")
142 if not toc_config:
143 return
145 res_dir = out_dir / "resources"
146 res_dir.mkdir(parents=True, exist_ok=True)
148 count = 0
149 for class_key, section_id, _ in toc_config:
150 for inst in grouped.get(class_key, []):
151 uri = inst.get_has_identifier()
152 if not uri:
153 continue
154 native = uri.startswith(ontology_ns)
155 if not (native or viewer._has_local_triples(uri)):
156 continue
158 rel = _rel_slug(uri, graph)
159 if rel.rsplit("/", 1)[-1] == "": # namespace IRI, no local part
160 continue
162 page = res_dir / f"{rel}.html"
163 # Defense in depth: even if a slug ever slipped through the
164 # whitelist, never write outside the resources directory.
165 if not page.resolve().is_relative_to(res_dir.resolve()):
166 continue
167 page.parent.mkdir(parents=True, exist_ok=True)
169 for fmt, ext in _SER:
170 out = viewer.export_resource(uri, fmt)
171 if isinstance(out, bytes):
172 out = out.decode("utf-8")
173 (res_dir / f"{rel}.{ext}").write_text(out, encoding="utf-8")
175 data_r = viewer.get_view_data(resource_uri=uri, language=lang)
176 data_r["request"] = _FakeRequest(f"resources/{rel}.html")
177 data_r["is_static"] = True
178 data_r["resource_url"] = resource_url_resource
179 data_r["export_base"] = _local_name(uri)
180 page.write_text(_minify(template_resource.render(**data_r)), encoding="utf-8")
181 count += 1
183class _FakeRequest:
184 def __init__(self, path: str):
185 self.url = _FakeURL(path)
186 self.method = "GET"
187 self.query_params = _FakeQueryParams()
188 self.is_static = True
190class _FakeQueryParams:
191 def get(self, key: str, default: str = "") -> str:
192 return default
194 # Accesso diretto tipo request.query_params.read_as
195 def __getattr__(self, key: str) -> str:
196 return ""
198class _FakeURL:
199 def __init__(self, path: str):
200 self.path = path
202 def __str__(self) -> str:
203 return self.path