From f35a640c1c9efef8c066463def2f1c47e193dada Mon Sep 17 00:00:00 2001 From: Valentina Date: Thu, 15 Oct 2026 16:01:15 +0200 Subject: [PATCH 01/21] fix(viewer): select literals per property with language fallback --- lode/viewer/base_viewer.py | 208 ++++++++++++++++++++----------------- lode/viewer/owl_viewer.py | 14 ++- lode/viewer/rdf_viewer.py | 25 ++--- lode/viewer/skos_viewer.py | 28 +++-- tests/test_owl_viewer.py | 55 ++++++++++ 5 files changed, 203 insertions(+), 127 deletions(-) diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 2593ecc..548898d 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -1,4 +1,8 @@ -# base_viewer.py +"""Generic viewer: turns model instances into the dictionaries the HTML templates render. + +Format-specific viewers (OWL, RDF, SKOS) extend it with their table of contents +and display names. It also serializes the provenance of each entity. +""" import hashlib import re from typing import Dict, List, Optional, Tuple @@ -6,7 +10,6 @@ import urllib.parse from rdflib import Graph, URIRef, BNode, Literal as RDFlibLiteral -from rdflib.namespace import RDF, OWL from lode.reader.provenance_graph import ProvenanceGraph from lode.helpers.rdf import iri_local_name @@ -25,18 +28,28 @@ ] def formats_for(graph): + """Return the serialization formats available for a graph. + + Quad formats are offered only for a ProvenanceGraph, because only it keeps + the named graph of each triple. + """ return QUAD_SERIALIZATION_FORMATS if isinstance(graph, ProvenanceGraph) else SERIALIZATION_FORMATS class BaseViewer: - """Base viewer per visualizzare istanze estratte dal Reader.""" - + """Base viewer to display the instances extracted by the Reader.""" + def __init__(self, reader): + """Bind the viewer to a Reader and share its instance cache.""" self.reader = reader self._cache = reader._instance_cache # it uses self._internal_iris = None self._ontology_ns = None def _get_ontology_ns(self) -> str: + """Return the namespace of the documented ontology (its IRI ending in '/' or '#'), or ''. + + Computed once from the first Model instance and then cached. + """ if self._ontology_ns is None: self._ontology_ns = '' for inst in self.get_all_instances(): @@ -48,15 +61,17 @@ def _get_ontology_ns(self) -> str: return self._ontology_ns def _has_local_triples(self, uri) -> bool: + """Return True if the graph has at least one triple whose subject is this IRI.""" if not uri: return False return (URIRef(str(uri)), None, None) in self.reader._graph def _formats(self): + """Return the serialization formats available for the loaded graph.""" return formats_for(self.reader._graph) def _with_provenance(self, sub): - """Con moduli attivi: ricolloca le triple di `sub` nei named graph d'origine.""" + """With modules active, put the triples of `sub` back into their original named graphs.""" ds = self.reader._graph if not isinstance(ds, ProvenanceGraph): return sub @@ -69,6 +84,10 @@ def _with_provenance(self, sub): return out def _is_internal(self, uri) -> bool: + """Return True if the IRI belongs to a browsable entity of this documentation. + + The set of IRIs is built once, on first use. + """ if not uri: return False if self._internal_iris is None: @@ -80,7 +99,7 @@ def _is_internal(self, uri) -> bool: return str(uri) in self._internal_iris def get_all_instances(self) -> List: - """Ottiene tutte le istanze (esclusi literal).""" + """Return all the instances in the cache, literals excluded.""" instances = [] for uri_id, instance_set in self._cache.items(): @@ -91,53 +110,49 @@ def get_all_instances(self) -> List: instances.extend(instance_list) return instances - + def get_instances_from_single_resource(self, resource_uri: str) -> Optional[set]: - """Ottiene istanze per un URI specifico dalla cache.""" - # Cerca l'URI nella cache + """Return the cached instances registered under an IRI, or None when it is unknown.""" + # Look the IRI up in the cache for uri_id in self._cache.keys(): if str(uri_id) == resource_uri: return self._cache[uri_id] return None + def _select_by_language(self, values: List, language: Optional[str] = None) -> List: + """Keep the values of one property in the best available language. + + The choice is made per property, not per literal: only with all the + siblings in view can a fallback exist. Tags follow BCP 47, so they are + compared case-insensitively. Order of preference (RFC 4647 lookup): the + exact tag, the tag truncated from the right (en-GB, then en), 'en', + untagged literals, then every literal sorted by tag (nothing disappears). + Non-literal values always pass, and the input order is kept. + """ + lits = [v for v in values if isinstance(v, Literal)] + parts = (language or "en").strip().lower().split("-") + # shortcut: no skipping of singleton subtags (zh-x-private), upgrade if such tags matter + chain = ["-".join(parts[:i]) for i in range(len(parts), 0, -1)] + ["en"] + + def tagged(wanted: str) -> List: + """Literals whose tag is exactly `wanted`, ignoring case.""" + return [l for l in lits if (l.get_has_language() or "").lower() == wanted] + + chosen = (next((m for m in map(tagged, chain) if m), None) + or [l for l in lits if not l.get_has_language()] + or sorted(lits, key=lambda l: str(l.get_has_language() or ""))) + keep = {id(l) for l in chosen} + return [v for v in values if not isinstance(v, Literal) or id(v) in keep] + def _get_best_label(self, resource, language: Optional[str] = None) -> Optional[str]: """Gets the best label to display: language > preferred_label > label > identifier.""" - # 1. Force 'en' as the absolute default if no language makes it this far - target_lang = language.strip().lower() if language else "en" - - # Check Preferred Labels for the target language - labels = resource.get_has_preferred_label() - for label in labels: - if hasattr(label, 'get_has_language') and label.get_has_language(): - if label.get_has_language().lower().startswith(target_lang): - # RETURN RAW LABEL - return label.get_has_value() - - # Check Normal Labels for the target language - labels = resource.get_has_label() - for label in labels: - if hasattr(label, 'get_has_language') and label.get_has_language(): - if label.get_has_language().lower().startswith(target_lang): - # RETURN RAW LABEL - return label.get_has_value() - - # --- DETERMINISTIC FALLBACKS --- - # If we reach here, no English label exists. - # We sort them by language tag. - - labels = resource.get_has_preferred_label() - if labels: - sorted_labels = sorted(labels, key=lambda x: str(x.get_has_language() or "")) - # RETURN RAW LABEL - return sorted_labels[0].get_has_value() - - labels = resource.get_has_label() - if labels: - sorted_labels = sorted(labels, key=lambda x: str(x.get_has_language() or "")) - # RETURN RAW LABEL - return sorted_labels[0].get_has_value() + # Preferred labels come first, so they win within the same language + labels = list(resource.get_has_preferred_label()) + list(resource.get_has_label()) + selected = self._select_by_language(labels, language) + if selected: + return selected[0].get_has_value() # Final Fallback: The URI Identifier resource_id = resource.get_has_identifier() @@ -182,6 +197,7 @@ def _is_toc_entity(self, instance) -> bool: return self._has_local_triples(instance.get_has_identifier()) def get_toc_instances(self) -> List: + """Return the instances that get their own card and link (the browsable entities).""" return [i for i in self.get_all_instances() if self._is_toc_entity(i)] def _handle_single_resource(self, resource_uri: str, language: Optional[str] = None) -> Dict: @@ -269,8 +285,8 @@ def _format_entities(self, instances: List, language: Optional[str] = None) -> L # We use the helper to get clean text for each item formatted_values = [] if isinstance(value, list): - for v in value: - # Nested list (es. has_property_chain: lista di catene) + for v in self._select_by_language(value, language): + # Nested list (e.g. has_property_chain: list of chains) if isinstance(v, list): chain_dict = self._resolve_chain_value(v, language) if chain_dict and chain_dict['text']: @@ -414,7 +430,7 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None # 6. Extract the actual text values extracted_values = [] - for val in values: + for val in self._select_by_language(values, language): entry = self._resolve_resource_value(val, language) if entry['text']: # 7. Check if the link if an visualizable ontology or not @@ -440,7 +456,7 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None # 9. Statements data.update(self._format_statement(all_instances, ontology_model, language)) - # Ordine alfabetico su tutti i metadati (uri/label in testa) + # Alphabetical order on all the metadata (uri/label first) pinned = ['uri', 'label'] rest = sorted(k for k in data if k not in pinned) data = {k: data[k] for k in pinned if k in data} | {k: data[k] for k in rest} @@ -448,8 +464,11 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None return data def _resolve_chain_value(self, chain, language=None) -> dict: - """Renderizza una property chain ordinata (lista di Relation) come un - unico valore con parti cliccabili unite dall'operatore di composizione.""" + """Render an ordered property chain (a list of Relations) as a single value. + + The clickable parts are joined by the composition operator. Returns None + when no member has a displayable text. + """ members = [] for m in chain: md = self._resolve_resource_value(m, language) @@ -523,17 +542,6 @@ def _resolve_resource_value(self, obj, language=None) -> dict: lit_lang = obj.get_has_language() - # If a language is requested (e.g., 'pt') - if language: - target_lang = language.strip().lower() - - # If the literal has a tag, and it DOES NOT match 'pt', destroy it. - if lit_lang and not lit_lang.lower().startswith(target_lang): - return handler_dic - - # if not lit_lang: - # return handler_dic - handler_dic['text'] = obj.get_has_value() handler_dic['lan'] = lit_lang handler_dic['type'] = obj_type @@ -566,7 +574,7 @@ def _resolve_resource_value(self, obj, language=None) -> dict: if attr.startswith('_') or attr in skip or not value: continue values = value if isinstance(value, list) else [value] - for v in values: + for v in self._select_by_language(values, language): resolved = self._resolve_resource_value(v, language) if resolved.get('text') or resolved.get('nested'): siblings.append({ @@ -614,38 +622,41 @@ def _format_statement(self, instances, target_instance, language=None) -> Dict: if target_instance is None: return statements + # 2. Group the objects by predicate: the language is chosen per property + grouped = {} for instance in instances: - if isinstance(instance, Statement): - subj = instance.get_has_subject() - - # 2. Identity comparison (handles punning: same URI, different Python instances) - if subj is not target_instance: - continue - - predicate = instance.get_has_predicate() - obj = instance.get_has_object() - - if obj: - obj_data = self._resolve_resource_value(obj, language) - - # 3. Predicate Resolution - pred_label = self._get_best_label(predicate, language) if predicate else "Annotation" - - if pred_label not in statements: - statements[pred_label] = [] - - # 4. Resolve Object and Prevent Duplicates - if obj: - obj_data = self._resolve_resource_value(obj, language) - - - if obj_data not in statements[pred_label]: - statements[pred_label].append(obj_data) + if not isinstance(instance, Statement): + continue + # Identity comparison (handles punning: same URI, different Python instances) + if instance.get_has_subject() is not target_instance: + continue + obj = instance.get_has_object() + if not obj: + continue + predicate = instance.get_has_predicate() + pred_label = self._get_best_label(predicate, language) if predicate else "Annotation" + grouped.setdefault(pred_label, []).append(obj) + + # 3. Pick the language per property, resolve, drop empties and duplicates + for pred_label, objs in grouped.items(): + values = [] + for obj in self._select_by_language(objs, language): + obj_data = self._resolve_resource_value(obj, language) + if obj_data['text'] and obj_data not in values: + values.append(obj_data) + if values: + statements[pred_label] = values return statements @staticmethod def _clean_name(self, name: str) -> str: + """Turn an attribute or getter name into a display label. + + Drops the has_/get_ prefix, splits camelCase, replaces underscores with + spaces and lowercases. It is a staticmethod that still takes `self` as its + first argument, so callers pass it explicitly. + """ if not name: return "" name = re.sub(r'^(get_has_|get_|has_)', '', name) @@ -697,6 +708,7 @@ def _parse_restriction(self, obj, language=None, _seen=None) -> list: # Helper to safely call getter methods (e.g., get_applies_on_property) def _get(instance, prop_name, default=None): + """Read a property through its getter, or directly as an attribute, with a default for None.""" getter = f"get_{prop_name}" if hasattr(instance, getter): res = getattr(instance, getter)() @@ -829,6 +841,7 @@ def _is_likely_ontology(self, url: str) -> bool: host = parsed.hostname or '' def _host_is(domain: str) -> bool: + """True if the URL host is the domain or one of its subdomains.""" return host == domain or host.endswith('.' + domain) # 4. Common Ontology Registries & Permanent URLs @@ -856,21 +869,21 @@ def _host_is(domain: str) -> bool: return False - # Mappa class_key -> nome del getter del parent (None = no hierarchy) + # Map class_key -> name of the parent getter (missing = no hierarchy) _HIERARCHY_PARENT_GETTERS = { 'Concept': 'get_is_sub_concept_of', 'Relation': 'get_is_sub_property_of', 'Attribute': 'get_is_sub_property_of', 'Property': 'get_is_sub_property_of', - # Annotation, Individual: nessuna gerarchia -> non in mappa + # Annotation, Individual: no hierarchy -> not in the map } _FULL_IRI_ATTRS = {'is_defined_by'} def _build_hierarchy(self, instances, parent_getter_name, language=None): """ - Costruisce un albero (forest) di dict. - anchor_id usa lo stesso formato di _format_entities: id_{md5}_{ClassName} + Build a forest (list of trees) of dicts from the parent relation. + anchor_id uses the same format as _format_entities: id_{md5}_{ClassName} """ import hashlib @@ -897,6 +910,7 @@ def _build_hierarchy(self, instances, parent_getter_name, language=None): roots = [uri for uri in by_uri if uri not in has_internal_parent] def node_dict(uri, visited): + """Build the tree node of an entity with its children, stopping on cycles.""" if uri in visited: return None visited = visited | {uri} @@ -925,6 +939,10 @@ def node_dict(uri, visited): # ========== PROVENANCE: subgraph serialisation for each card ========== def _build_provenance_subgraph(self, instance) -> List[Dict[str, str]]: + """Serialize the triples an instance comes from, once per available format. + + Returns an empty list when the instance has no provenance. + """ sub = self.reader.get_provenance_subgraph(instance) if not any(True for _ in sub): return [] @@ -936,13 +954,17 @@ def _build_provenance_subgraph(self, instance) -> List[Dict[str, str]]: @staticmethod def _safe_serialize(g, fmt: str) -> str: + """Serialize a graph, returning a comment with the error instead of raising. + + One format that fails must not break the page that shows all of them. + """ try: return g.serialize(format=fmt) except Exception as e: return f"# serialization error ({fmt}): {e}" def export_resource(self, resource_uri: str, fmt: str = "turtle") -> str: - """Serializza il subgraph della singola risorsa (unione su punning).""" + """Serialize the subgraph of a single resource (the union of its punned instances).""" g = Graph() for prefix, ns in self.reader._graph.namespaces(): g.bind(prefix, ns) @@ -950,6 +972,6 @@ def export_resource(self, resource_uri: str, fmt: str = "turtle") -> str: if instances: inst_iter = instances if isinstance(instances, set) else [instances] for inst in inst_iter: - for t in self.reader.get_provenance_subgraph(inst): # giΓ  esiste + for t in self.reader.get_provenance_subgraph(inst): # already exists g.add(t) return self._safe_serialize(self._with_provenance(g), fmt) \ No newline at end of file diff --git a/lode/viewer/owl_viewer.py b/lode/viewer/owl_viewer.py index 320fd2d..db01eed 100644 --- a/lode/viewer/owl_viewer.py +++ b/lode/viewer/owl_viewer.py @@ -1,9 +1,9 @@ -# viewer/owl_viewer.py +"""Viewer for OWL artefacts.""" from typing import Dict, Optional from lode.viewer.base_viewer import BaseViewer class OwlViewer(BaseViewer): - """Viewer OWL""" + """OWL viewer""" TYPE_MAP = { 'concept': {'singular': 'Class', 'plural': 'Classes', 'abb': 'c'}, @@ -14,6 +14,7 @@ class OwlViewer(BaseViewer): } def get_toc_config(self): + """Return the table of contents: (class name, section id, TYPE_MAP key) in display order.""" return [ ('Concept', 'concepts', 'Concept'), ('Relation', 'relations', 'Relation'), @@ -24,8 +25,15 @@ def get_toc_config(self): def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: + """Build the template data for one resource, or for the whole ontology grouped by entity type. + + Both views also carry the ontology metadata and the TYPE_MAP of display names. + """ + + anguage = language.strip() if language else "en" + all_instances = self.get_all_instances() - metadata_dict = self._find_and_format_metadata(all_instances) + metadata_dict = self._find_and_format_metadata(all_instances, language) if resource_uri: data = super().get_view_data(resource_uri, language) diff --git a/lode/viewer/rdf_viewer.py b/lode/viewer/rdf_viewer.py index 7ddc7fa..34840a0 100644 --- a/lode/viewer/rdf_viewer.py +++ b/lode/viewer/rdf_viewer.py @@ -1,4 +1,4 @@ -# viewer/rdf_viewer.py +"""Viewer for RDF/RDFS artefacts.""" from typing import Dict, Optional from lode.viewer.base_viewer import BaseViewer @@ -15,6 +15,13 @@ class RdfViewer(BaseViewer): } def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: + """Build the template data for one resource, or for the vocabulary grouped by concepts, properties and containers. + + Both views also carry the vocabulary metadata and the TYPE_MAP of display names. + """ + + language = language.strip() if language else "en" + all_instances = self.get_all_instances() metadata_dict = self._find_and_format_metadata(all_instances, language) @@ -24,7 +31,7 @@ def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[s data['type_map'] = self.TYPE_MAP return data - # title in SINGOLARE per matchare le chiavi di TYPE_MAP + # title in SINGULAR to match the TYPE_MAP keys toc_config = [ ('Concept', 'concepts', 'Concept'), ('Property', 'properties', 'Property'), @@ -35,18 +42,4 @@ def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[s data['metadata'] = metadata_dict data['type_map'] = self.TYPE_MAP - # DEBUG β€” togliere dopo - print("=" * 60) - print(f"DEBUG RDF VIEWER") - print(f" grouped_view = {data.get('grouped_view')}") - print(f" sections count = {len(data.get('sections') or [])}") - for s in data.get('sections') or []: - print(f" [{s['id']}] title={s['title']!r} entities={len(s['entities'])}") - print(f" type_map keys = {list(data['type_map'].keys())}") - # Conta i tipi reali nel cache - from collections import Counter - types = Counter(type(i).__name__ for i in self.get_all_instances()) - print(f" instances by type = {dict(types)}") - print("=" * 60) - return data \ No newline at end of file diff --git a/lode/viewer/skos_viewer.py b/lode/viewer/skos_viewer.py index 5061635..f434aca 100644 --- a/lode/viewer/skos_viewer.py +++ b/lode/viewer/skos_viewer.py @@ -1,13 +1,14 @@ -# viewer/skos.py +"""Viewer for SKOS artefacts.""" import hashlib from typing import Dict, Optional, List from lode.viewer.base_viewer import BaseViewer from lode.helpers.rdf import iri_local_name class SkosViewer(BaseViewer): - """Viewer SKOS con visualizzazione LODE-style.""" + """SKOS viewer with LODE-style rendering.""" def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: + """Build the template data for one resource, or for the scheme grouped by collections and concepts.""" # 1. Single resource detail view if resource_uri: return super().get_view_data(resource_uri, language) @@ -22,7 +23,7 @@ def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[s return self._build_skos_grouped_view(toc_config, language) def _build_skos_grouped_view(self, group_definitions: List, language: Optional[str] = None) -> Dict: - """Costruisce la vista raggruppata con formattazione SKOS-style.""" + """Build the grouped view with SKOS-style formatting.""" all_instances = self.get_all_instances() sections = [] @@ -45,7 +46,7 @@ def _build_skos_grouped_view(self, group_definitions: List, language: Optional[s } def _format_skos_entities(self, instances: List, language: Optional[str] = None) -> List[Dict]: - """Formatta le entitΓ  SKOS in stile LODE.""" + """Format the SKOS entities in LODE style, sorted by label (or IRI when there is none).""" entities = [] for instance in instances: @@ -159,7 +160,7 @@ def _format_skos_entities(self, instances: List, language: Optional[str] = None) return entities def _format_concept_list(self, concepts, language: Optional[str]) -> List[Dict]: - """Formatta una lista di concetti con label e URI.""" + """Format a list of concepts with label and IRI; plain string IRIs are marked as external.""" items = [] concept_list = concepts if isinstance(concepts, (list, set)) else [concepts] @@ -182,22 +183,19 @@ def _format_concept_list(self, concepts, language: Optional[str]) -> List[Dict]: return items def _get_definition(self, instance, language: Optional[str]) -> Optional[str]: - """Estrae la definizione.""" + """Extract the definition text in the given language, or None when there is none.""" if hasattr(instance, 'has_definition') and instance.has_definition: return self._get_literal_value(instance.has_definition, language) return None def _get_literal_value(self, value, language: Optional[str]) -> Optional[str]: - """Estrae il valore stringa da un Literal o lista di Literal.""" + """Extract the string value of a Literal or of a list of Literals. + + The first literal in the best available language wins (see `_select_by_language`). + """ if isinstance(value, (set, list)): - if language: - for v in value: - if hasattr(v, 'get_has_language') and v.get_has_language() == language: - return v.get_has_value() if hasattr(v, 'get_has_value') else str(v) - for v in value: - if hasattr(v, 'get_has_value'): - return v.get_has_value() - return str(v) + for v in self._select_by_language(list(value), language): + return v.get_has_value() if hasattr(v, 'get_has_value') else str(v) elif hasattr(value, 'get_has_value'): return value.get_has_value() elif value: diff --git a/tests/test_owl_viewer.py b/tests/test_owl_viewer.py index c4925e0..b82aefd 100644 --- a/tests/test_owl_viewer.py +++ b/tests/test_owl_viewer.py @@ -292,6 +292,61 @@ def test_each_section_entity_is_toc_kind(self): assert ent["type"] in toc_keys +# --------------------------------------------------------------------------- +# Language selection: per property, with fallback +# --------------------------------------------------------------------------- + +def _lit(text, lang=None): + """Build a model Literal with an optional language tag.""" + lit = Literal() + lit.set_has_value(text) + if lang: + lit.set_has_language(lang) + return lit + + +class TestLanguageSelection: + """The language is chosen per property: requested, then 'en', then untagged, then all.""" + + @pytest.mark.parametrize("tags, requested, expected", [ + (["da", "en", "it"], "it", ["it"]), + (["da", "en"], "cs", ["en"]), + (["da", None], "cs", [None]), + (["da", "es"], "cs", ["da", "es"]), + (["en", None], "en", ["en"]), + (["en-GB", "en"], "en", ["en"]), + (["en", "en-GB"], "en-GB", ["en-GB"]), + (["en", "da"], "en-GB", ["en"]), + (["EN", "da"], "en", ["EN"]), + (["en-GB", "da"], "en", ["en-GB", "da"]), + ]) + def test_select_by_language(self, tags, requested, expected): + v = _viewer() + lits = [_lit(str(t), t) for t in tags] + picked = v._select_by_language(lits, requested) + assert [l.get_has_language() for l in picked] == expected + + def test_label_falls_back_to_en_not_alphabetical(self): + c = _entity(Concept, C) + for text, lang in (("Datatjeneste", "da"), ("Data service", "en"), ("Servizio", "it")): + c.set_has_label(_lit(text, lang)) + assert _viewer(c)._get_best_label(c, "cs") == "Data service" + + def test_metadata_has_only_requested_language_and_no_none(self): + onto = _entity(Model, ONTO) + title = _entity(Annotation, "http://purl.org/dc/terms/title", "title") + stmts = [] + for text, lang in (("Title", "en"), ("Titolo", "it")): + st = Statement() + st.set_has_subject(onto) + st.set_has_predicate(title) + st.set_has_object(_lit(text, lang)) + stmts.append(st) + v = _viewer(onto, title, *stmts) + texts = [e["text"] for e in v.get_view_data(language="en")["metadata"]["title"]] + assert texts == ["Title"] + + # =========================================================================== # Integration: real corpus, same loader pattern as test_owl_integrity. # =========================================================================== From 26b6678868f929f3c06f3d15bb6d93104f6621a3 Mon Sep 17 00:00:00 2001 From: Valentina Date: Thu, 15 Oct 2026 16:02:43 +0200 Subject: [PATCH 02/21] fix(publication): added docstrings in eng + replaced astro with mkdocs material --- .dockerignore | 4 +- .github/workflows/deploy-docs.yml | 17 +- .github/workflows/release.yml | 2 +- .gitignore | 8 +- Dockerfile | 2 +- LICENSE.md | 2 +- README.md | 21 +- docs/.gitignore | 21 - docs/AGENTS.md | 22 - docs/CLAUDE.md | 22 - docs/README.md | 49 - docs/{src/content/docs => }/api/endpoints.md | 5 +- docs/astro.config.mjs | 41 - .../{src/content/docs => }/getting_started.md | 18 +- docs/index.md | 24 + docs/package-lock.json | 5567 ----------------- docs/package.json | 17 - docs/public/favicon.svg | 1 - docs/src/assets/houston.webp | Bin 98506 -> 0 bytes docs/src/content.config.ts | 7 - docs/src/content/docs/guides/example.md | 11 - docs/src/content/docs/index.mdx | 34 - docs/src/content/docs/reference/example.md | 11 - docs/tsconfig.json | 5 - docs/{src/content/docs => }/usage/cli.md | 11 +- docs/{src/content/docs => }/usage/docker.md | 35 +- docs/{src/content/docs => }/usage/web_form.md | 7 +- lode/__init__.py | 3 +- lode/api.py | 77 +- lode/builder.py | 48 +- lode/cli.py | 28 +- lode/exceptions.py | 29 +- lode/helpers/__init__.py | 2 +- lode/helpers/html_utils.py | 1 + lode/models/__init__.py | 5 + lode/models/annotation.py | 3 + lode/models/atom.py | 22 +- lode/models/attribute.py | 5 +- lode/models/cardinality.py | 13 +- lode/models/collection.py | 11 +- lode/models/concept.py | 78 +- lode/models/container.py | 12 +- lode/models/datatype.py | 3 + lode/models/datatypeRestriction.py | 7 + lode/models/individual.py | 16 +- lode/models/literal.py | 17 +- lode/models/model.py | 33 +- lode/models/oneOf.py | 7 +- lode/models/property.py | 37 +- lode/models/propertyConceptRestriction.py | 16 +- lode/models/propertySelfRestriction.py | 5 + lode/models/quantifier.py | 12 +- lode/models/relation.py | 47 +- lode/models/resource.py | 143 +- lode/models/restriction.py | 3 + lode/models/rule.py | 22 +- lode/models/statement.py | 19 +- lode/models/truthFunction.py | 11 +- lode/models/value.py | 9 +- lode/models/variable.py | 4 + lode/reader/config/old_base.yaml | 599 -- lode/reader/config_manager.py | 94 +- lode/reader/loader.py | 47 +- lode/reader/logic/base_logic.py | 175 +- lode/reader/logic/owl_logic.py | 162 +- lode/reader/logic/rdf_logic.py | 21 +- lode/reader/logic/skos_logic.py | 44 +- lode/reader/modules.py | 69 +- lode/reader/provenance_graph.py | 29 +- lode/reader/reader.py | 243 +- lode/reader/warnings/owl_warnings.py | 13 + lode/viewer/__init__.py | 7 +- mkdocs.yml | 40 + pyproject.toml | 23 +- tests/data/provenance/main.ttl | 3 + tests/test_modules.py | 52 +- uv.lock | 97 +- 77 files changed, 1313 insertions(+), 7117 deletions(-) delete mode 100644 docs/.gitignore delete mode 100644 docs/AGENTS.md delete mode 100644 docs/CLAUDE.md delete mode 100644 docs/README.md rename docs/{src/content/docs => }/api/endpoints.md (95%) delete mode 100644 docs/astro.config.mjs rename docs/{src/content/docs => }/getting_started.md (61%) create mode 100644 docs/index.md delete mode 100644 docs/package-lock.json delete mode 100644 docs/package.json delete mode 100644 docs/public/favicon.svg delete mode 100644 docs/src/assets/houston.webp delete mode 100644 docs/src/content.config.ts delete mode 100644 docs/src/content/docs/guides/example.md delete mode 100644 docs/src/content/docs/index.mdx delete mode 100644 docs/src/content/docs/reference/example.md delete mode 100644 docs/tsconfig.json rename docs/{src/content/docs => }/usage/cli.md (82%) rename docs/{src/content/docs => }/usage/docker.md (66%) rename docs/{src/content/docs => }/usage/web_form.md (86%) delete mode 100644 lode/reader/config/old_base.yaml create mode 100644 mkdocs.yml diff --git a/.dockerignore b/.dockerignore index 5509ba4..4d5b6c5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,9 +24,7 @@ htmlcov/ .idea/ # Docs site build artifacts -docs/node_modules/ -docs/.astro/ -docs/dist/ +site/ # Runtime spool cache **/spool/ diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cddd0ed..9ab55cb 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'docs/**' + - 'mkdocs.yml' workflow_run: workflows: ["Run tests"] types: [completed] @@ -30,7 +31,7 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-report - path: docs/public/coverage + path: docs/coverage github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} @@ -39,13 +40,21 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-badge - path: docs/public/coverage + path: docs/coverage github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} - - uses: withastro/action@v5 + - uses: actions/setup-python@v5 with: - path: ./docs + python-version: "3.13" + + - run: pip install mkdocs-material "mkdocs<2" + + - run: mkdocs build --strict + + - uses: actions/upload-pages-artifact@v3 + with: + path: site deploy: needs: build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e8d7b6..aa4d359 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,4 +54,4 @@ jobs: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} run: | uv build - uv publish --token $UV_PUBLISH_TOKEN + uv publish --check-url https://pypi.org/simple/ --token $UV_PUBLISH_TOKEN diff --git a/.gitignore b/.gitignore index 2b28c9e..793c3eb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,11 @@ htmlcov/ .idea/ # Starlight/Astro -docs/node_modules/ -docs/.astro/ -docs/dist/ +site/ # saved files spool/ + +# claude +CLAUDE.md +.claude/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index af5ab1d..9bc2345 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ USER appuser # Install dependencies first for better layer caching (frozen = exact lockfile) COPY --chown=appuser:appuser pyproject.toml uv.lock README.md ./ -RUN uv sync --frozen --no-dev --no-install-project +RUN uv sync --frozen --no-dev --no-install-project --extra server # Application code COPY --chown=appuser:appuser . . diff --git a/LICENSE.md b/LICENSE.md index d764181..8e3cb7f 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ ISC License -Copyright (c) 2025 Valentina Pasqual, Sivio Peroni +Copyright (c) 2025 Valentina Pasqual, Silvio Peroni Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/README.md b/README.md index 917f824..4915a17 100644 --- a/README.md +++ b/README.md @@ -9,19 +9,22 @@ LODE 2.0 is an open source service that automatically extracts entities from sem ## Installation ```bash -pip install lode -``` - -## Usage - -```python -from lode import example_function +pip install oc-lode -result = example_function() -print(result) +# with the web service (`lode serve`) +pip install "oc-lode[server]" ``` + ## Usage + +```bash +# Build static documentation from an ontology URL or a local file +lode build --url --read-as [--out ./docs] [--lang en] [--imported] [--closure] +lode build --file --read-as [--out ./docs] +# Run the web service (requires the [server] extra) +lode serve --port 8000 +``` ## Documentation diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 6240da8..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1,21 +0,0 @@ -# build output -dist/ -# generated types -.astro/ - -# dependencies -node_modules/ - -# logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - - -# environment variables -.env -.env.production - -# macOS-specific files -.DS_Store diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index c5ff429..0000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,22 +0,0 @@ -## Development - -When starting the dev server, use background mode: - -``` -astro dev --background -``` - -Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`. - -## Documentation - -Full documentation: https://docs.astro.build - -Consult these guides before working on related tasks: - -- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/) -- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/) -- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/) -- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/) -- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/) -- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/) diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 100644 index c5ff429..0000000 --- a/docs/CLAUDE.md +++ /dev/null @@ -1,22 +0,0 @@ -## Development - -When starting the dev server, use background mode: - -``` -astro dev --background -``` - -Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`. - -## Documentation - -Full documentation: https://docs.astro.build - -Consult these guides before working on related tasks: - -- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/) -- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/) -- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/) -- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/) -- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/) -- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 1b7f5c3..0000000 --- a/docs/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Starlight Starter Kit: Basics - -[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) - -``` -npm create astro@latest -- --template starlight -``` - -> πŸ§‘β€πŸš€ **Seasoned astronaut?** Delete this file. Have fun! - -## πŸš€ Project Structure - -Inside of your Astro + Starlight project, you'll see the following folders and files: - -``` -. -β”œβ”€β”€ public/ -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ assets/ -β”‚ β”œβ”€β”€ content/ -β”‚ β”‚ └── docs/ -β”‚ └── content.config.ts -β”œβ”€β”€ astro.config.mjs -β”œβ”€β”€ package.json -└── tsconfig.json -``` - -Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. - -Images can be added to `src/assets/` and embedded in Markdown with a relative link. - -Static assets, like favicons, can be placed in the `public/` directory. - -## 🧞 Commands - -All commands are run from the root of the project, from a terminal: - -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:4321` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | - -## πŸ‘€ Want to learn more? - -Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). diff --git a/docs/src/content/docs/api/endpoints.md b/docs/api/endpoints.md similarity index 95% rename from docs/src/content/docs/api/endpoints.md rename to docs/api/endpoints.md index 36bc33f..44baec1 100644 --- a/docs/src/content/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -1,7 +1,4 @@ ---- -title: API Endpoints -description: LODE REST API reference ---- +# API endpoints Interactive docs available at `/docs` (Swagger) and `/redoc` (ReDoc) when the server is running. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs deleted file mode 100644 index a59d492..0000000 --- a/docs/astro.config.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { defineConfig } from 'astro/config'; -import starlight from '@astrojs/starlight'; - -export default defineConfig({ - site: 'https://opencitations.github.io', - base: '/lode', - - integrations: [ - starlight({ - title: 'LODE 2.0', - description: 'Live OWL Documentation Environment β€” extract and document RDF/OWL/SKOS semantic artefacts as browsable HTML.', - - social: [ - { icon: 'github', label: 'GitHub', href: 'https://github.com/opencitations/lode' }, - ], - - sidebar: [ - { - label: 'Getting Started', - items: [ - { label: 'Installation', slug: 'getting_started' }, - ], - }, - { - label: 'Usage', - items: [ - { label: 'Web Form', slug: 'usage/web_form' }, - { label: 'CLI', slug: 'usage/cli' }, - { label: 'Docker', slug: 'usage/docker' }, - ], - }, - { - label: 'API Reference', - items: [ - { label: 'Endpoints', slug: 'api/endpoints' }, - ], - }, - ], - }), - ], -}); \ No newline at end of file diff --git a/docs/src/content/docs/getting_started.md b/docs/getting_started.md similarity index 61% rename from docs/src/content/docs/getting_started.md rename to docs/getting_started.md index 673ef03..8f38258 100644 --- a/docs/src/content/docs/getting_started.md +++ b/docs/getting_started.md @@ -1,7 +1,4 @@ ---- -title: Getting Started -description: Install and run LODE 2.0 ---- +# Getting started ## Requirements @@ -11,12 +8,15 @@ description: Install and run LODE 2.0 ## Installation ```bash -pip install lode2 +pip install oc-lode + +# with the web service (`lode serve`) +pip install "oc-lode[server]" ``` ## First run -Start the web server: +Start the web server (requires the `[server]` extra): ```bash lode serve --port 8000 @@ -27,7 +27,7 @@ Open `http://localhost:8000` in your browser, paste an ontology URL and click ** Or generate a static site from a local file: ```bash -lode build --file my_ontology.ttl --read-as owl --out ./docs +lode build --file my_ontology.ttl --read-as owl --out ./site ``` ## Development install @@ -36,5 +36,5 @@ lode build --file my_ontology.ttl --read-as owl --out ./docs git clone https://github.com/opencitations/lode.git cd lode uv sync --all-extras --dev -uvicorn lode.api:app --reload --port 8000 -``` \ No newline at end of file +uv run python main.py +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..040258b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,24 @@ +# LODE 2.0 + +Live OWL Documentation Environment β€” extract and document semantic artefacts as browsable, interlinked HTML. Maintained by [OpenCitations](https://opencitations.net). + +[Get started](getting_started.md){ .md-button .md-button--primary } +[View on GitHub](https://github.com/opencitations/lode){ .md-button } + +## Features + +- Extracts and documents OWL classes, properties, individuals, restrictions and more +- Renders browsable HTML with embedded navigation links +- Serves a dynamic web interface and REST API +- Generates self-contained static sites from the CLI +- Content negotiation: returns RDF serializations on request +- Docker-ready with configurable storage via environment variables + +## Quick start + +```bash +pip install "oc-lode[server]" +lode serve --port 8000 +``` + +Open `http://localhost:8000` to use the web form, or `http://localhost:8000/docs` for the interactive API. diff --git a/docs/package-lock.json b/docs/package-lock.json deleted file mode 100644 index fb4256a..0000000 --- a/docs/package-lock.json +++ /dev/null @@ -1,5567 +0,0 @@ -{ - "name": "docs", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "docs", - "version": "0.0.1", - "dependencies": { - "@astrojs/starlight": "^0.42.3", - "astro": "^7.2.10", - "sharp": "^0.35.3" - } - }, - "node_modules/@astrojs/compiler-binding": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.0.tgz", - "integrity": "sha512-PxFTx3WoxQnbgUvvwmF6LRQwhHO77a4pewOtJvzxlrYtQ90tLgomLKvks/akrd2iix2ngGlOhxQonKNd8YoBHg==", - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@astrojs/compiler-binding-android-arm64": "0.5.0", - "@astrojs/compiler-binding-darwin-arm64": "0.5.0", - "@astrojs/compiler-binding-darwin-x64": "0.5.0", - "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.0", - "@astrojs/compiler-binding-linux-arm64-musl": "0.5.0", - "@astrojs/compiler-binding-linux-x64-gnu": "0.5.0", - "@astrojs/compiler-binding-linux-x64-musl": "0.5.0", - "@astrojs/compiler-binding-wasm32-wasi": "0.5.0", - "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.0", - "@astrojs/compiler-binding-win32-x64-msvc": "0.5.0" - } - }, - "node_modules/@astrojs/compiler-binding-android-arm64": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.5.0.tgz", - "integrity": "sha512-wVviC/1KaBqGVF0DbTZ8UtAE3c8TN84CCe7w4ju3SGzo/ByWKn8HCqSU/+1wdltGCCSa30pjVk3H8DQUgnfhRQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-darwin-arm64": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.5.0.tgz", - "integrity": "sha512-5NNIkaGeiztD9QQfhOy+nNrBCZVeHqVZPlfJVjTKq4yJiiSsxAhuBzmVlmK3BDhbSgUT8dai4JV5m6t4/BIQaw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-darwin-x64": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.5.0.tgz", - "integrity": "sha512-HU/waaccOQDXpvjoWA/9xzadNZIcWKblJoaw19lCxfVs6AHQYdZZOzZUQjY8HaLYGerTXjdADbra2XU+kXk9Og==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.5.0.tgz", - "integrity": "sha512-a21petBJRi0mNBf+gkRZFoWdGH+VOo0zRQSzM4Nuez5RL5rFFkmbondMejkaRZzvE3LVYU2NVD5MGRgAs4V+nQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.5.0.tgz", - "integrity": "sha512-t5rmu23b/6e6v0bhPibCtwPdABXMRL+bUaJXdkJwTpkINp4zCgH6fNa5Z6+RgkgFSCpmg9uf1MtRIqx1gdmBaA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.5.0.tgz", - "integrity": "sha512-JWtouuxSiDb2OKKFsxOwfqlpJuZHo1ExirlBGfDgoJVG5zeJWwcd2tU2w4UaJA4suT/tAFSLk/vFhM5fLjWV4A==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-linux-x64-musl": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.5.0.tgz", - "integrity": "sha512-LlaSO5v3UC6IxuTKXDViWV/fFHYhLKSO9Of118lNBXv/sjIWwziU+jjENxPgxfq/4oUeOu+613y6H93LBPNTOA==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.5.0.tgz", - "integrity": "sha512-IP+Cp0m9yKg/AUT47077vHEaAOh/oT/iUZx1PDnV2GJzUxLSEotdQy5Hhv+ycvEadO/DbETSJfAWeV7kte4etQ==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.3", - "@emnapi/runtime": "1.11.3", - "@napi-rs/wasm-runtime": "^1.2.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.5.0.tgz", - "integrity": "sha512-v6plwy5GQBGvfv4QgiGhy/qkkQvYKW7Zh2vBArOnsGEDNHyUS43CWYwIQdw4ZdVVmLsFyhwTiueDZEP6zejddQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.5.0.tgz", - "integrity": "sha512-SgM158cSCJ248UIXaCCW2WqSTjOoFlDv93ed/ATguJqYpZNNVMlOdcPlgNH7WYj/OYeD0rIQr7XyRAYhB7+G1A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@astrojs/compiler-rs": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.5.0.tgz", - "integrity": "sha512-2xIypn5R33FRWCXXXv0sfbDj8xcOvc2VmIbteytxMjOzP9LiGfvqaAFJjhdAjzhs6+ZKzYzbSE79rrPObYW5mg==", - "license": "MIT", - "dependencies": { - "@astrojs/compiler-binding": "0.5.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@astrojs/internal-helpers": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.11.0.tgz", - "integrity": "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.4", - "@types/mdast": "^4.0.4", - "js-yaml": "^4.3.0", - "picomatch": "^4.0.4", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "unified": "^11.0.5" - } - }, - "node_modules/@astrojs/markdown-satteri": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.2.tgz", - "integrity": "sha512-g4QehXnB/MzxQWnSf17NciFInMqEJy9948p8cc7zGg/r7995m0Ln5PMr3Xlnj24iey1LchaoZ3SzTOa3D4P/3w==", - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "satteri": "^0.10.3" - } - }, - "node_modules/@astrojs/mdx": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-8.0.2.tgz", - "integrity": "sha512-WedIVP2Bu8fXSzxb3ZYZ8xDrWWjf2SRKCneh52Llhoa+O3lcpHPhoZVP3FerlivUaH/0LJz2yVaCNvMdg0u8Bw==", - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/markdown-satteri": "0.4.2", - "es-module-lexer": "^2.0.0" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0", - "@astrojs/markdown-satteri": "^0.4.0", - "astro": "^7.2.10" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/@astrojs/prism": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", - "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", - "license": "MIT", - "dependencies": { - "prismjs": "^1.30.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@astrojs/sitemap": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz", - "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==", - "license": "MIT", - "dependencies": { - "sitemap": "^9.0.0", - "zod": "^4.3.6" - } - }, - "node_modules/@astrojs/starlight": { - "version": "0.42.3", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.3.tgz", - "integrity": "sha512-eX0KI2jeNWkI42pJNA2YpStfdNZ/NRBp6H9/Ui/zmu7qJ66yHV4qdgW8SfsCNt2QMUMgvm8auUNOsE4aD68RUQ==", - "license": "MIT", - "dependencies": { - "@astrojs/markdown-satteri": "^0.4.0", - "@astrojs/mdx": "^8.0.0", - "@astrojs/sitemap": "^3.7.3", - "@pagefind/default-ui": "^1.3.0", - "@types/hast": "^3.0.5", - "@types/js-yaml": "^4.0.9", - "@types/mdast": "^4.0.4", - "astro-expressive-code": "^0.44.0", - "bcp-47": "^2.1.0", - "hast-util-format": "^1.1.0", - "hast-util-select": "^6.0.4", - "hast-util-to-html": "^9.0.5", - "hast-util-to-string": "^3.0.1", - "hastscript": "^9.0.1", - "i18next": "^26.0.7", - "js-yaml": "^4.1.1", - "klona": "^2.0.6", - "magic-string": "^1.2.3", - "mdast-util-directive": "^3.1.0", - "mdast-util-to-markdown": "^2.1.2", - "mdast-util-to-string": "^4.0.0", - "pagefind": "^1.5.2", - "remark-directive": "^4.0.0", - "satteri": "^0.10.3", - "ultrahtml": "^1.6.0", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0", - "vfile": "^6.0.3" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0", - "astro": "^7.2.10" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/@astrojs/telemetry": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", - "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", - "license": "MIT", - "dependencies": { - "ci-info": "^4.4.0", - "dset": "^3.1.4", - "is-docker": "^4.0.0", - "package-manager-detector": "^1.6.0" - }, - "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bruits/satteri-darwin-arm64": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", - "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@bruits/satteri-darwin-x64": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", - "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@bruits/satteri-linux-arm64-gnu": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", - "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-arm64-musl": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", - "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-x64-gnu": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", - "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-linux-x64-musl": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", - "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@bruits/satteri-wasm32-wasi": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", - "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.2.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@bruits/satteri-win32-arm64-msvc": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", - "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@bruits/satteri-win32-x64-msvc": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", - "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@capsizecss/unpack": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", - "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", - "license": "MIT", - "dependencies": { - "fontkitten": "^1.0.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@clack/core": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", - "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", - "license": "MIT", - "dependencies": { - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, - "node_modules/@clack/prompts": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", - "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", - "license": "MIT", - "dependencies": { - "@clack/core": "1.5.1", - "fast-string-width": "^3.0.2", - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, - "node_modules/@ctrl/tinycolor": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz", - "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@expressive-code/core": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.2.tgz", - "integrity": "sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==", - "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "^4.0.4", - "hast-util-select": "^6.0.2", - "hast-util-to-html": "^9.0.1", - "hast-util-to-text": "^4.0.1", - "hastscript": "^9.0.0", - "postcss": "^8.4.38", - "postcss-nested": "^6.0.1", - "unist-util-visit": "^5.0.0", - "unist-util-visit-parents": "^6.0.1" - } - }, - "node_modules/@expressive-code/plugin-frames": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.2.tgz", - "integrity": "sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==", - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2" - } - }, - "node_modules/@expressive-code/plugin-shiki": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.2.tgz", - "integrity": "sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==", - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2", - "shiki": "^4.0.2" - } - }, - "node_modules/@expressive-code/plugin-text-markers": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.2.tgz", - "integrity": "sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==", - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.4" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" - } - }, - "node_modules/@oslojs/encoding": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", - "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", - "license": "MIT" - }, - "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@pagefind/darwin-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", - "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@pagefind/darwin-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", - "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@pagefind/default-ui": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.5.2.tgz", - "integrity": "sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==", - "license": "MIT" - }, - "node_modules/@pagefind/freebsd-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", - "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@pagefind/linux-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", - "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@pagefind/linux-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", - "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@pagefind/windows-arm64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", - "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@pagefind/windows-x64": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", - "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.10.tgz", - "integrity": "sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.10.tgz", - "integrity": "sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.10.tgz", - "integrity": "sha512-UbEfXq/AqGNgRTV3ik+X/iR6mUxu2QdYAadwRxJWquUGnW6gDqdP1FtLtFXRow7RJx0ssRwi80XAPr4r+4DtsA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.10.tgz", - "integrity": "sha512-7f5h17q5KZVx/ji1vb8OTq31ch1O2I7K8NPIr44GkyWTApXMIsmhWqZfgpOH10xeauqghDAvGlZktasCkcF6Eg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.10.tgz", - "integrity": "sha512-ynOk/eEYhC6ZB2xCGvKrEOwE58oBy9LnrAqtkrDF9Fz1VTaNdGZTsV0VarJdhPwb+sOJTGjCLwcuyRJZ1dnMcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.10.tgz", - "integrity": "sha512-ERrAs185meZZhGan7a4l3RiiJK1ArSDlHdST++uvSxe+FDbR4TwUPahT/cbZJvaG6fIpDpF78surN+tX708Y4Q==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.10.tgz", - "integrity": "sha512-KN7OHKD0J3jy1UzBwZWPxpwhODf9IARUIJcrH+yLYKOcmegZ8luEUM38lDP1bDVj40yP6PsSzCqOJF76vljFnQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.10.tgz", - "integrity": "sha512-8l9wP8O+wa8zD6iw6egSfzVtu7oZVfH3hlUsMM4MwbLMhxleqeoXbZzjddyK3YyNlwLhqznq3tF7PkNJ8T/V2w==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.10.tgz", - "integrity": "sha512-SeXNKeQzA5kLhz/J0CH6ZP0/HJ3v1xm/0YbiYpE0kK7emfRC2OIGGIaE14xzkISEGv2aYuUSpiLiU5Gbq+OI0A==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.10.tgz", - "integrity": "sha512-mtht0nR+y8/hart4175Ll15w7lY8dg7CtQ+j2FDNTsDRspOWTK/2V3l0aj9sIj7XmvqxT8Yli/wq22e7feTTWg==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.10.tgz", - "integrity": "sha512-FSM94nGd55NYo48usCyM/nHfUKRnqc9+b0vJNuKV0oCCpIp/OGims7rO1Nv/DkFkt0S/s2rxsJ2kkS8J3HcpeA==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.10.tgz", - "integrity": "sha512-C3YxNB16myRLs7o+B+6PnQ6jBsdIS4+AE4Ah8glVGhDpEv9AOvxhZ/1duAb4B0UGczEK/lBbccksd8VI+p6zfw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.10.tgz", - "integrity": "sha512-571TlE/F1eeTjjdjYAMMMPs1Mfv3MtX6s3+ZKVU6HiUjZ5Njc6c/qzNy/8K3zALTZnaw3JQVYrHxvNfjm43KAg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.10.tgz", - "integrity": "sha512-QXW+ZWaiqs2c7Fi++D/SsW07LTPcUrncxcskJGfGNBoaLik1IU6fJymz4HsqwEO0u5Iq11yTO0B/mc4cPk7jrQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.10.tgz", - "integrity": "sha512-5FQFGgah17YeMtG1Yd5a+rMxQpTksyNXxRtKz06FVTaQw3RKYUJQbUoKk0/5jrXBpDo+7makNP7UHA2LQyH64A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "license": "MIT" - }, - "node_modules/@shikijs/core": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", - "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", - "license": "MIT", - "dependencies": { - "@shikijs/primitive": "4.4.3", - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5", - "hast-util-to-html": "^9.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", - "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", - "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/langs": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", - "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/primitive": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", - "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/themes": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", - "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/types": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", - "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", - "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/nlcst": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", - "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/node": { - "version": "24.13.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", - "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/sax": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", - "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", - "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", - "license": "ISC" - }, - "node_modules/am-i-vibing": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", - "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", - "license": "MIT", - "dependencies": { - "process-ancestry": "^0.1.0" - }, - "bin": { - "am-i-vibing": "dist/cli.mjs" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/astro": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.3.5.tgz", - "integrity": "sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg==", - "license": "MIT", - "dependencies": { - "@astrojs/compiler-rs": "^0.5.0", - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/markdown-satteri": "0.4.2", - "@astrojs/telemetry": "3.3.3", - "@capsizecss/unpack": "^4.0.0", - "@clack/prompts": "^1.1.0", - "@oslojs/encoding": "^1.1.0", - "am-i-vibing": "^0.4.0", - "aria-query": "^5.3.2", - "axobject-query": "^4.1.0", - "ci-info": "^4.4.0", - "clsx": "^2.1.1", - "common-ancestor-path": "^2.0.0", - "cookie": "^2.0.1", - "devalue": "^5.8.1", - "diff": "^9.0.0", - "dset": "^3.1.4", - "es-module-lexer": "^2.0.0", - "esbuild": "^0.28.0", - "find-proc": "0.2.0", - "flattie": "^1.1.1", - "fontace": "~0.4.1", - "get-tsconfig": "5.0.0-beta.4", - "github-slugger": "^2.0.0", - "html-escaper": "3.0.3", - "http-cache-semantics": "^4.2.0", - "js-yaml": "^4.3.2", - "jsonc-parser": "^3.3.1", - "magic-string": "^1.0.0", - "magicast": "^0.5.2", - "mrmime": "^2.0.1", - "neotraverse": "^1.0.1", - "obug": "^3.0.0", - "p-limit": "^7.3.0", - "p-queue": "^9.1.0", - "package-manager-detector": "^1.6.0", - "piccolore": "^0.1.3", - "picomatch": "^4.0.4", - "shiki": "^4.0.2", - "smol-toml": "^1.8.0", - "svgo": "^4.1.0", - "tinyclip": "^1.0.1", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15", - "ultrahtml": "^1.6.0", - "unifont": "~0.7.5", - "unstorage": "^1.17.5", - "verkit": "^0.4.0", - "vite": "^8.0.13", - "vitefu": "^1.1.2", - "xxhash-wasm": "^1.1.0", - "yargs-parser": "^22.0.0", - "zod": "^4.5.4" - }, - "bin": { - "astro": "bin/astro.mjs" - }, - "engines": { - "node": ">=22.12.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/astrodotbuild" - }, - "optionalDependencies": { - "sharp": "^0.35.4" - }, - "peerDependencies": { - "@astrojs/markdown-remark": "^7.3.0" - }, - "peerDependenciesMeta": { - "@astrojs/markdown-remark": { - "optional": true - } - } - }, - "node_modules/astro-expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.2.tgz", - "integrity": "sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==", - "license": "MIT", - "dependencies": { - "rehype-expressive-code": "^0.44.2", - "url-extras": "^0.1.0" - }, - "peerDependencies": { - "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/bcp-47": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz", - "integrity": "sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/bcp-47-match": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", - "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "license": "ISC" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/common-ancestor-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", - "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">= 18" - } - }, - "node_modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cookie-es": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", - "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", - "license": "MIT" - }, - "node_modules/crossws": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", - "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", - "license": "MIT", - "dependencies": { - "uncrypto": "^0.1.3" - } - }, - "node_modules/css-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-selector-parser": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", - "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "license": "MIT", - "dependencies": { - "css-tree": "~2.2.0" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "license": "CC0-1.0" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.9.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", - "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/diff": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", - "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/direction": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", - "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", - "license": "MIT", - "bin": { - "direction": "cli.js" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dset": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", - "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.2.tgz", - "integrity": "sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==", - "license": "MIT", - "dependencies": { - "@expressive-code/core": "^0.44.2", - "@expressive-code/plugin-frames": "^0.44.2", - "@expressive-code/plugin-shiki": "^0.44.2", - "@expressive-code/plugin-text-markers": "^0.44.2" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/find-proc": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.2.0.tgz", - "integrity": "sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==", - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/flattie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", - "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/fontace": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", - "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", - "license": "MIT", - "dependencies": { - "fontkitten": "^1.0.2" - } - }, - "node_modules/fontkitten": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", - "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", - "license": "MIT", - "dependencies": { - "tiny-inflate": "^1.0.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "5.0.0-beta.4", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", - "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "engines": { - "node": ">=20.20.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", - "license": "ISC" - }, - "node_modules/h3": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", - "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", - "license": "MIT", - "dependencies": { - "cookie-es": "^1.2.3", - "crossws": "^0.3.5", - "defu": "^6.1.6", - "destr": "^2.0.5", - "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.4", - "radix3": "^1.1.2", - "ufo": "^1.6.3", - "uncrypto": "^0.1.3" - } - }, - "node_modules/hast-util-embedded": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", - "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-is-element": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-format": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", - "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-minify-whitespace": "^1.0.0", - "hast-util-phrasing": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "html-whitespace-sensitive-tag-names": "^3.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-has-property": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", - "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-body-ok-link": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", - "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-minify-whitespace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", - "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-phrasing": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", - "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-embedded": "^3.0.0", - "hast-util-has-property": "^3.0.0", - "hast-util-is-body-ok-link": "^3.0.0", - "hast-util-is-element": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-select": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", - "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "bcp-47-match": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "css-selector-parser": "^3.0.0", - "devlop": "^1.0.0", - "direction": "^2.0.0", - "hast-util-has-property": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "nth-check": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html-escaper": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", - "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/html-whitespace-sensitive-tag-names": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", - "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause" - }, - "node_modules/i18next": { - "version": "26.4.2", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", - "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", - "funding": [ - { - "type": "individual", - "url": "https://www.locize.com/i18next" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - }, - { - "type": "individual", - "url": "https://www.locize.com" - } - ], - "license": "MIT", - "peerDependencies": { - "typescript": "^5 || ^6 || ^7" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/iron-webcrypto": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", - "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/brc-dd" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-docker": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", - "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "license": "MIT" - }, - "node_modules/klona": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", - "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/magicast": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", - "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "source-map-js": "^1.2.1" - } - }, - "node_modules/mdast-util-directive": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", - "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "license": "CC0-1.0" - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-directive": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", - "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neotraverse": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", - "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/nlcst-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", - "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT" - }, - "node_modules/node-mock-http": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", - "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/obug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", - "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/ofetch": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", - "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", - "license": "MIT", - "dependencies": { - "destr": "^2.0.5", - "node-fetch-native": "^1.6.7", - "ufo": "^1.6.1" - } - }, - "node_modules/ohash": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", - "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "license": "MIT" - }, - "node_modules/oniguruma-parser": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", - "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", - "license": "MIT" - }, - "node_modules/oniguruma-to-es": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", - "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", - "license": "MIT", - "dependencies": { - "oniguruma-parser": "^0.12.2", - "regex": "^6.1.0", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/p-limit": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", - "integrity": "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.2.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", - "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.4", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", - "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-manager-detector": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", - "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", - "license": "MIT" - }, - "node_modules/pagefind": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", - "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", - "license": "MIT", - "bin": { - "pagefind": "lib/runner/bin.cjs" - }, - "optionalDependencies": { - "@pagefind/darwin-arm64": "1.5.2", - "@pagefind/darwin-x64": "1.5.2", - "@pagefind/freebsd-x64": "1.5.2", - "@pagefind/linux-arm64": "1.5.2", - "@pagefind/linux-x64": "1.5.2", - "@pagefind/windows-arm64": "1.5.2", - "@pagefind/windows-x64": "1.5.2" - } - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/piccolore": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", - "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", - "license": "ISC" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/process-ancestry": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", - "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/radix3": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", - "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "license": "MIT" - }, - "node_modules/rehype-expressive-code": { - "version": "0.44.2", - "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.2.tgz", - "integrity": "sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==", - "license": "MIT", - "dependencies": { - "expressive-code": "^0.44.2" - } - }, - "node_modules/remark-directive": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", - "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/retext-smartypants": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", - "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "nlcst-to-string": "^4.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rolldown": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.10.tgz", - "integrity": "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog==", - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.151.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.10", - "@rolldown/binding-android-arm64": "1.2.10", - "@rolldown/binding-darwin-arm64": "1.2.10", - "@rolldown/binding-darwin-x64": "1.2.10", - "@rolldown/binding-freebsd-x64": "1.2.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", - "@rolldown/binding-linux-arm64-gnu": "1.2.10", - "@rolldown/binding-linux-arm64-musl": "1.2.10", - "@rolldown/binding-linux-ppc64-gnu": "1.2.10", - "@rolldown/binding-linux-s390x-gnu": "1.2.10", - "@rolldown/binding-linux-x64-gnu": "1.2.10", - "@rolldown/binding-linux-x64-musl": "1.2.10", - "@rolldown/binding-openharmony-arm64": "1.2.10", - "@rolldown/binding-win32-arm64-msvc": "1.2.10", - "@rolldown/binding-win32-x64-msvc": "1.2.10" - } - }, - "node_modules/satteri": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", - "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.5", - "@types/hast": "^3.0.5", - "@types/mdast": "^4.0.4", - "@types/unist": "^3.0.3" - }, - "optionalDependencies": { - "@bruits/satteri-darwin-arm64": "0.10.5", - "@bruits/satteri-darwin-x64": "0.10.5", - "@bruits/satteri-linux-arm64-gnu": "0.10.5", - "@bruits/satteri-linux-arm64-musl": "0.10.5", - "@bruits/satteri-linux-x64-gnu": "0.10.5", - "@bruits/satteri-linux-x64-musl": "0.10.5", - "@bruits/satteri-wasm32-wasi": "0.10.5", - "@bruits/satteri-win32-arm64-msvc": "0.10.5", - "@bruits/satteri-win32-x64-msvc": "0.10.5" - } - }, - "node_modules/sax": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", - "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/shiki": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", - "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.4.3", - "@shikijs/engine-javascript": "4.4.3", - "@shikijs/engine-oniguruma": "4.4.3", - "@shikijs/langs": "4.4.3", - "@shikijs/themes": "4.4.3", - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, - "node_modules/sitemap": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", - "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", - "license": "MIT", - "dependencies": { - "@types/node": "^24.9.2", - "@types/sax": "^1.2.1", - "arg": "^5.0.0", - "sax": "^1.4.1" - }, - "bin": { - "sitemap": "dist/esm/cli.js" - }, - "engines": { - "node": ">=20.19.5", - "npm": ">=10.8.2" - } - }, - "node_modules/smol-toml": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", - "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/svgo": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", - "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", - "license": "MIT", - "dependencies": { - "commander": "^11.1.0", - "css-select": "^6.0.0", - "css-tree": "^3.0.1", - "css-what": "^7.0.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "1.6.1" - }, - "bin": { - "svgo": "bin/svgo.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/tiny-inflate": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", - "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", - "license": "MIT" - }, - "node_modules/tinyclip": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-1.0.3.tgz", - "integrity": "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==", - "license": "MIT", - "engines": { - "node": "^16.14.0 || >= 17.3.0" - } - }, - "node_modules/tinyexec": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "license": "MIT" - }, - "node_modules/ultrahtml": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", - "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", - "license": "MIT" - }, - "node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, - "node_modules/undici": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", - "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unifont": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", - "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", - "license": "MIT", - "dependencies": { - "css-tree": "^3.1.0", - "ohash": "^2.0.11", - "undici": "^8.0.0" - } - }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unstorage": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", - "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", - "license": "MIT", - "dependencies": { - "anymatch": "^3.1.3", - "chokidar": "^5.0.0", - "destr": "^2.0.5", - "h3": "^1.15.10", - "lru-cache": "^11.2.7", - "node-fetch-native": "^1.6.7", - "ofetch": "^1.5.1", - "ufo": "^1.6.3" - }, - "peerDependencies": { - "@azure/app-configuration": "^1.8.0", - "@azure/cosmos": "^4.2.0", - "@azure/data-tables": "^13.3.0", - "@azure/identity": "^4.6.0", - "@azure/keyvault-secrets": "^4.9.0", - "@azure/storage-blob": "^12.26.0", - "@capacitor/preferences": "^6 || ^7 || ^8", - "@deno/kv": ">=0.9.0", - "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", - "@planetscale/database": "^1.19.0", - "@upstash/redis": "^1.34.3", - "@vercel/blob": ">=0.27.1", - "@vercel/functions": "^2.2.12 || ^3.0.0", - "@vercel/kv": "^1 || ^2 || ^3", - "aws4fetch": "^1.0.20", - "db0": ">=0.2.1", - "idb-keyval": "^6.2.1", - "ioredis": "^5.4.2", - "uploadthing": "^7.4.4" - }, - "peerDependenciesMeta": { - "@azure/app-configuration": { - "optional": true - }, - "@azure/cosmos": { - "optional": true - }, - "@azure/data-tables": { - "optional": true - }, - "@azure/identity": { - "optional": true - }, - "@azure/keyvault-secrets": { - "optional": true - }, - "@azure/storage-blob": { - "optional": true - }, - "@capacitor/preferences": { - "optional": true - }, - "@deno/kv": { - "optional": true - }, - "@netlify/blobs": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/blob": { - "optional": true - }, - "@vercel/functions": { - "optional": true - }, - "@vercel/kv": { - "optional": true - }, - "aws4fetch": { - "optional": true - }, - "db0": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "uploadthing": { - "optional": true - } - } - }, - "node_modules/url-extras": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", - "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/verkit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.1.tgz", - "integrity": "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==", - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/xxhash-wasm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", - "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", - "license": "MIT" - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index 3592a2d..0000000 --- a/docs/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "docs", - "type": "module", - "version": "0.0.1", - "scripts": { - "dev": "astro dev", - "start": "astro dev", - "build": "astro build", - "preview": "astro preview", - "astro": "astro" - }, - "dependencies": { - "@astrojs/starlight": "^0.42.3", - "astro": "^7.2.10", - "sharp": "^0.35.3" - } -} \ No newline at end of file diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg deleted file mode 100644 index cba5ac1..0000000 --- a/docs/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/src/assets/houston.webp b/docs/src/assets/houston.webp deleted file mode 100644 index 930c164974ad8eb528878f15a98016249b8cf546..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98506 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 b6~t;_wBcJ3AWgga7~Sz~izoKbpAY~5r|m$8 diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts deleted file mode 100644 index d9ee8c9..0000000 --- a/docs/src/content.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineCollection } from 'astro:content'; -import { docsLoader } from '@astrojs/starlight/loaders'; -import { docsSchema } from '@astrojs/starlight/schema'; - -export const collections = { - docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), -}; diff --git a/docs/src/content/docs/guides/example.md b/docs/src/content/docs/guides/example.md deleted file mode 100644 index ebd0f3b..0000000 --- a/docs/src/content/docs/guides/example.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Example Guide -description: A guide in my new Starlight docs site. ---- - -Guides lead a user through a specific task they want to accomplish, often with a sequence of steps. -Writing a good guide requires thinking about what your users are trying to do. - -## Further reading - -- Read [about how-to guides](https://diataxis.fr/how-to-guides/) in the DiΓ‘taxis framework diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx deleted file mode 100644 index 61eb820..0000000 --- a/docs/src/content/docs/index.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: LODE 2.0 -description: Live OWL Documentation Environment β€” extract and document RDF/OWL/SKOS semantic artefacts as browsable HTML. -template: splash -hero: - title: LODE 2.0 - tagline: Live OWL Documentation Environment β€” extract and document semantic artefacts as browsable, interlinked HTML. - actions: - - text: Get started - link: /lode/getting_started/ - icon: right-arrow - - text: View on GitHub - link: https://github.com/opencitations/lode - icon: external - variant: minimal ---- - -## Features - -- Extracts and documents OWL classes, properties, individuals, restrictions and more -- Renders browsable HTML with embedded navigation links -- Serves a dynamic web interface and REST API -- Generates self-contained static sites from the CLI -- Content negotiation: returns RDF serializations on request -- Docker-ready with configurable storage via environment variables - -## Quick start - -```bash -pip install lode2 -lode serve --port 8000 -``` - -Open `http://localhost:8000` to use the web form, or `http://localhost:8000/docs` for the interactive API. \ No newline at end of file diff --git a/docs/src/content/docs/reference/example.md b/docs/src/content/docs/reference/example.md deleted file mode 100644 index 0224f09..0000000 --- a/docs/src/content/docs/reference/example.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Example Reference -description: A reference page in my new Starlight docs site. ---- - -Reference pages are ideal for outlining how things work in terse and clear terms. -Less concerned with telling a story or addressing a specific use case, they should give a comprehensive outline of what you're documenting. - -## Further reading - -- Read [about reference](https://diataxis.fr/reference/) in the DiΓ‘taxis framework diff --git a/docs/tsconfig.json b/docs/tsconfig.json deleted file mode 100644 index 8bf91d3..0000000 --- a/docs/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "astro/tsconfigs/strict", - "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] -} diff --git a/docs/src/content/docs/usage/cli.md b/docs/usage/cli.md similarity index 82% rename from docs/src/content/docs/usage/cli.md rename to docs/usage/cli.md index 56b32a0..2f154a6 100644 --- a/docs/src/content/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -1,13 +1,10 @@ ---- -title: CLI Reference -description: LODE command line interface ---- +# CLI reference ## Commands ### `lode serve` -Start the web server. +Start the web server (requires the `[server]` extra). ```bash lode serve [--port PORT] @@ -34,6 +31,7 @@ lode build (--url URL | --file FILE) --read-as FORMAT [OPTIONS] | `--lang` | no | Language tag for labels (default `en`) | | `--imported` | no | Load `owl:imports` at depth 1 | | `--closure` | no | Load the full `owl:imports` closure | +| `--partial-import` | no | Load only the imported entities that are actually used | ## Examples @@ -42,7 +40,7 @@ lode build (--url URL | --file FILE) --read-as FORMAT [OPTIONS] lode build --url http://purl.org/spar/fabio --read-as owl --out ./fabio # from a local file -lode build --file ontology.ttl --read-as owl --out ./docs +lode build --file ontology.ttl --read-as owl --out ./site # with Italian labels and imports lode build --url http://purl.org/spar/fabio --read-as owl --lang it --imported --out ./fabio @@ -54,4 +52,5 @@ lode build --url http://purl.org/spar/fabio --read-as owl --lang it --imported - |----------|---------|-------------| | `LODE_SPOOL_DIR` | `$TMPDIR/lode-spool` | Cache for fetched URLs and uploads | | `LODE_BUILD_DIR` | `$TMPDIR/lode-builds` | Workspace for ZIP build jobs | +| `LODE_LOG_LEVEL` | `WARNING` | Log verbosity (`DEBUG`, `INFO`, `WARNING`, ...) | | `LODE_DEBUG` | `false` | Show full tracebacks in error pages | diff --git a/docs/src/content/docs/usage/docker.md b/docs/usage/docker.md similarity index 66% rename from docs/src/content/docs/usage/docker.md rename to docs/usage/docker.md index 2f62898..ff7442e 100644 --- a/docs/src/content/docs/usage/docker.md +++ b/docs/usage/docker.md @@ -1,45 +1,44 @@ ---- -title: Docker -description: Deploy LODE with Docker ---- +# Docker + +The image listens on port `8080`. ## Quick start ```bash docker build -t lode . -docker run -p 8000:8000 lode +docker run -p 8080:8080 lode +``` + +Open `http://localhost:8080`. + +## From Docker Hub + +```bash +docker run -p 8080:8080 opencitations/lode:latest ``` -Open `http://localhost:8000`. +Replace `latest` with the desired version tag. ## With persistent storage ```bash -docker run -p 8000:8000 \ +docker run -p 8080:8080 \ -v lode-data:/data \ -e LODE_SPOOL_DIR=/data/spool \ -e LODE_BUILD_DIR=/data/builds \ - lode + opencitations/lode:latest ``` ## Production (read-only filesystem) ```bash -docker run -p 8000:8000 \ +docker run -p 8080:8080 \ --read-only \ --tmpfs /tmp \ -v lode-data:/data \ -e LODE_SPOOL_DIR=/data/spool \ -e LODE_BUILD_DIR=/data/builds \ - lode -``` - -## From the registry - -Once published: - -```bash -docker run -p 8000:8000 ghcr.io/opencitations/lode:latest + opencitations/lode:latest ``` ## Environment variables diff --git a/docs/src/content/docs/usage/web_form.md b/docs/usage/web_form.md similarity index 86% rename from docs/src/content/docs/usage/web_form.md rename to docs/usage/web_form.md index c4cffa2..902270c 100644 --- a/docs/src/content/docs/usage/web_form.md +++ b/docs/usage/web_form.md @@ -1,7 +1,4 @@ ---- -title: Web Form -description: How to use the LODE web interface ---- +# Web form Start the server and open `http://localhost:8000`. @@ -11,7 +8,7 @@ Start the server and open `http://localhost:8000`. |-------|-------------| | **URL** | Public http(s) URL of the artefact | | **File** | Upload a local `.ttl`, `.rdf`, `.owl`, `.n3`, `.jsonld` file | -| **Format** | Artefact type (`owl` is currently available) | +| **Format** | Artefact type (`owl`, `rdf`, `skos`) | | **Language** | Preferred language for labels (e.g. `en`, `it`) | | **Load imports** | Also fetch `owl:imports` at depth 1 | | **Load closure** | Fetch the full `owl:imports` closure | diff --git a/lode/__init__.py b/lode/__init__.py index 8d1c8b6..5d99a7a 100644 --- a/lode/__init__.py +++ b/lode/__init__.py @@ -1 +1,2 @@ - +"""LODE: Live OWL Documentation Environment, maintained by OpenCitations.""" + diff --git a/lode/api.py b/lode/api.py index 077ec31..0bbff78 100644 --- a/lode/api.py +++ b/lode/api.py @@ -13,12 +13,9 @@ # ========================================================================== # 1. IMPORTS # ========================================================================== -from enum import Enum -from functools import lru_cache from pathlib import Path from typing import Optional from urllib.parse import urlencode -from uuid import uuid4 import hashlib import logging import os @@ -57,10 +54,15 @@ # ========================================================================== # 2. APP SETUP # ========================================================================== +LOG_LEVEL = os.getenv("LODE_LOG_LEVEL", "WARNING").strip().upper() + logging.basicConfig( - level=logging.DEBUG, + level=LOG_LEVEL, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) +# Third-party loggers: silence per-request HTTP noise and URI warnings. +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("rdflib").setLevel(logging.ERROR) logger = logging.getLogger(__name__) # When enabled, error pages include the full traceback (development only). @@ -127,7 +129,7 @@ def _prune_builds() -> int: if st.st_mtime < cutoff: shutil.rmtree(p, ignore_errors=True) continue - for root, _dirs, files in os.walk(p): + for root, _, files in os.walk(p): for f in files: try: total += os.path.getsize(os.path.join(root, f)) @@ -141,6 +143,11 @@ def _prune_builds() -> int: # ========================================================================== def _nav_qs(read_as: str, url, upload_id, lang, imported, closure, partial_import) -> str: + """Build the query string that keeps the current view parameters in navigation links. + + Exactly one of `upload_id` and `url` is emitted, so a link never mixes the two + sources. Boolean options are included only when enabled. + """ p = {"read_as": read_as, "lang": lang or ""} p["upload_id" if upload_id else "url"] = upload_id or (url or "") if imported: @@ -154,6 +161,11 @@ def _nav_qs(read_as: str, url, upload_id, lang, imported, closure, partial_impor def _render_view(request, reader, *, resource, lang, source_url, upload_id, read_as, imported=None, closure=None, partial_import=None): + """Render one resource (or the whole artefact) as the minified viewer page. + + The body is minified after rendering, so Content-Length is recomputed: + keeping the old value would truncate the response. + """ viewer = reader.get_viewer() data = viewer.get_view_data(resource_uri=resource, language=lang) data["warnings"] = reader.get_warnings() @@ -172,11 +184,22 @@ def _render_view(request, reader, *, resource, lang, source_url, upload_id, read # 7. READER RESOLUTION # ========================================================================== def _url_token(url, read_as, imported, closure, partial_import) -> str: - key = f"{url}|{read_as}|{imported}|{closure}|{partial_import}".encode() + """Return the stable cache key for a URL and its load options. + + Every option that changes the loaded graph is part of the key, so two + requests share a cached copy only if they would produce the same result. + """ + key =f"{url}|{read_as}|{imported}|{closure}|{partial_import}".encode() return "url_" + hashlib.sha256(key).hexdigest()[:32] def _load_url(url, read_as, imported, closure, partial_import, warnings, use_cache=True): + """Load an artefact from a URL, through the disk cache when allowed. + + The URL is validated first (SSRF protection). Only the main document is + cached: modules (imports, closure) are re-applied on every hit, so the cache + never goes stale on remote ontologies. Blocking: network and disk I/O. + """ # Enforce http(s)://host up front: a non-URL value (local path, file://, ...) # must never reach the loader and be opened as a local file. security.check_url_safe(url) @@ -184,7 +207,7 @@ def _load_url(url, read_as, imported, closure, partial_import, warnings, use_cac token = _url_token(url, read_as, imported, closure, partial_import) path = spool.get_path(token) if use_cache and os.path.exists(path): - # cache hit: ricostruisci dal Turtle salvato + # cache hit: rebuild from the saved Turtle reader = Reader() reader.load_instances(path, read_as, imported=imported, closure=closure, partial_import=partial_import, warnings=warnings) @@ -195,11 +218,11 @@ def _load_url(url, read_as, imported, closure, partial_import, warnings, use_cac os.unlink(path) except OSError: pass - # cache miss (or forced refresh): scarica e processa dalla URL + # cache miss (or forced refresh): fetch and process the URL reader = Reader() reader.load_instances(url, read_as, imported=imported, closure=closure, partial_import=partial_import, warnings=warnings) - # persisti SOLO il documento principale: i moduli vengono riapplicati a ogni hit, + # persist ONLY the main document: modules are re-applied on every hit. try: g = reader._graph main = g.graph(reader._ontology_iri) if isinstance(g, ProvenanceGraph) else g @@ -211,6 +234,11 @@ def _load_url(url, read_as, imported, closure, partial_import, warnings, use_cac def _resolve_reader(read_as: str, url, upload_id, imported, closure, partial_import, warnings, use_cache=True): + """Build a Reader from an uploaded file or from a URL. + + Raises ArtefactValidationError when the upload has expired or when neither + source is given. Uploads win over URLs and are never re-fetched. + """ if upload_id: # Uploads are not re-fetched, so the cache flag does not apply to them. path = spool.get_path(upload_id) @@ -259,7 +287,7 @@ def _zip_response(reader, slug: str, lang: Optional[str] = None) -> FileResponse Blocking and disk-heavy: callers must keep it off the event loop. """ - from lode.builder import build_html # lazy: evita l'import circolare + from lode.builder import build_html # lazy: avoids a circular import if not _BUILD_SLOTS.acquire(timeout=_BUILD_WAIT): raise HTTPException(503, "Server busy: too many concurrent builds", @@ -303,6 +331,11 @@ def _zip_response(reader, slug: str, lang: Optional[str] = None) -> FileResponse # 9. ERROR RENDERING # ========================================================================== def _error_payload(exc: Exception) -> dict: + """Build the dict used by error.html for any exception. + + The traceback is included only for non-LODE errors and only when LODE_DEBUG + is set: it is always logged server-side and never shown to users otherwise. + """ is_lode = isinstance(exc, LODEError) return { "type": type(exc).__name__, @@ -317,6 +350,7 @@ def _error_payload(exc: Exception) -> dict: @app.exception_handler(LODEError) async def lode_error_handler(request: Request, exc: LODEError): + """Show a domain error (LODEError) as an HTML page with HTTP 400.""" logger.error(f"{type(exc).__name__}: {exc}") return templates.TemplateResponse( "error.html", {"request": request, "error": _error_payload(exc)}, status_code=400 @@ -325,6 +359,10 @@ async def lode_error_handler(request: Request, exc: LODEError): @app.exception_handler(Exception) async def unexpected_error_handler(request: Request, exc: Exception): + """Show an unhandled exception as an HTML page with HTTP 500. + + The full traceback goes to the server log; the page shows it only in debug mode. + """ logger.exception("Unexpected error") return templates.TemplateResponse( "error.html", {"request": request, "error": _error_payload(exc)}, status_code=500 @@ -358,9 +396,8 @@ async def limit_upload_size(request: Request, call_next): # ========================================================================== @app.get("/", response_class=HTMLResponse, tags=["ops"], summary="Web input form") async def input_web_interface(request: Request): - """Interfaccia web per l'API""" - return templates.TemplateResponse("index.html", { - "request": request, + """Serve the HTML input form, the entry point of the web interface.""" + return templates.TemplateResponse(request, "index.html", { "formats": [format.value for format in ReadAsFormat], "enabled_formats": sorted(ENABLED_FORMATS), }) @@ -385,7 +422,7 @@ def extract_get( lang: Optional[str] = Query(None, description="Language tag for labels, e.g. 'en', 'it'"), imported: Optional[bool] = Query(None, description="Also load owl:imports (depth 1)"), closure: Optional[bool] = Query(None, description="Load the full owl:imports closure"), - partial_import: Optional[bool] = Query(None, description="Additiornally load only explicitly mentioned entities from other ontologies"), + partial_import: Optional[bool] = Query(None, description="Additionally load only explicitly mentioned entities from other ontologies"), format: Optional[str] = Query(None, description="Force a serialization (ttl, rdf, jsonld…) instead of HTML"), warnings: bool = Query(False, description="Collect and show reader warnings"), cache: bool = Query(True, description="Use the disk cache for fetched URLs"), @@ -397,11 +434,7 @@ def extract_get( reader = _resolve_reader(read_as.value, url, upload_id, imported, closure, partial_import, warnings, use_cache=cache) - # Content negotiation - accept = request.headers.get("accept", "text/html") - serial = None - - # Content negotiation: i formati disponibili dipendono dal grafo (quadruple solo con moduli) + # Content negotiation: the available formats depend on the graph (quads only with modules) accept = request.headers.get("accept", "text/html") available = formats_for(reader._graph) serial = None @@ -443,7 +476,7 @@ async def extract_post( resource: Optional[str] = Form(None, description="IRI of a single resource; omit for the whole ontology"), lang: Optional[str] = Form(None, description="Language tag for labels, e.g. 'en', 'it'"), imported: Optional[bool] = Form(None, description="Also load owl:imports (depth 1)"), - partial_import: Optional[bool] = Form(None, description="Additiornally load only explicitly mentioned entities from other ontologies"), + partial_import: Optional[bool] = Form(None, description="Additionally load only explicitly mentioned entities from other ontologies"), closure: Optional[bool] = Form(None, description="Load the full owl:imports closure"), warnings: bool = Form(False, description="Collect and show reader warnings"), ): @@ -477,7 +510,7 @@ def build_get( lang: Optional[str] = Query(None, description="Language tag for labels, e.g. 'en', 'it'"), imported: Optional[bool] = Query(None, description="Also load owl:imports (depth 1)"), closure: Optional[bool] = Query(None, description="Load the full owl:imports closure"), - partial_import: Optional[bool] = Query(None, description="Additiornally load only explicitly mentioned entities from other ontologies"), + partial_import: Optional[bool] = Query(None, description="Additionally load only explicitly mentioned entities from other ontologies"), warnings: bool = Query(False, description="Collect and show reader warnings"), cache: bool = Query(True, description="Use the disk cache for fetched URLs"), ): @@ -496,7 +529,7 @@ async def build_post( lang: Optional[str] = Form(None, description="Language tag for labels, e.g. 'en', 'it'"), imported: Optional[bool] = Form(None, description="Also load owl:imports (depth 1)"), closure: Optional[bool] = Form(None, description="Load the full owl:imports closure"), - partial_import: Optional[bool] = Form(None, description="Additiornally load only explicitly mentioned entities from other ontologies"), + partial_import: Optional[bool] = Form(None, description="Additionally load only explicitly mentioned entities from other ontologies"), warnings: bool = Form(False, description="Collect and show reader warnings"), ): """Build the static site from an uploaded file instead of a URL.""" diff --git a/lode/builder.py b/lode/builder.py index 248e9c0..eaf76fc 100644 --- a/lode/builder.py +++ b/lode/builder.py @@ -1,4 +1,8 @@ -# lode/builder.py +"""Static-site builder: renders an artefact as a folder of plain HTML pages. + +Produces an index page, one page per documented resource and the serializations +of the whole artefact and of each resource, ready to publish on any static host. +""" import re import shutil from pathlib import Path @@ -17,6 +21,12 @@ _SER = [(f["fmt"], f["ext"]) for f in SERIALIZATION_FORMATS] def _get_template_env(static_path: str = "static") -> Environment: + """Create the Jinja2 environment used to render the static pages. + + `static_path` is the relative path from the page to the static folder, so it + differs between the index and the resource pages. The web app's url_for does + not exist here, hence the minimal replacement registered below. + """ try: pkg_path = importlib.resources.files("lode").joinpath("templates") template_dir = str(pkg_path) @@ -29,6 +39,7 @@ def _get_template_env(static_path: str = "static") -> Environment: ) def url_for(name: str, path: str = "") -> str: + """Resolve static files relative to the page; every other route goes to the root.""" if name == "static": return f"{static_path}/{path}" return "/" @@ -37,6 +48,7 @@ def url_for(name: str, path: str = "") -> str: return env def _prefix_map(graph): + """Map each namespace IRI of the graph to its prefix ('default' for the empty one).""" return {str(ns): (p or 'default') for p, ns in graph.namespaces()} def _safe_segment(seg: str) -> str: @@ -54,21 +66,37 @@ def _safe_segment(seg: str) -> str: def _rel_slug(uri, graph): + """Return the 'prefix/local' path of a resource, with both parts made filesystem-safe. + + The local part is empty for a bare namespace IRI: callers skip those. + """ prefix, ns, local = graph.namespace_manager.compute_qname(URIRef(str(uri)), generate=True) prefix = _safe_segment(prefix or "default") or "default" return f"{prefix}/{_safe_segment(local)}" def _local_name(uri: str) -> str: + """Return the part of an IRI after '#', or its last path segment.""" return uri.split('#', 1)[1] if '#' in uri else uri.rstrip('/').rsplit('/', 1)[-1] def _copy_static(out_dir: Path) -> None: + """Copy the package's static assets into the output folder, once. + + An existing static folder is left untouched, so rebuilding does not overwrite it. + """ static_src = Path(__file__).parent / "static" static_dst = out_dir / "static" if static_src.exists() and not static_dst.exists(): shutil.copytree(static_src, static_dst) def build_html(viewer, out_dir: Path, lang: str = "en", reader=None) -> None: + """Write the whole static site for a viewer into `out_dir`. + + Writes index.html, the serializations of the artefact and, under resources/, + one page plus serializations for each resource that belongs to the ontology + namespace or has local triples. Blocking and disk-heavy; existing files are + overwritten. A page is never written outside resources/, whatever the IRI. + """ from collections import defaultdict out_dir.mkdir(parents=True, exist_ok=True) @@ -97,9 +125,11 @@ def build_html(viewer, out_dir: Path, lang: str = "en", reader=None) -> None: materialized.add(str(u)) def _keep(uri): + """Tell whether a resource gets its own page, and so can be linked to.""" return str(uri) in materialized def resource_url_resource(uri, section=None): + """Link to a resource from a resource page; unpublished IRIs stay absolute.""" if not _keep(uri): return uri rel = _rel_slug(uri, graph) @@ -108,6 +138,7 @@ def resource_url_resource(uri, section=None): return f"../{rel}.html" def resource_url_index(uri, section=None): + """Link to a resource from the index page; unpublished IRIs stay absolute.""" if not _keep(uri): return uri rel = _rel_slug(uri, graph) @@ -145,7 +176,6 @@ def resource_url_index(uri, section=None): res_dir = out_dir / "resources" res_dir.mkdir(parents=True, exist_ok=True) - count = 0 for class_key, section_id, _ in toc_config: for inst in grouped.get(class_key, []): uri = inst.get_has_identifier() @@ -178,26 +208,36 @@ def resource_url_index(uri, section=None): data_r["resource_url"] = resource_url_resource data_r["export_base"] = _local_name(uri) page.write_text(_minify(template_resource.render(**data_r)), encoding="utf-8") - count += 1 class _FakeRequest: + """Minimal stand-in for a web request, so the viewer templates render without a web app.""" + def __init__(self, path: str): + """Build a GET request for `path` with no query parameters.""" self.url = _FakeURL(path) self.method = "GET" self.query_params = _FakeQueryParams() self.is_static = True class _FakeQueryParams: + """Query parameters that are always empty: a static page has no query string.""" + def get(self, key: str, default: str = "") -> str: + """Return the default for any key.""" return default - # Accesso diretto tipo request.query_params.read_as + # Direct access such as request.query_params.read_as def __getattr__(self, key: str) -> str: + """Return an empty string for any attribute.""" return "" class _FakeURL: + """Minimal URL object exposing only the path, as the templates need.""" + def __init__(self, path: str): + """Store the page path.""" self.path = path def __str__(self) -> str: + """Return the page path.""" return self.path \ No newline at end of file diff --git a/lode/cli.py b/lode/cli.py index 7f5c8a5..c061a72 100644 --- a/lode/cli.py +++ b/lode/cli.py @@ -10,11 +10,26 @@ from pathlib import Path def cmd_serve(args): - import uvicorn + """Start the FastAPI web service with uvicorn. + + uvicorn is imported lazily because it belongs to the optional [server] + extra: the build command must keep working without it. Blocks until the + server is stopped; binds to all interfaces so it works inside Docker. + """ + try: + import uvicorn + except ImportError: + sys.exit("Server dependencies missing: pip install oc-lode[server]") uvicorn.run("lode.api:app", host="0.0.0.0", port=args.port, reload=False) def cmd_build(args): + """Generate the static HTML documentation for one artefact. + + Reader and builder are imported lazily so that `lode serve` and `--help` + do not pay their import cost. Creates the output directory if missing and + overwrites existing pages in it. Prints the output path on success. + """ from lode.reader import Reader from lode.builder import build_html from lode.helpers.formats import check_format_enabled, ReadAsFormat @@ -37,15 +52,20 @@ def cmd_build(args): def main(): - parser = argparse.ArgumentParser(prog="lode") + """Parse the command line and dispatch to the chosen subcommand. + + Entry point of the `lode` script declared in pyproject.toml. Adds no logic + of its own: serve and build are thin adapters over the core. + """ + parser =argparse.ArgumentParser(prog="lode") sub = parser.add_subparsers(dest="cmd", required=True) # --- serve --- - p_serve = sub.add_parser("serve", help="Avvia il server FastAPI") + p_serve = sub.add_parser("serve", help="Start the FastAPI web service") p_serve.add_argument("--port", type=int, default=8000) # --- build --- - p_build = sub.add_parser("build", help="Genera HTML statici") + p_build = sub.add_parser("build", help="Generate static HTML documentation") src = p_build.add_mutually_exclusive_group(required=True) src.add_argument("--url") src.add_argument("--file") diff --git a/lode/exceptions.py b/lode/exceptions.py index 7ff77dc..32058f1 100644 --- a/lode/exceptions.py +++ b/lode/exceptions.py @@ -1,28 +1,43 @@ +"""Domain exceptions of LODE. + +Every error the user can cause (bad URL, unreachable or malformed artefact, +rejected upload) is a LODEError subclass, so the web layer can tell it apart +from an unexpected failure and show a clear message instead of a traceback. +""" from datetime import datetime, timezone +from typing import Optional import uuid class LODEError(Exception): - """Base exception. All LODE errors inherit this exception""" + """Base exception. All LODE errors inherit this exception. + + Carries a `context` dict (which file, which URL, which limit), a UTC + `timestamp` and a unique `request_id` so a message shown to the user can be + matched with the server log. + """ - def __init__(self, message: str, context: dict = None): + def __init__(self, message: str, context: Optional[dict] = None): + """Store the message together with its context, timestamp and request id.""" super().__init__(message) self.context = context or {} self.timestamp = datetime.now(timezone.utc).isoformat() self.request_id = str(uuid.uuid4()) class ArtefactLoadError(LODEError): - """Artefatto raggiunto ma non parsabile in nessun formato RDF.""" + """The artefact was reached but cannot be parsed in any RDF format.""" pass class ArtefactNotFoundError(LODEError): - """Artefatto risponde 404 o errore di rete.""" + """The artefact answers 404 or the request fails with a network error.""" pass class ArtefactUnavailableError(LODEError): - """Host remoto raggiunto ma in errore server (5xx): l'artefatto puo' esistere, - e' il servizio a essere temporaneamente non disponibile.""" + """The remote host was reached but answers with a server error (5xx). + + The artefact may well exist: it is the service that is temporarily unavailable. + """ pass class ArtefactValidationError(LODEError): - """Artefatto rifiutato dai security check (size, estensione, scheme/SSRF, non testo).""" + """The artefact was rejected by a security check (size, extension, URL scheme/SSRF, non-text).""" pass \ No newline at end of file diff --git a/lode/helpers/__init__.py b/lode/helpers/__init__.py index 66a3934..83014cf 100644 --- a/lode/helpers/__init__.py +++ b/lode/helpers/__init__.py @@ -1 +1 @@ -# helpers/__init__.py \ No newline at end of file +"""Small helpers shared by the reader, the viewer, the builder and the API.""" \ No newline at end of file diff --git a/lode/helpers/html_utils.py b/lode/helpers/html_utils.py index 22ab17b..e17ca4e 100644 --- a/lode/helpers/html_utils.py +++ b/lode/helpers/html_utils.py @@ -18,6 +18,7 @@ def minify(html: str) -> str: stash: list[str] = [] def _hold(m: re.Match) -> str: + """Replace a protected block with a numbered placeholder and remember the block.""" stash.append(m.group(0)) return f"\x00MD{len(stash)-1}\x00" diff --git a/lode/models/__init__.py b/lode/models/__init__.py index d1a9463..f940e21 100644 --- a/lode/models/__init__.py +++ b/lode/models/__init__.py @@ -1,3 +1,8 @@ +"""Internal representation of the entities extracted from an ontology (the Model layer). + +The Reader fills these classes and the Viewer renders them: neither depends on +how the other works. +""" from .resource import Resource from .property import Property from .relation import Relation diff --git a/lode/models/annotation.py b/lode/models/annotation.py index b20908f..5f223a8 100644 --- a/lode/models/annotation.py +++ b/lode/models/annotation.py @@ -1,6 +1,9 @@ +"""Annotation: an owl:AnnotationProperty.""" from .property import Property class Annotation(Property): + """An annotation property: it carries documentation, not logical meaning.""" def __init__(self, **kwargs): + """Create an annotation property; all state comes from Property.""" super().__init__(**kwargs) \ No newline at end of file diff --git a/lode/models/atom.py b/lode/models/atom.py index 76d7595..3aa50b4 100644 --- a/lode/models/atom.py +++ b/lode/models/atom.py @@ -1,14 +1,28 @@ +"""Atom: an atom of a SWRL rule.""" from .resource import Resource class Atom(Resource): + """A SWRL atom: a predicate applied to a list of arguments.""" + def __init__(self, **kwargs): + """Create an atom with no arguments and no predicate.""" super().__init__(**kwargs) self.has_arguments = [] # Variable or Resource [1...n] self.has_predicate = None # Concept (classPredicate) or Property (propertyPredicate) [1] - def get_has_arguments(self): return list(self.has_arguments) - def set_has_arguments(self, v): self.has_arguments.append(v) + def get_has_arguments(self): + """Return a copy of has_arguments.""" + return list(self.has_arguments) + + def set_has_arguments(self, v): + """Add an argument (a variable or a resource) to has_arguments.""" + self.has_arguments.append(v) + + def get_has_predicate(self): + """Return has_predicate (a concept or a property).""" + return self.has_predicate - def get_has_predicate(self): return self.has_predicate - def set_has_predicate(self, v): self.has_predicate = v \ No newline at end of file + def set_has_predicate(self, v): + """Set has_predicate.""" + self.has_predicate = v \ No newline at end of file diff --git a/lode/models/attribute.py b/lode/models/attribute.py index f48096f..36d2166 100644 --- a/lode/models/attribute.py +++ b/lode/models/attribute.py @@ -1,10 +1,13 @@ +"""Attribute: an owl:DatatypeProperty.""" from .property import Property class Attribute(Property): + """A datatype property: it relates a resource to literals.""" def __init__(self, **kwargs): + """Create an attribute with empty has_range and has_type.""" super().__init__(**kwargs) # Relation with Literal - self.has_range = [] # GIΓ  definita in parent class, card 1..* + self.has_range = [] # Already defined in the parent class, card 1..* self.has_type = [] # [1..*] \ No newline at end of file diff --git a/lode/models/cardinality.py b/lode/models/cardinality.py index 7aa9a79..61cfdb6 100644 --- a/lode/models/cardinality.py +++ b/lode/models/cardinality.py @@ -1,26 +1,29 @@ +"""Cardinality: an OWL cardinality restriction (exactly, min or max).""" from .propertyConceptRestriction import PropertyConceptRestriction class Cardinality(PropertyConceptRestriction): - + """A restriction on how many values a property can take on a concept.""" + def __init__(self, **kwargs): + """Create a cardinality restriction with no number and no type set.""" super().__init__(**kwargs) self.has_cardinality = None # int[1] self.has_cardinality_type = None # str[1] def get_has_cardinality(self): - """Restituisce il valore has_cardinality""" + """Return has_cardinality, the number of values.""" return self.has_cardinality def set_has_cardinality(self, var): - """Aggiunge un integer a has_cardinality""" + """Set has_cardinality, converting the value to int.""" self.has_cardinality = int(var) def get_has_cardinality_type(self): - """Restituisce il valore has_cardinality_type""" + """Return has_cardinality_type ("exactly", "min" or "max").""" return self.has_cardinality_type def set_has_cardinality_type(self, string): - """Aggiunge una string a has_cardinality_type""" + """Set has_cardinality_type ("exactly", "min" or "max").""" self.has_cardinality_type = string diff --git a/lode/models/collection.py b/lode/models/collection.py index 3f9ba9a..af59184 100644 --- a/lode/models/collection.py +++ b/lode/models/collection.py @@ -1,26 +1,29 @@ +"""Collection: a SKOS collection of concepts.""" from .resource import Resource class Collection(Resource): + """A SKOS collection: a labelled group of concepts or other collections.""" def __init__(self, **kwargs): + """Create an unordered collection with no members.""" super().__init__(**kwargs) self.is_ordered= False # bool[1] self.has_member= [] # [0..*] def get_is_ordered(self): - """Restituisce il valore is_ordered bool[1]""" + """Return is_ordered.""" return self.is_ordered def set_is_ordered(self, bool): - """Setta il valore di is_ordered bool [1]""" + """Set is_ordered.""" self.is_ordered = bool def get_has_member(self): - """Restituisce il valore has_member [0..*]""" + """Return a copy of has_member.""" return list(self.has_member) def set_has_member(self, concept_or_collection): - """Setta il valore di has_member [0..*]""" + """Add a concept or a collection to has_member.""" self.has_member.append(concept_or_collection) \ No newline at end of file diff --git a/lode/models/concept.py b/lode/models/concept.py index cfb9b40..fc576a9 100644 --- a/lode/models/concept.py +++ b/lode/models/concept.py @@ -1,8 +1,15 @@ +"""Concept: an OWL class or SKOS concept, with its hierarchy and matching relations.""" from .resource import Resource class Concept(Resource): + """An OWL class or a SKOS concept. + + Holds the relations between concepts (sub/super, disjoint, equivalent, SKOS + matches) and the inverse links to the properties and individuals that refer to it. + """ def __init__(self, **kwargs): + """Create a concept with every relation list empty.""" super().__init__(**kwargs) @@ -18,121 +25,132 @@ def __init__(self, **kwargs): self.has_exact_match = [] # 0..* self.has_close_match = [] # 0..* self.has_key = [] # 0..* - self.individuals_with_this_type = [] # 0..* (inverso di Individual.has_type) + self.individuals_with_this_type = [] # 0..* (inverse of Individual.has_type) self.is_in_domain_of = [] # 0..* def set_has_key(self, prop): + """Add a property to has_key (owl:hasKey).""" self.has_key.append(prop) def get_has_key(self): + """Return the has_key list (not a copy).""" return self.has_key - # Setter e Getter per is_sub_concept_of + # Setter and getter foris_sub_concept_of def set_is_sub_concept_of(self, concept): + """Add a super concept to is_sub_concept_of and register self as its sub concept. + + The inverse link is set only when the target supports it (a Restriction or + TruthFunction may not have the attribute). + """ if concept not in self.is_sub_concept_of: self.is_sub_concept_of.append(concept) - # inverso: solo se il target supporta la relazione (es. Restriction/TruthFunction potrebbero non averlo) + # inverse: only if the target supports the relation inv = getattr(concept, 'is_super_concept_of', None) if inv is not None and self not in inv: inv.append(self) def set_is_super_concept_of(self, concept): + """Add a concept to is_super_concept_of, ignoring duplicates.""" if concept not in self.is_super_concept_of: self.is_super_concept_of.append(concept) def get_is_super_concept_of(self): + """Return the is_super_concept_of list (not a copy).""" return self.is_super_concept_of def get_is_sub_concept_of(self): - """Restituisce la lista is_sub_concept_of""" + """Return the is_sub_concept_of list (not a copy).""" return self.is_sub_concept_of - # Setter e Getter per is_disjoint_with + # Setter and getter foris_disjoint_with def set_is_disjoint_with(self, concept): - """Aggiunge un Concept a is_disjoint_with""" + """Add a concept to is_disjoint_with.""" self.is_disjoint_with.append(concept) def get_is_disjoint_with(self): - """Restituisce la lista is_disjoint_with""" + """Return the is_disjoint_with list (not a copy).""" return self.is_disjoint_with - # Setter e Getter per is_equivalent_to + # Setter and getter foris_equivalent_to def set_is_equivalent_to(self, concept): - """Aggiunge un Concept a is_equivalent_to""" + """Add a concept to is_equivalent_to.""" self.is_equivalent_to.append(concept) def get_is_equivalent_to(self): - """Restituisce la lista is_equivalent_to""" + """Return the is_equivalent_to list (not a copy).""" return self.is_equivalent_to - # Setter e Getter per is_related_to + # Setter and getter foris_related_to def set_is_related_to(self, concept): - """Aggiunge un Concept a is_related_to""" + """Add a concept to is_related_to.""" self.is_related_to.append(concept) def get_is_related_to(self): - """Restituisce la lista is_related_to""" + """Return the is_related_to list (not a copy).""" return self.is_related_to - # Setter e Getter per has_broad_match + # Setter and getter forhas_broad_match def set_has_broad_match(self, concept): - """Aggiunge un Concept a has_broad_match""" + """Add a concept to has_broad_match.""" self.has_broad_match.append(concept) def get_has_broad_match(self): - """Restituisce la lista has_broad_match""" + """Return the has_broad_match list (not a copy).""" return self.has_broad_match - # Setter e Getter per has_narrow_match + # Setter and getter forhas_narrow_match def set_has_narrow_match(self, concept): - """Aggiunge un Concept a has_narrow_match""" + """Add a concept to has_narrow_match.""" self.has_narrow_match.append(concept) def get_has_narrow_match(self): - """Restituisce la lista has_narrow_match""" + """Return the has_narrow_match list (not a copy).""" return self.has_narrow_match - # Setter e Getter per has_related_match + # Setter and getter forhas_related_match def set_has_related_match(self, concept): - """Aggiunge un Concept a has_related_match""" + """Add a concept to has_related_match.""" self.has_related_match.append(concept) def get_has_related_match(self): - """Restituisce la lista has_related_match""" + """Return the has_related_match list (not a copy).""" return self.has_related_match - # Setter e Getter per has_exact_match + # Setter and getter forhas_exact_match def set_has_exact_match(self, concept): - """Aggiunge un Concept a has_exact_match""" + """Add a concept to has_exact_match.""" self.has_exact_match.append(concept) def get_has_exact_match(self): - """Restituisce la lista has_exact_match""" + """Return the has_exact_match list (not a copy).""" return self.has_exact_match - # Setter e Getter per has_close_match + # Setter and getter forhas_close_match def set_has_close_match(self, concept): - """Aggiunge un Concept a has_close_match""" + """Add a concept to has_close_match.""" self.has_close_match.append(concept) def get_has_close_match(self): - """Restituisce la lista has_close_match""" + """Return the has_close_match list (not a copy).""" return self.has_close_match def set_individuals_with_this_type(self, individual): - """Aggiunge un Individual a individuals_with_this_type (inverso di rdf:type).""" + """Add an individual to individuals_with_this_type (inverse of rdf:type), ignoring duplicates.""" if individual not in self.individuals_with_this_type: self.individuals_with_this_type.append(individual) def get_individuals_with_this_type(self): - """Restituisce la lista individuals_with_this_type.""" + """Return a copy of individuals_with_this_type.""" return list(self.individuals_with_this_type) def set_is_in_domain_of(self, prop): + """Add a property to is_in_domain_of, ignoring duplicates.""" if prop not in self.is_in_domain_of: self.is_in_domain_of.append(prop) def get_is_in_domain_of(self): + """Return a copy of is_in_domain_of.""" return list(self.is_in_domain_of) \ No newline at end of file diff --git a/lode/models/container.py b/lode/models/container.py index 765da66..c13489e 100644 --- a/lode/models/container.py +++ b/lode/models/container.py @@ -1,21 +1,23 @@ +"""Container: an RDF container or list.""" from .resource import Resource class Container(Resource): """RDF Container (Bag, Seq, Alt, List)""" - + def __init__(self): + """Create a container with no members.""" super().__init__() self.members = [] def set_has_member(self, member): - """Aggiunge un singolo membro""" + """Add a single member, ignoring duplicates.""" if member not in self.members: self.members.append(member) def set_has_members(self, members: list): - """Imposta tutti i membri in una volta""" - self._members = members.copy() + """Set all the members at once, from a copy of `members`.""" + self.members = members.copy() def get_has_members(self): - """Ritorna la lista dei membri""" + """Return a copy of the members list.""" return self.members.copy() diff --git a/lode/models/datatype.py b/lode/models/datatype.py index df9bdc5..20ab961 100644 --- a/lode/models/datatype.py +++ b/lode/models/datatype.py @@ -1,6 +1,9 @@ +"""Datatype: an RDFS/OWL datatype such as xsd:string.""" from .concept import Concept class Datatype(Concept): + """A datatype: the kind of value a literal can have.""" def __init__(self, **kwargs): + """Create a datatype; all state comes from Concept.""" super().__init__(**kwargs) diff --git a/lode/models/datatypeRestriction.py b/lode/models/datatypeRestriction.py index 7b66c44..5f04de2 100644 --- a/lode/models/datatypeRestriction.py +++ b/lode/models/datatypeRestriction.py @@ -1,21 +1,28 @@ +"""DatatypeRestriction: an owl:DatatypeRestriction (a datatype narrowed by facets).""" from .restriction import Restriction class DatatypeRestriction(Restriction): + """A datatype narrowed by an XSD facet, such as a pattern or a minimum value.""" def __init__(self, **kwargs): + """Create a datatype restriction with no datatype and no facet value.""" super().__init__(**kwargs) self.applies_on_concept = None # Datatype (from owl:onDatatype) string (xsd:pattern, xsd:minInclusive, ...) self.has_restriction_value = None # Literal (the corresponding value e.g. 0.0) def get_applies_on_concept(self): + """Return applies_on_concept (the datatype, or the facet string).""" return self.applies_on_concept def set_applies_on_concept(self, datatype): + """Set applies_on_concept (the datatype, or the facet string).""" self.applies_on_concept = datatype def set_has_restriction_value(self, string): + """Set has_restriction_value (the literal value of the facet).""" self.has_restriction_value = string def get_has_restriction_value(self): + """Return has_restriction_value (the literal value of the facet).""" return self.has_restriction_value \ No newline at end of file diff --git a/lode/models/individual.py b/lode/models/individual.py index 006de5b..f096396 100644 --- a/lode/models/individual.py +++ b/lode/models/individual.py @@ -1,18 +1,20 @@ +"""Individual: an OWL named individual.""" from .resource import Resource class Individual(Resource): """ - Rappresenta un'istanza individuale di una classe (owl:NamedIndividual). - Estende Resource con relazioni specifiche per individui OWL. + Represents an individual instance of a class (owl:NamedIndividual). + Extends Resource with the relations specific to OWL individuals. """ def __init__(self, **kwargs): + """Create an individual with empty is_same_as and is_different_from lists.""" super().__init__(**kwargs) # Relations with Individual self.is_same_as = [] # 0..* self.is_different_from = [] # 0..* - # Relations with Concept (ereditato ma qui esplicitato, perchΓ¨ cambia la cardinalitΓ ) + # Relations with Concept (inherited but made explicit here, because the cardinality changes) # self.has_type = [] # 1..* # def get_has_type(self): @@ -22,17 +24,17 @@ def __init__(self, **kwargs): # self.has_type.append(concept) def get_is_same_as(self): - """Restituisce la lista is_same_as [0..*]""" + """Return a copy of is_same_as [0..*].""" return list(self.is_same_as) def set_is_same_as(self, individual): - """Aggiunge un Individual a is_same_as [0..*]""" + """Add an individual to is_same_as [0..*].""" self.is_same_as.append(individual) def get_is_different_from(self): - """Restituisce la lista is_different_from [0..*]""" + """Return a copy of is_different_from [0..*].""" return list(self.is_different_from) def set_is_different_from(self, individual): - """Aggiunge un Individual a is_different_from [0..*]""" + """Add an individual to is_different_from [0..*].""" self.is_different_from.append(individual) diff --git a/lode/models/literal.py b/lode/models/literal.py index 369c970..8dfaf08 100644 --- a/lode/models/literal.py +++ b/lode/models/literal.py @@ -1,9 +1,12 @@ +"""Literal: an RDF literal with its value, language and datatype.""" from .resource import Resource -class Literal(Resource): +class Literal(Resource): + """An RDF literal: a lexical value with an optional language tag and datatype.""" def __init__(self, **kwargs): - + """Create an empty literal: no value, language or datatype.""" + super().__init__(**kwargs) # Attributes @@ -13,23 +16,25 @@ def __init__(self, **kwargs): self.has_type = None def set_has_language(self, literal): - """Imposta has_language""" + """Set has_language.""" self.has_language = literal def get_has_language(self): - """Restituisce has_language""" + """Return has_language.""" return self.has_language def set_has_value(self, literal): - """Imposta has_value""" + """Set has_value.""" self.has_value = literal def get_has_value(self): - """Restituisce has_value""" + """Return has_value.""" return self.has_value def set_has_type(self, datatype): + """Set has_type, the datatype of the literal.""" self.has_type = datatype def get_has_type(self): + """Return has_type, the datatype of the literal.""" return self.has_type diff --git a/lode/models/model.py b/lode/models/model.py index a94dec9..b5c2a90 100644 --- a/lode/models/model.py +++ b/lode/models/model.py @@ -1,70 +1,73 @@ +"""Model: the documented ontology (or vocabulary) itself, with its versions and imports.""" from .resource import Resource class Model(Resource): + """An ontology or vocabulary: versioning, compatibility, imports and namespaces.""" def __init__(self, **kwargs): + """Create a model with no versions, imports or namespaces.""" super().__init__(**kwargs) self.has_version = [] # 0..* self.is_backward_compatible_with = [] # 0..* self.imports = [] # 0..* self.is_incompatible_with = [] # 0..* - self.has_top_concept = [] # 0..1 + self.has_top_concept = None # 0..1 self.has_prior_version = None # 0..1 self.has_namespaces = {} # 1 (prefix -> URI) def get_has_version(self): - """Restituisce la lista has_version""" + """Return a copy of has_version.""" return list(self.has_version) def set_has_version(self, model): - """Aggiunge un Model a has_version """ + """Add a model to has_version.""" self.has_version.append(model) def get_is_backward_compatible_with(self): - """Restituisce la lista is_backward_compatible_with""" + """Return a copy of is_backward_compatible_with.""" return list(self.is_backward_compatible_with) def set_is_backward_compatible_with(self, model): - """Aggiunge un Model a is_backward_compatible_with""" + """Add a model to is_backward_compatible_with.""" self.is_backward_compatible_with.append(model) def get_imports(self): - """Restituisce la lista imports""" + """Return a copy of imports.""" return list(self.imports) def set_imports(self, model): - """Aggiunge un Model a imports""" + """Add a model to imports.""" self.imports.append(model) def get_is_incompatible_with(self): - """Restituisce la lista is_incompatible_with""" + """Return a copy of is_incompatible_with.""" return list(self.is_incompatible_with) def set_is_incompatible_with(self, model): - """Aggiunge un Model a is_incompatible_with""" + """Add a model to is_incompatible_with.""" self.is_incompatible_with.append(model) def get_has_top_concept(self): - """Restituisce la lista has_top_concept""" + """Return has_top_concept (a single concept, or None).""" return self.has_top_concept def set_has_top_concept(self, concept): - """Aggiunge un Concept a has_top_concept""" + """Set has_top_concept, replacing any previous value.""" self.has_top_concept = concept def get_has_prior_version(self): - """Restituisce la lista has_prior_version""" + """Return has_prior_version (a single model, or None).""" return self.has_prior_version def set_has_prior_version(self, model): - """Aggiunge un Model a has_prior_version""" + """Set has_prior_version, replacing any previous value.""" self.has_prior_version = model def get_has_namespaces(self): - """Restituisce il dizionario prefix -> URI""" + """Return a copy of the prefix -> URI dictionary.""" return dict(self.has_namespaces) def set_has_namespaces(self, namespaces: dict): - """Imposta il dizionario dei namespace (prefix -> URI)""" + """Set the prefix -> URI dictionary of the namespaces.""" self.has_namespaces = namespaces \ No newline at end of file diff --git a/lode/models/oneOf.py b/lode/models/oneOf.py index b983d2c..47ce034 100644 --- a/lode/models/oneOf.py +++ b/lode/models/oneOf.py @@ -1,17 +1,20 @@ +"""OneOf: an owl:oneOf enumeration.""" from .restriction import Restriction class OneOf(Restriction): + """A class defined by listing its members (owl:oneOf).""" def __init__(self, **kwargs): + """Create an enumeration with no resources.""" super().__init__(**kwargs) self.applies_on_resource = [] # 1..* def get_applies_on_resource(self): - """Restituisce la lista applies on resource""" + """Return a copy of applies_on_resource.""" return list(self.applies_on_resource) def set_applies_on_resource(self, resource): - """Aggiunge un Resource a applies_on_resource """ + """Add a resource to applies_on_resource.""" self.applies_on_resource.append(resource) diff --git a/lode/models/property.py b/lode/models/property.py index be764ef..032f973 100644 --- a/lode/models/property.py +++ b/lode/models/property.py @@ -1,10 +1,12 @@ +"""Property: the base class of RDF/OWL properties.""" from .resource import Resource class Property(Resource): """Represents an RDF Property""" def __init__(self, **kwargs): - + """Create a property that is not functional, with empty hierarchy, domain and range.""" + super().__init__(**kwargs) # Attributes @@ -22,17 +24,21 @@ def __init__(self, **kwargs): # Relations with Concept (1..*) self.has_domain = [] - # Metodi per is_functional + # Methods foris_functional def set_is_functional(self, bool): - """Imposta is_functional""" + """Set is_functional.""" self.is_functional = bool def get_is_functional(self): - """Restituisce is_functional""" + """Return is_functional.""" return self.is_functional - # Metodi per is_sub_property_of / is_super_property_of + # Methods foris_sub_property_of / is_super_property_of def set_is_sub_property_of(self, property_obj): + """Add a super property to is_sub_property_of and register self as its sub property. + + The inverse link is set only when the target supports it. + """ if property_obj not in self.is_sub_property_of: self.is_sub_property_of.append(property_obj) inv = getattr(property_obj, 'is_super_property_of', None) @@ -40,46 +46,51 @@ def set_is_sub_property_of(self, property_obj): inv.append(self) def get_is_sub_property_of(self): + """Return a copy of is_sub_property_of.""" return list(self.is_sub_property_of) def set_is_super_property_of(self, property_obj): + """Add a property to is_super_property_of, ignoring duplicates.""" if property_obj not in self.is_super_property_of: self.is_super_property_of.append(property_obj) def get_is_super_property_of(self): + """Return a copy of is_super_property_of.""" return list(self.is_super_property_of) - # Metodi per is_disjoint_with + # Methods foris_disjoint_with def set_is_disjoint_with(self, property_obj): - """Aggiunge una property a is_disjoint_with""" + """Add a property to is_disjoint_with.""" self.is_disjoint_with.append(property_obj) def get_is_disjoint_with(self): - """Restituisce una copia della lista is_disjoint_with""" + """Return a de-duplicated copy of is_disjoint_with (order is not preserved).""" return list(set(self.is_disjoint_with)) def set_is_equivalent_to(self, property_obj): - """Aggiunge una property a is_equivalent_to""" + """Add a property to is_equivalent_to.""" self.is_equivalent_to.append(property_obj) def get_is_equivalent_to(self): - """Restituisce una copia della lista is_equivalent_to""" + """Return a de-duplicated copy of is_equivalent_to (order is not preserved).""" return list(set(self.is_equivalent_to)) - # Metodi per has_range + # Methods forhas_range def set_has_range(self, resource): + """Add a resource to has_range, ignoring duplicates.""" if resource not in self.has_range: self.has_range.append(resource) def get_has_range(self): - """Restituisce una copia della lista has_range""" + """Return a copy of has_range.""" return list(self.has_range) def set_has_domain(self, concept): + """Add a concept to has_domain, ignoring duplicates.""" if concept not in self.has_domain: self.has_domain.append(concept) def get_has_domain(self): - """Restituisce una copia della lista has_domain""" + """Return a copy of has_domain.""" return list(self.has_domain) diff --git a/lode/models/propertyConceptRestriction.py b/lode/models/propertyConceptRestriction.py index 29ffff7..f9cd3e3 100644 --- a/lode/models/propertyConceptRestriction.py +++ b/lode/models/propertyConceptRestriction.py @@ -1,7 +1,11 @@ +"""PropertyConceptRestriction: a restriction that links a property to a concept.""" from .restriction import Restriction class PropertyConceptRestriction(Restriction): + """Base of the restrictions that apply a property to a concept (quantifiers, cardinalities).""" + def __init__(self, **kwargs): + """Create a restriction with no property, no concept, and is_inverse False.""" super().__init__(**kwargs) self.applies_on_property = None # 1 @@ -9,25 +13,25 @@ def __init__(self, **kwargs): self.is_inverse = False #bool 1 def get_applies_on_property(self): - """Restituisce il valore applies on property""" + """Return applies_on_property.""" return self.applies_on_property def set_applies_on_property(self, property): - """Aggiunge un Property a applies_on_property """ + """Set applies_on_property.""" self.applies_on_property = property def get_applies_on_concept(self): - """Restituisce la lista applies on concept""" + """Return applies_on_concept.""" return self.applies_on_concept def set_applies_on_concept(self, concept): - """Aggiunge un Concept a applies_on_concept """ + """Set applies_on_concept.""" self.applies_on_concept = concept def get_is_inverse(self): - """Restituisce la lista applies on concept""" + """Return is_inverse.""" return self.is_inverse def set_is_inverse(self, boolean): - """Sets if the restriction is inverse""" + """Set is_inverse: whether the restriction is on the inverse of the property.""" self.is_inverse = boolean diff --git a/lode/models/propertySelfRestriction.py b/lode/models/propertySelfRestriction.py index ce673aa..03a7a9c 100644 --- a/lode/models/propertySelfRestriction.py +++ b/lode/models/propertySelfRestriction.py @@ -1,15 +1,20 @@ +"""PropertySelfRestriction: an owl:hasSelf restriction.""" from .restriction import Restriction class PropertySelfRestriction(Restriction): + """A restriction on the individuals related to themselves by a property (owl:hasSelf).""" def __init__(self, **kwargs): + """Create a self restriction with no property.""" super().__init__(**kwargs) self.applies_on_property = None # [1] def get_applies_on_property(self): + """Return applies_on_property.""" return self.applies_on_property def set_applies_on_property(self, relation): + """Set applies_on_property.""" self.applies_on_property = relation diff --git a/lode/models/quantifier.py b/lode/models/quantifier.py index 577bb2d..b5ce1f1 100644 --- a/lode/models/quantifier.py +++ b/lode/models/quantifier.py @@ -1,16 +1,22 @@ +"""Quantifier: an existential or universal property restriction.""" from .propertyConceptRestriction import PropertyConceptRestriction class Quantifier(PropertyConceptRestriction): - + """A quantified restriction of a property on a concept. + + The type is "some" (owl:someValuesFrom) or "only" (owl:allValuesFrom). + """ + def __init__(self, **kwargs): + """Create a quantifier with no type set.""" super().__init__(**kwargs) self.has_quantifier_type = None # str[1] def get_has_quantifier_type(self): - """Restituisce il valore has_quantifier_type""" + """Return has_quantifier_type ("some" or "only").""" return self.has_quantifier_type def set_has_quantifier_type(self, string): - """Aggiunge una string a has_quantifier_type""" + """Set has_quantifier_type ("some" or "only").""" self.has_quantifier_type = string \ No newline at end of file diff --git a/lode/models/relation.py b/lode/models/relation.py index 6ec5cc5..88518f7 100644 --- a/lode/models/relation.py +++ b/lode/models/relation.py @@ -1,10 +1,11 @@ +"""Relation: an owl:ObjectProperty and its logical characteristics.""" from .property import Property class Relation(Property): - """Rappresenta una Object Property RDF""" + """Represents an RDF Object Property""" def __init__(self, **kwargs): - # Chiama il costruttore della classe padre + """Create a relation with every characteristic set to False and no inverses or chains.""" super().__init__(**kwargs) # Attributes @@ -22,73 +23,75 @@ def __init__(self, **kwargs): # Relation with Concept self.has_range = [] # 1..* - # Metodi per is_asymmetric + # Methods foris_asymmetric def set_is_asymmetric(self, value): - """Imposta is_asymmetric""" + """Set is_asymmetric.""" self.is_asymmetric = value def get_is_asymmetric(self): - """Restituisce is_asymmetric""" + """Return is_asymmetric.""" return self.is_asymmetric - # Metodi per is_inverse_functional + # Methods foris_inverse_functional def set_is_inverse_functional(self, value): - """Imposta is_inverse_functional""" + """Set is_inverse_functional.""" self.is_inverse_functional = value def get_is_inverse_functional(self): - """Restituisce is_inverse_functional""" + """Return is_inverse_functional.""" return self.is_inverse_functional - # Metodi per is_irreflexive + # Methods foris_irreflexive def set_is_irreflexive(self, value): - """Imposta is_irreflexive""" + """Set is_irreflexive.""" self.is_irreflexive = value def get_is_irreflexive(self): - """Restituisce is_irreflexive""" + """Return is_irreflexive.""" return self.is_irreflexive - # Metodi per is_reflexive + # Methods foris_reflexive def set_is_reflexive(self, value): - """Imposta is_reflexive""" + """Set is_reflexive.""" self.is_reflexive = value def get_is_reflexive(self): - """Restituisce is_reflexive""" + """Return is_reflexive.""" return self.is_reflexive - # Metodi per is_symmetric + # Methods foris_symmetric def set_is_symmetric(self, value): - """Imposta is_symmetric""" + """Set is_symmetric.""" self.is_symmetric = value def get_is_symmetric(self): - """Restituisce is_symmetric""" + """Return is_symmetric.""" return self.is_symmetric - # Metodi per is_transitive + # Methods foris_transitive def set_is_transitive(self, value): - """Imposta is_transitive""" + """Set is_transitive.""" self.is_transitive = value def get_is_transitive(self): - """Restituisce is_transitive""" + """Return is_transitive.""" return self.is_transitive def get_is_inverse_of(self): - """Returns the list of inverse of""" + """Return a de-duplicated copy of is_inverse_of (order is not preserved).""" return list(set(self.is_inverse_of)) def set_is_inverse_of(self, relation): - """Adds a inverse relation""" + """Add a relation to is_inverse_of.""" self.is_inverse_of.append(relation) def set_has_property_chain(self, relation): + """Add a relation to has_property_chain, ignoring duplicates.""" if relation not in self.has_property_chain: self.has_property_chain.append(relation) def get_has_property_chain(self): + """Return a copy of has_property_chain.""" return list(self.has_property_chain) # def set_has_range(self, concept): diff --git a/lode/models/resource.py b/lode/models/resource.py index eb6a535..7ae5bda 100644 --- a/lode/models/resource.py +++ b/lode/models/resource.py @@ -1,7 +1,16 @@ +"""Root of the LODE model: the RDF resource every other entity extends.""" + + class Resource(): - """Rappresenta una Risorsa RDF""" + """An RDF resource, with its annotations (labels, notes, ...) and links to other resources. + + Cardinalities are noted next to each field in __init__. Multi-valued fields + are plain lists filled by the setters; most getters return a copy so callers + cannot alter the model by accident. + """ def __init__(self): + """Create an empty resource: no identifier, not deprecated, all lists empty.""" # Init Attributes self.has_identifier = None self.is_deprecated = False @@ -20,8 +29,6 @@ def __init__(self): self.has_example = [] self.has_history_note = [] self.has_scope_note = [] - self.has_contributor = [] - self.has_creator = [] # Relations with Resources (0..*) self.see_also = [] @@ -39,187 +46,179 @@ def __init__(self): self.is_in_range_of = [] def set_has_identifier(self, value): - """Imposta has_identifier""" + """Set has_identifier.""" self.has_identifier = value def get_has_identifier(self): - """Restituisce has_identifier""" + """Return has_identifier.""" return self.has_identifier def set_is_deprecated(self, value): - """Imposta is_deprecated""" + """Set is_deprecated.""" self.is_deprecated = value def get_is_deprecated(self): - """Restituisce is_deprecated""" + """Return is_deprecated.""" return self.is_deprecated def set_has_comment(self, literal): - """Aggiunge un literal a has_comment""" + """Add a literal to has_comment.""" self.has_comment.append(literal) def get_has_comment(self): - """Restituisce una copia della lista has_comment""" - return list(set(self.has_comment)) + """Return a de-duplicated copy of has_comment, in insertion order.""" + return list(dict.fromkeys(self.has_comment)) def set_has_label(self, literal): - """Aggiunge un literal a has_label""" + """Add a literal to has_label.""" self.has_label.append(literal) def get_has_label(self): - """Restituisce una copia della lista has_label""" - return list(set(self.has_label)) + """Return a de-duplicated copy of has_label, in insertion order.""" + return list(dict.fromkeys(self.has_label)) def set_has_preferred_label(self, literal): - """Aggiunge un literal a has_preferred_label""" + """Add a literal to has_preferred_label.""" self.has_preferred_label.append(literal) def get_has_preferred_label(self): - """Restituisce una copia della lista has_preferred_label""" - return list(set(self.has_preferred_label)) + """Return a de-duplicated copy of has_preferred_label, in insertion order.""" + return list(dict.fromkeys(self.has_preferred_label)) def set_has_alternative_label(self, literal): - """Aggiunge un literal a has_alternative_label""" + """Add a literal to has_alternative_label.""" self.has_alternative_label.append(literal) def get_has_alternative_label(self): - """Restituisce una copia della lista has_alternative_label""" - return list(set(self.has_alternative_label)) + """Return a de-duplicated copy of has_alternative_label, in insertion order.""" + return list(dict.fromkeys(self.has_alternative_label)) def set_has_hidden_label(self, literal): - """Aggiunge un literal a has_hidden_label""" + """Add a literal to has_hidden_label.""" self.has_hidden_label.append(literal) def get_has_hidden_label(self): - """Restituisce una copia della lista has_hidden_label""" - return list(set(self.has_hidden_label)) + """Return a de-duplicated copy of has_hidden_label, in insertion order.""" + return list(dict.fromkeys(self.has_hidden_label)) def set_has_notation(self, literal): - """Aggiunge un literal a has_notation""" + """Add a literal to has_notation.""" self.has_notation.append(literal) def get_has_notation(self): - """Restituisce una copia della lista has_notation""" - return list(set(self.has_notation)) + """Return a de-duplicated copy of has_notation, in insertion order.""" + return list(dict.fromkeys(self.has_notation)) def set_has_note(self, literal): - """Aggiunge un literal a has_note""" + """Add a literal to has_note.""" self.has_note.append(literal) def get_has_note(self): - """Restituisce una copia della lista has_note""" - return list(set(self.has_note)) + """Return a de-duplicated copy of has_note, in insertion order.""" + return list(dict.fromkeys(self.has_note)) def set_has_change_note(self, literal): - """Aggiunge un literal a has_change_note""" + """Add a literal to has_change_note.""" self.has_change_note.append(literal) def get_has_change_note(self): - """Restituisce una copia della lista has_change_note""" - return list(set(self._has_change_note)) + """Return a de-duplicated copy of has_change_note, in insertion order.""" + return list(dict.fromkeys(self.has_change_note)) def set_has_definition(self, literal): - """Aggiunge un literal a has_definition""" + """Add a literal to has_definition.""" self.has_definition.append(literal) def get_has_definition(self): - """Restituisce una copia della lista has_definition""" - return list(set(self.has_definition)) + """Return a de-duplicated copy of has_definition, in insertion order.""" + return list(dict.fromkeys(self.has_definition)) def set_has_editorial_note(self, literal): - """Aggiunge un literal a has_editorial_note""" + """Add a literal to has_editorial_note.""" self.has_editorial_note.append(literal) def get_has_editorial_note(self): - """Restituisce una copia della lista has_editorial_note""" - return list(set(self.has_editorial_note)) + """Return a de-duplicated copy of has_editorial_note, in insertion order.""" + return list(dict.fromkeys(self.has_editorial_note)) def set_has_example(self, literal): - """Aggiunge un literal a has_example""" + """Add a literal to has_example.""" self.has_example.append(literal) def get_has_example(self): - """Restituisce una copia della lista has_example""" - return list(set(self.has_example)) + """Return a de-duplicated copy of has_example, in insertion order.""" + return list(dict.fromkeys(self.has_example)) def set_has_history_note(self, literal): - """Aggiunge un literal a has_history_note""" + """Add a literal to has_history_note.""" self.has_history_note.append(literal) def get_has_history_note(self): - """Restituisce una copia della lista has_history_note""" - return list(set(self.has_history_note)) + """Return a de-duplicated copy of has_history_note, in insertion order.""" + return list(dict.fromkeys(self.has_history_note)) def set_has_scope_note(self, literal): - """Aggiunge un literal a has_scope_note""" + """Add a literal to has_scope_note.""" self.has_scope_note.append(literal) def get_has_scope_note(self): - """Restituisce una copia della lista has_scope_note""" - return list(set(self.has_scope_note)) + """Return a de-duplicated copy of has_scope_note, in insertion order.""" + return list(dict.fromkeys(self.has_scope_note)) def set_see_also(self, resource): - """Aggiunge una risorsa a see_also""" + """Add a resource to see_also.""" self.see_also.append(resource) def get_see_also(self): - """Restituisce una copia della lista see_also""" - return list(set(self.see_also)) + """Return a de-duplicated copy of see_also, in insertion order.""" + return list(dict.fromkeys(self.see_also)) def set_is_defined_by(self, resource): - """Aggiunge una risorsa a is_defined_by""" + """Add a resource to is_defined_by.""" self.is_defined_by.append(resource) def get_is_defined_by(self): - """Restituisce una copia della lista is_defined_by""" - return list(set(self.is_defined_by)) + """Return a de-duplicated copy of is_defined_by, in insertion order.""" + return list(dict.fromkeys(self.is_defined_by)) def set_has_version_info(self, resource): - """Aggiunge una risorsa a has_version_info""" + """Add a resource to has_version_info.""" self.has_version_info.append(resource) def get_has_version_info(self): - """Restituisce una copia della lista has_version_info""" - return list(set(self.has_version_info)) + """Return a de-duplicated copy of has_version_info, in insertion order.""" + return list(dict.fromkeys(self.has_version_info)) def set_has_type(self, concept): + """Add a concept to has_type, ignoring duplicates.""" if concept not in self.has_type: self.has_type.append(concept) def get_has_type(self): + """Return a copy of has_type.""" return list(self.has_type) def set_is_included_in(self, model): - """Aggiunge un model a is_included_in""" + """Add a model to is_included_in.""" self.is_included_in.append(model) def get_is_included_in(self): - """Restituisce una copia della lista is_included_in""" - return list(set(self.is_included_in)) - - def set_has_contributor(self, literal): - self.has_contributor.append(literal) - - def get_has_contributor(self): - return list(set(self.has_contributor)) - - def set_has_creator(self, literal): - self.has_creator.append(literal) - - def get_has_creator(self): - return list(set(self.has_creator)) + """Return a de-duplicated copy of is_included_in, in insertion order.""" + return list(dict.fromkeys(self.is_included_in)) def get_also_defined_as(self): - """Returns the list of also defined as""" + """Return a copy of also_defined_as.""" return list(self.also_defined_as) def set_also_defined_as(self, resource): - """Aggiunge un Resource alla lista di also_defined_as""" + """Add a resource to also_defined_as.""" self.also_defined_as.append(resource) def set_is_in_range_of(self, prop): + """Add a property to is_in_range_of, ignoring duplicates.""" if prop not in self.is_in_range_of: self.is_in_range_of.append(prop) def get_is_in_range_of(self): + """Return a copy of is_in_range_of.""" return list(self.is_in_range_of) \ No newline at end of file diff --git a/lode/models/restriction.py b/lode/models/restriction.py index 5d7f518..53c1a90 100644 --- a/lode/models/restriction.py +++ b/lode/models/restriction.py @@ -1,7 +1,10 @@ +"""Restriction: base class of OWL restrictions and anonymous class expressions.""" from .concept import Concept class Restriction(Concept): + """Base of the anonymous classes built by restrictions and logical operators.""" def __init__(self, **kwargs): + """Create a restriction; all state comes from Concept.""" super().__init__(**kwargs) diff --git a/lode/models/rule.py b/lode/models/rule.py index 07e308a..357d5d8 100644 --- a/lode/models/rule.py +++ b/lode/models/rule.py @@ -1,13 +1,27 @@ +"""Rule: a SWRL rule.""" from .resource import Resource class Rule(Resource): + """A SWRL rule: when every antecedent atom holds, the consequent atoms hold.""" + def __init__(self, **kwargs): + """Create a rule with no antecedent and no consequent.""" super().__init__(**kwargs) self.has_antecedent = [] # Atom [1..*] self.has_consequent = [] # Atom [1..*] - def get_has_antecedent(self): return list(self.has_antecedent) - def set_has_antecedent(self, atom): self.has_antecedent.append(atom) + def get_has_antecedent(self): + """Return a copy of has_antecedent.""" + return list(self.has_antecedent) + + def set_has_antecedent(self, atom): + """Add an atom to has_antecedent.""" + self.has_antecedent.append(atom) + + def get_has_consequent(self): + """Return a copy of has_consequent.""" + return list(self.has_consequent) - def get_has_consequent(self): return list(self.has_consequent) - def set_has_consequent(self, atom): self.has_consequent.append(atom) \ No newline at end of file + def set_has_consequent(self, atom): + """Add an atom to has_consequent.""" + self.has_consequent.append(atom) \ No newline at end of file diff --git a/lode/models/statement.py b/lode/models/statement.py index 8462c01..246faa3 100644 --- a/lode/models/statement.py +++ b/lode/models/statement.py @@ -1,8 +1,11 @@ +"""Statement: a reified RDF triple.""" from .resource import Resource class Statement(Resource): + """An RDF triple (subject, predicate, object) described as a resource, so it can be documented.""" def __init__(self, **kwargs): + """Create a positive statement with no subject, predicate or object.""" super().__init__(**kwargs) self.is_positive_statement = True # bool [1] @@ -12,35 +15,35 @@ def __init__(self, **kwargs): # Attributes def get_is_positive_statement(self): - """Restituisce il valore is_positive_statement [1]""" + """Return is_positive_statement.""" return self.is_positive_statement def set_is_positive_statement(self, statement): - """Setta il valore di a is_positive_statement [1]""" + """Set is_positive_statement.""" self.is_positive_statement = statement # Relations with Resource def get_has_subject(self): - """Restituisce il valore has_subject [1]""" + """Return has_subject.""" return self.has_subject def set_has_subject(self, statement): - """Setta il valore di a has_subject [1]""" + """Set has_subject.""" self.has_subject = statement def get_has_object(self): - """Restituisce il valore has_object [1]""" + """Return has_object.""" return self.has_object def set_has_object(self, statement): - """Setta il valore di a has_object [1]""" + """Set has_object.""" self.has_object = statement # Relation with Property def get_has_predicate(self): - """Restituisce il valore has_predicate [1]""" + """Return has_predicate.""" return self.has_predicate def set_has_predicate(self, statement): - """Setta il valore di a has_predicate [1]""" + """Set has_predicate.""" self.has_predicate = statement diff --git a/lode/models/truthFunction.py b/lode/models/truthFunction.py index 2e1b69a..99ab47e 100644 --- a/lode/models/truthFunction.py +++ b/lode/models/truthFunction.py @@ -1,29 +1,32 @@ +"""TruthFunction: a class expression built with a logical operator (and, or, not).""" from .restriction import Restriction class TruthFunction(Restriction): + """A class expression combining concepts with a logical operator (and, or, not).""" # the has_cardinality_type can have one of three values: "max", "min", and "exact". Any other string will be interpreted as "exact". # fallback = exact def __init__(self, **kwargs): + """Create a truth function with no operator and no concepts.""" super().__init__(**kwargs) self.has_logical_operator = None # string[1] self.applies_on_concept = [] # 1..* def get_has_logical_operator(self): - """Restituisce la stringa per has_logical_operator""" + """Return has_logical_operator.""" return self.has_logical_operator def set_has_logical_operator(self, literal): - """Aggiunge una str a as_logical_operator""" + """Set has_logical_operator.""" self.has_logical_operator = literal def get_applies_on_concept(self): - """Restituisce la lista applies on concept""" + """Return a copy of applies_on_concept.""" return list(self.applies_on_concept) def set_applies_on_concept(self, concept): - """Aggiunge un Concept a applies_on_concept """ + """Add a concept to applies_on_concept.""" self.applies_on_concept.append(concept) diff --git a/lode/models/value.py b/lode/models/value.py index cd75ffc..262722c 100644 --- a/lode/models/value.py +++ b/lode/models/value.py @@ -1,23 +1,28 @@ +"""Value: an owl:hasValue restriction.""" from .restriction import Restriction class Value(Restriction): + """A restriction requiring a property to take one specific resource as value (owl:hasValue).""" def __init__(self, **kwargs): + """Create a value restriction with no resource and no property.""" super().__init__(**kwargs) self.applies_on_resource = None # 1 self.applies_on_property = None # 1 def get_applies_on_resource(self): - """Restituisce la lista applies on resource""" + """Return applies_on_resource.""" return self.applies_on_resource def set_applies_on_resource(self, resource): - """Aggiunge un Resource a applies_on_resource """ + """Set applies_on_resource.""" self.applies_on_resource = resource def get_applies_on_property(self): + """Return applies_on_property.""" return self.applies_on_property def set_applies_on_property(self, prop): + """Set applies_on_property.""" self.applies_on_property = prop \ No newline at end of file diff --git a/lode/models/variable.py b/lode/models/variable.py index 26a3294..8777eca 100644 --- a/lode/models/variable.py +++ b/lode/models/variable.py @@ -1,5 +1,9 @@ +"""Variable: a variable of a SWRL rule.""" from .resource import Resource class Variable(Resource): + """A SWRL variable, used as an argument of an atom.""" + def __init__(self, **kwargs): + """Create a variable; all state comes from Resource.""" super().__init__(**kwargs) \ No newline at end of file diff --git a/lode/reader/config/old_base.yaml b/lode/reader/config/old_base.yaml deleted file mode 100644 index ca61cd6..0000000 --- a/lode/reader/config/old_base.yaml +++ /dev/null @@ -1,599 +0,0 @@ -name: BASE - -# ====================== -# MAPPER -# ====================== -mapper: - # ========== Classification, no behavior ========== - - rdfs:Literal: - is: class - target_class: Literal - - # All definitions belonging to reasoning are excluded - # such as owl:bottomObjectPorperty owl:topObjectProperty, owl:bottomObjectProperty, owl:topDataProperty, owl:bottomDataProperty, owl:Thing, owl:Nothing - - - # rdfs:Container, rdfs:ContainerMembershipProperty, rdfs:member - # skos:memberList, skos:narrower [!!], skos:inScheme [!!!], skos:narrowerTransitive, skos:semanticRelation, skos:broaderTransitive, skos:mappingRelation - - rdfs:Datatype: - is: class - target_class: Datatype - - owl:ObjectProperty: - is: class - target_class: Relation - - rdf:Property: - is: class - target_class: Property - - owl:DatatypeProperty: - is: class - target_class: Attribute - - owl:AnnotationProperty: - is: class - target_class: Annotation - - owl:OntologyProperty: - is: class - target_class: Property - - owl:DeprecatedProperty: - is: class - target_class: Relation - - owl:Thing: - is: class - target_class: Individual - - # ========== Property characteristics (with behavior) ========== - owl:AsymmetricProperty: - is: class - target_class: Relation - setters: - - set_is_asymmetric: True - - owl:SymmetricProperty: - is: class - target_class: Relation - setters: [set_is_symmetric: True] - - owl:FunctionalProperty: - is: class - target_class: Property - setters: - - set_is_functional: True - - owl:InverseFunctionalProperty: - is: class - target_class: Relation - setters: [set_is_inverse_functional: True] - - owl:TransitiveProperty: - is: class - target_class: Relation - setters: [set_is_transitive: True] - - owl:IrreflexiveProperty: - is: class - target_class: Relation - setters: [set_is_irreflexive: True] - - owl:ReflexiveProperty: - is: class - target_class: Relation - setters: [set_is_reflexive: True] - - owl:NegativePropertyAssertion: - is: class - target_class: Statement - setters: [set_is_positive_statement: False] - - # ========== Datatypes ========== - owl:DataRange: - is: class - target_class: Datatype - - # ========== Model ========== - owl:Ontology: - is: class - target_class: Model - - skos:ConceptScheme: - is: class - target_class: Model - - # ========== Individuals ========== - owl:NamedIndividual: - is: class - target_class: Individual - - # ========== Statements ========== - owl:Annotation: - is: class - target_class: Statement - - owl:sourceIndividual: - is: predicate - target_classes: [Statement] - setters: [set_has_subject: Individual] - - owl:targetIndividual: - is: predicate - target_classes: [Statement] - setters: [set_has_object: Individual] - - owl:targetValue: - is: predicate - target_classes: [Statement] - setters: [set_has_object: Literal] - - # ========== Property ========== - rdfs:domain: - is: predicate - target_classes: [Property] - setters: [set_has_domain: Concept] - - rdfs:range: - is: predicate - target_classes: [Property] - # handler: handle_range - setters: [set_has_range: Resource] - - rdfs:subPropertyOf: - is: predicate - target_classes: [Property] - setters: [set_is_sub_property_of: Property] - - owl:propertyDisjointWith: - is: predicate - target_classes: [Property] - setters: [set_is_disjoint_with: Property] - - owl:equivalentProperty: - is: predicate - target_classes: [Property] - setters: [set_is_equivalent_to: Property] - - # ========== Concept ========== - rdfs:subClassOf: - is: predicate - target_classes: [Concept] - setters: [set_is_sub_concept_of: Concept] - - owl:disjointWith: - is: predicate - target_classes: [Concept] - setters: [set_is_disjoint_with: Concept] - - owl:equivalentClass: - is: predicate - target_classes: [Concept] - setters: [set_is_equivalent_to: Concept] - - # ========== Model ========== - owl:imports: - is: predicate - target_classes: [Model] - setters: [set_imports: Model] - - owl:versionIRI: - is: predicate - target_classes: [Model] - setters: [set_has_version: Model] - - owl:priorVersion: - is: predicate - target_classes: [Model] - setters: [set_has_prior_version: Model] - - owl:backwardCompatibleWith: - is: predicate - target_classes: [Model] - setters: [set_is_backward_compatible_with: Model] - - owl:incompatibleWith: - is: predicate - target_classes: [Model] - setters: [set_is_incompatible_with: Model] - - skos:hasTopConcept: - is: predicate - target_classes: [Model] - setters: [set_has_top_concept: Concept] - - # inverse skos:hasTopConcept - skos:isTopConceptOf: - is: predicate - target_classes: [Concept] - setter: [set_has_top_concept: Model] - - skos:inScheme: - is: predicate - target_classes: [Resource] - setters: [set_is_defined_by: Resource] - - # ========== Statement/Annotation ========== - - owl:annotatedProperty: - is: predicate - target_classes: [Statement] - setters: [set_has_predicate: Property] - - owl:annotatedSource: - is: predicate - target_classes: [Statement] - setters: [set_has_subject: Resource] - - owl:annotatedTarget: - is: predicate - target_classes: [Statement] - setters: [set_has_object: Resource] - - owl:assertionProperty: - is: predicate - target_classes: [Statement] - setters: [set_has_predicate: Property] - - # ========== Concepts ========== - - skos:Concept: - is: class - target_class: Concept - - owl:Class: - is: class - target_class: Concept - - rdfs:Class: - is: class - target_class: Concept - - owl:DeprecatedClass: - is: class - target_class: Concept - setters: [set_is_deprecated: True] - - # owl:Thing: - # is: class - # target_class: Individual - - # owl:Nothing: - # is: class - # target_class: Individual - - # ========== SKOS Concept Relations ========== - skos:broader: - is: predicate - target_classes: [Concept] - setters: [set_is_sub_concept_of: Concept] - - skos:narrower: - is: predicate - target_classes: [Concept] - handler: handle_narrower - - skos:related: - is: predicate - target_classes: [Concept] - setters: [set_is_related_to: Concept] - - skos:broadMatch: - is: predicate - target_classes: [Concept] - setters: [set_has_broad_match: Concept] - - skos:narrowMatch: - is: predicate - target_classes: [Concept] - setters: [set_has_narrow_match: Concept] - - skos:relatedMatch: - is: predicate - target_classes: [Concept] - setters: [set_has_related_match: Concept] - - skos:exactMatch: - is: predicate - target_classes: [Concept] - setters: [set_has_exact_match: Concept] - - skos:closeMatch: - is: predicate - target_classes: [Concept] - setters: [set_has_close_match: Concept] - - # ========== Collection ========== - - skos:Collection: - is: class - target_class: Collection - - skos:OrderedCollection: - is: class - target_class: Collection - setter: [is_ordered: True] - - skos:member: - is: predicate - target_classes: [Collection] - handler: handle_membership - - - # ========== Resource ========== - - rdfs:Resource: - is: class - target_class: Resource - - rdfs:label: - is: predicate - target_classes: [Resource] - setters: [set_has_label: Literal] - - rdfs:comment: - is: predicate - target_classes: [Resource] - setters: [set_has_comment: Literal] - - # ========== SKOS Resource to Literals ========== - skos:prefLabel: - is: predicate - target_classes: [Resource] - setters: [set_has_preferred_label: Literal] - - skos:altLabel: - is: predicate - target_classes: [Resource] - setters: [set_has_alternative_label: Literal] - - skos:hiddenLabel: - is: predicate - target_classes: [Resource] - setters: [set_has_hidden_label: Literal] - - skos:definition: - is: predicate - target_classes: [Resource] - setters: [set_has_definition: Literal] - - skos:example: - is: predicate - target_classes: [Resource] - setters: [set_has_example: Literal] - - skos:note: - is: predicate - target_classes: [Resource] - setters: [set_has_note: Literal] - - skos:scopeNote: - is: predicate - target_classes: [Resource] - setters: [set_has_scope_note: Literal] - - skos:historyNote: - is: predicate - target_classes: [Resource] - setters: [set_has_history_note: Literal] - - skos:editorialNote: - is: predicate - target_classes: [Resource] - setters: [set_has_editorial_note: Literal] - - skos:changeNote: - is: predicate - target_classes: [Resource] - setters: [set_has_change_note: Literal] - - skos:notation: - is: predicate - target_classes: [Resource] - setters: [set_has_notation: Literal] - - # ========== Resource Object Properties ========== - rdfs:seeAlso: - is: predicate - target_classes: [Resource] - setters: [set_see_also: Resource] - - rdfs:isDefinedBy: - is: predicate - target_classes: [Model, Resource] - setters: [set_is_defined_by: Resource] - - owl:versionInfo: - is: predicate - target_classes: [Resource] - setters: [set_has_version_info: Resource] - - # ========== Boolean Properties ========== - owl:deprecated: - is: predicate - target_classes: [Resource] - setters: [set_is_deprecated: True] - - # ========== Concepts - Differenza da base ========== - owl:Axiom: - is: class - target_class: Restriction - - owl:Restriction: - is: class - target_class: Restriction - - # ========== Relation - Solo in OWL ========== - owl:inverseOf: - is: predicate - target_classes: [Relation] - setters: [set_is_inverse_of: Relation] - - owl:propertyChainAxiom: - is: predicate - target_classes: [Relation] - handler: handle_property_chain - - # ========== TRUTH FUNCTIONS - Con handlers (looks for different subjects concepts or truth function)========== - owl:intersectionOf: - is: predicate - inferred_class: TruthFunction - target_classes: [TruthFunction, Concept] - handler: handle_intersection - - owl:unionOf: - is: predicate - inferred_class: TruthFunction - target_classes: [TruthFunction, Concept] - handler: handle_union - - owl:complementOf: - is: predicate - inferred_class: TruthFunction - target_classes: [TruthFunction, Concept] - handler: handle_complement - - # ========== VALUE - with setter ========== - owl:hasValue: - is: predicate - inferred_class: Value - target_classes: [Value] - setters: - [set_applies_on_resource: Resource] - - # ========== ONE OF - with setter ========== - owl:oneOf: - is: predicate - inferred_class: OneOf - target_classes: [OneOf, Concept] - handler: handle_one_of - - # ========== QUANTIFIERS - Con handlers ========== - # owl:someValuesFrom: - # is: predicate - # inferred_class: Quantifier - # target_classes: [Quantifier] - # handler: handle_quantifier_exist - - # owl:allValuesFrom: - # is: predicate - # inferred_class: Quantifier - # target_classes: [Quantifier] - # handler: handle_quantifier_all - - owl:someValuesFrom: - is: predicate - inferred_class: Quantifier - target_classes: [Quantifier] - setters: - - set_has_quantifier_type: "some" - # needed as it is inferred, not explicitly declared - - set_applies_on_concept: Concept - - owl:allValuesFrom: - is: predicate - inferred_class: Quantifier - target_classes: [Quantifier] - setters: - - set_has_quantifier_type: "only" - # needed as it is inferred, not explicitly declared - - set_applies_on_concept: Concept - - # ========== CARDINALITY - Con handlers ========== - owl:cardinality: - is: predicate - inferred_class: Cardinality - target_classes: [Cardinality] - # needs an handler since it defaults set_applies_on_concept to OWL.Thing - handler: handle_cardinality_exactly - - owl:minCardinality: - is: predicate - inferred_class: Cardinality - target_classes: [Cardinality] - # needs an handler since it defaults set_applies_on_concept to OWL.Thing - handler: handle_cardinality_min - - owl:maxCardinality: - is: predicate - inferred_class: Cardinality - target_classes: [Cardinality] - # needs an handler since it defaults set_applies_on_concept to OWL.Thing - handler: handle_cardinality_min - - owl:qualifiedCardinality: - is: predicate - inferred_class: Cardinality - target_classes: [Cardinality] - setters: - - set_has_cardinality_type: "exactly" - # need set_applies_on_concept as it is inferred, not explicitly declared - - set_applies_on_concept: Concept - - owl:minQualifiedCardinality: - is: predicate - inferred_class: Cardinality - target_classes: [Cardinality] - setters: - - set_has_cardinality_type: "min" - # need set_applies_on_concept as it is inferred, not explicitly declared - - set_applies_on_concept: Concept - - owl:maxQualifiedCardinality: - is: predicate - inferred_class: Cardinality - target_classes: [Cardinality] - setters: - - set_has_cardinality_type: "max" - # need set_applies_on_concept as it is inferred, not explicitly declared - - set_applies_on_concept: Concept - - # ========== Restrictions - Target diverso ========== - owl:onProperty: - is: predicate - target_classes: [Restriction] - setters: [set_applies_on_property: Property] - - owl:onClass: - is: predicate - target_classes: [Restriction] - setters: [set_applies_on_concept: Concept] - - owl:onDatatype: - is: predicate - target_classes: [Restriction] - setters: [set_applies_on_concept: Datatype] - - owl:onDataRange: - is: predicate - target_classes: [Restriction] - setters: [set_applies_on_concept: Datatype] - - # ========== Individual - Target diverso ========== - owl:sameAs: - is: predicate - target_classes: [Individual] - setters: [set_is_same_as: Individual] - - owl:differentFrom: - is: predicate - target_classes: [Individual] - setters: [set_is_different_from: Individual] - - rdf:type: - is: predicate - target_classes: [Individual, Resource] - setters: [set_has_type: Concept] - -# ====================== -# ENRICHER -# ====================== -enricher: - owl:AllDisjointClasses: process_all_disjoint_classes - owl:AllDifferent: process_all_different - owl:AllDisjointProperties: process_all_disjoint_properties - rdfs:range: infer_range - rdfs:domain: infer_domain \ No newline at end of file diff --git a/lode/reader/config_manager.py b/lode/reader/config_manager.py index b553ea8..7702771 100644 --- a/lode/reader/config_manager.py +++ b/lode/reader/config_manager.py @@ -1,4 +1,9 @@ -# config_magarer.py - STRATEGIE CON CONFIG UNIFICATA +"""Configuration managers: bind each artefact type to its YAML mapper, logic and viewer. + +The mapping from RDF/OWL/SKOS constructs to the model lives in the YAML files +under reader/config, not in Python: a manager loads base.yaml, merges the +type-specific file on top and exposes the result to the logic classes. +""" import yaml from pathlib import Path @@ -7,25 +12,34 @@ from lode.models import * class ConfigManager(ABC): - """Strategia basata su config YAML unico""" - + """Strategy driven by a single merged YAML configuration. + + Subclasses only say which config file they use and how to build their + logic and viewer; parsing and caching of the mappings is shared here. + """ + def __init__(self): + """Load and merge the YAML configuration; mappings are parsed lazily.""" self.config = self._load_config() self._type_mapping_cache = None self._property_mapping_cache = None @abstractmethod def create_logic(self, graph: Graph, cache: dict): - """Factory method per creare Logic specifica""" + """Factory method: build the logic specific to this artefact type.""" pass @property @abstractmethod def config_name(self) -> str: - """Nome del file config specifico: 'owl' | 'skos' | 'rdfs'""" + """Name of the type-specific config file: 'owl' | 'skos' | 'rdf'.""" pass def _load_config(self) -> dict: + """Read base.yaml and the type-specific YAML, and return them deep-merged. + + Files are looked up next to this module so the config ships inside the wheel. + """ config_dir = Path(__file__).parent / 'config' with open(config_dir / 'base.yaml') as f: @@ -37,6 +51,10 @@ def _load_config(self) -> dict: return self._deep_merge(base, specific) def _deep_merge(self, base: dict, override: dict) -> dict: + """Merge `override` into a copy of `base`, recursing into nested dicts. + + The 'name' and 'inherits' keys only describe the file and are not merged. + """ result = base.copy() for key, value in override.items(): if key in ('name', 'inherits'): @@ -47,18 +65,9 @@ def _deep_merge(self, base: dict, override: dict) -> dict: result[key] = value return result - # def _load_config(self) -> dict: - # """Carica config da file YAML unico""" - # config_path = Path(__file__).parent / 'config' / 'base.yaml' - - # with open(config_path, 'r') as f: - # config = yaml.safe_load(f) - - # return config - @abstractmethod def create_viewer(self, reader): - """Factory method per creare Viewer specifico""" + """Factory method: build the viewer specific to this artefact type.""" pass # ========== NAMESPACE MAP ========== @@ -80,6 +89,10 @@ def _parse_uri(self, uri_str: str) -> URIRef: return URIRef(self.NAMESPACES[prefix] + local) def _parse_class(self, class_name: str): + """Resolve a model class name from the YAML into the class itself. + + Raises KeyError for an unknown name, so a typo in the config fails loudly. + """ import lode.models as _models cls = getattr(_models, class_name, None) if cls is None: @@ -87,6 +100,10 @@ def _parse_class(self, class_name: str): return cls def _parse_value(self, value): + """Convert a setter argument from the YAML: model class names become classes. + + 'Literal', booleans and strings that are not model classes are kept as they are. + """ if value == 'Literal': return 'Literal' elif value is True or value is False: @@ -121,7 +138,7 @@ def get_property_mapping(self) -> dict[URIRef, dict]: return self._property_mapping_cache def _parse_config(self, cfg: dict) -> dict: - """Parse un blocco di config""" + """Parse one mapper entry: resolve class names (and setter arguments) into classes.""" parsed = cfg.copy() if 'target_class' in parsed: @@ -152,6 +169,10 @@ def get_group_axioms(self) -> dict[URIRef, str]: } def get_fallback_class(self) -> type | None: + """Return the class given to resources no mapping recognised, or None if unset. + + An unknown class name in the config falls back to Statement. + """ fallback = self.config.get('mapper', {}).get('fallback_class') if fallback: import lode.models as _models @@ -161,21 +182,21 @@ def get_fallback_class(self) -> type | None: # ========== HELPER METHODS ========== def get_classifier_predicates(self) -> set[URIRef]: - """Predicati con 'inferred_class'""" + """Predicates whose config declares an 'inferred_class'.""" return { pred for pred, cfg in self.get_property_mapping().items() if 'inferred_class' in cfg } def classify_by_predicate(self, uri: Node, graph: Graph) -> type | None: - """Classifica guardando predicati. - - inferred_class: tipo del soggetto (BNode restrictions, quantifiers, etc.) -> priorita' massima - Se piu' predicati hanno inferred_class, vince la classe piu' specifica (issubclass). - - target_classes con 1 elemento, nessun inferred_class, classify != false: - tipo implicito del soggetto (fallback) - - target_classes con 2+ elementi: ambiguo, gestito dall'handler -> None - - classify: false nel config: il predicato non classifica il soggetto, - serve solo per i setters in phase3 (es. facet XSD) + """Classify a subject by looking at the mapped predicates it uses. + - inferred_class: type of the subject (BNode restrictions, quantifiers, etc.) -> highest priority. + If several predicates have an inferred_class, the most specific class wins (issubclass). + - target_classes with 1 element, no inferred_class, classify != false: + implicit type of the subject (fallback) + - target_classes with 2+ elements: ambiguous, left to the handler -> None + - classify: false in the config: the predicate does not classify the subject, + it is only used by the setters in phase3 (e.g. XSD facets) """ fallback = None inferred = None @@ -191,50 +212,65 @@ def classify_by_predicate(self, uri: Node, graph: Graph) -> type | None: return inferred if inferred else fallback def get_punning_priority(self): + """Return the classes in the order that decides which one wins on a punned IRI.""" names = self.config.get('punning_priority', []) return [self._parse_class(n) for n in names] -# config_manager.py - aggiungi nei concrete managers +# ========== CONCRETE MANAGERS ========== class OwlConfigManager(ConfigManager): + """Configuration for OWL artefacts.""" @property def config_name(self) -> str: + """Use owl.yaml on top of base.yaml.""" return 'owl' - + def create_logic(self, graph: Graph, cache: dict): + """Build the OWL logic.""" from lode.reader.logic import OwlLogic return OwlLogic(graph, cache, self) def create_viewer(self, reader): + """Build the OWL viewer.""" from lode.viewer import OwlViewer return OwlViewer(reader) class RdfConfigManager(ConfigManager): + """Configuration for RDF/RDFS artefacts.""" + @property def config_name(self) -> str: + """Use rdf.yaml on top of base.yaml.""" return 'rdf' def create_logic(self, graph: Graph, cache: dict): + """Build the RDF logic.""" from lode.reader.logic import RdfLogic return RdfLogic(graph, cache, self) def create_viewer(self, reader): + """Build the RDF viewer.""" from lode.viewer import RdfViewer return RdfViewer(reader) class SkosConfigManager(ConfigManager): + """Configuration for SKOS artefacts.""" + @property def config_name(self) -> str: + """Use skos.yaml on top of base.yaml.""" return 'skos' def create_logic(self, graph: Graph, cache: dict): + """Build the SKOS logic.""" from lode.reader.logic import SkosLogic return SkosLogic(graph, cache, self) def create_viewer(self, reader): + """Build the SKOS viewer.""" from lode.viewer import SkosViewer return SkosViewer(reader) @@ -248,6 +284,10 @@ def create_viewer(self, reader): def get_configuration(configuration_name: str) -> ConfigManager: + """Return a new manager for the given artefact type (case-insensitive). + + Raises ValueError listing the available types when the name is unknown. + """ key = configuration_name.upper() if key not in CONFIGURATION_REGISTRY: available = ', '.join(CONFIGURATION_REGISTRY.keys()) diff --git a/lode/reader/loader.py b/lode/reader/loader.py index 7fac94f..8d25027 100644 --- a/lode/reader/loader.py +++ b/lode/reader/loader.py @@ -1,5 +1,7 @@ -""" -Reader - Caricamento e validazione file RDF +"""Loading and validation of RDF artefacts, from a local file or from a URL. + +Every security check on remote content (SSRF, size, text-only, XXE) runs here, +before the RDF parser sees any byte. """ import requests import os @@ -15,10 +17,19 @@ from lode.reader.provenance_graph import ProvenanceGraph class Loader: - """Gestisce il caricamento di file RDF""" + """Load an RDF artefact into a graph, optionally together with its imports. + + The loaded graph is a plain Graph, or a ProvenanceGraph (one named graph per + ontology) when imported, closure or partial_import is requested. + """ def __init__(self, file_path: Optional[str] = None, imported=None, closure=None, partial_import=None): - + """Prepare the loader and, when `file_path` is given, load it immediately. + + `imported`, `closure` and `partial_import` select the module applied + after loading (see lode.reader.modules); at most one of them is used. + """ + self.graph = Graph() self.ontology_iri = None self._imported = imported @@ -38,7 +49,6 @@ def load(self, source: str) -> None: if self._is_url(source): self._load_from_url_with_content_negotiation(source) - # ADD BY VALE TO FIX WINDOWS LOCAL BUG - COMMENTED FOR PRODUCTION elif os.path.isfile(source): self._load_from_local_file(source) else: @@ -69,6 +79,11 @@ def load(self, source: str) -> None: # ---------------------------------------------------------- def _apply_modules(self) -> None: + """Apply the requested import module to the loaded graph. + + Precedence: partial_import, then imported, then closure. Also records the + IRIs that were imported, so the viewer can keep the original model metadata. + """ if self._partial_import: self.graph, self.imported_uris = modules.apply_partial_import(self.graph) elif self._imported: @@ -112,6 +127,17 @@ def _load_from_url_with_content_negotiation(self, url: str) -> None: context={"url": url, "http_status": response.status_code} ) + # Reject HTML/XHTML pages before downloading: servers answer with a + # human-readable page when no RDF serialization is available. + content_type = response.headers.get("Content-Type", "").lower() + mime = content_type.split(";")[0].strip() + if mime in ("text/html", "application/xhtml+xml"): + response.close() + raise ArtefactLoadError( + "Resource is an HTML page, not an RDF serialization", + context={"url": url, "content_type": mime}, + ) + # SECURITY: reject by declared Content-Length before downloading the body declared = response.headers.get("Content-Length") if declared and declared.isdigit(): @@ -153,6 +179,11 @@ def _load_from_url_with_content_negotiation(self, url: str) -> None: # LOCAL FILE LOADING # ---------------------------------------------------------- def _load_from_local_file(self, path: str) -> None: + """Parse a local file, trying every known RDF format. + + Raises ArtefactLoadError if no format can parse it. The caller is expected + to have excluded URLs: this method opens `path` on the local disk. + """ with open(path, "rb") as f: raw = f.read() graph = parse_any(raw) @@ -165,12 +196,17 @@ def _load_from_local_file(self, path: str) -> None: # HELPERS # ---------------------------------------------------------- def _is_url(self, s: str) -> bool: + """Return True only for http(s) URLs; other schemes are not treated as URLs.""" try: return urlparse(s).scheme in ("http", "https") except Exception: return False def _guess_format_from_content_type(self, content_type: str) -> Optional[str]: + """Map an HTTP Content-Type to an rdflib format name, or None when unknown. + + The result is only a hint: parse_any still falls back to the other formats. + """ if "text/turtle" in content_type or "application/x-turtle" in content_type: return "turtle" if "application/rdf+xml" in content_type: @@ -182,6 +218,7 @@ def _guess_format_from_content_type(self, content_type: str) -> Optional[str]: return None def get_graph(self) -> Graph: + """Return the loaded graph (a ProvenanceGraph when modules were applied).""" return self.graph def _fetch_following_redirects(self, url: str, headers: dict, max_redirects: int = security.MAX_REDIRECTS): diff --git a/lode/reader/logic/base_logic.py b/lode/reader/logic/base_logic.py index 6f74494..1224d35 100644 --- a/lode/reader/logic/base_logic.py +++ b/lode/reader/logic/base_logic.py @@ -1,4 +1,8 @@ -# base_logic.py +"""Format-independent core of the extraction logic. + +BaseLogic turns the triples of a graph into model instances in phases. What is +specific to OWL, RDF or SKOS lives in the subclasses and in the YAML mapper. +""" from abc import ABC, abstractmethod from rdflib import Graph, URIRef, Node, Literal as RDFlibLiteral, BNode from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD, Namespace @@ -9,12 +13,23 @@ from lode.models import * from lode.helpers.rdf import iri_local_name, iri_namespace +import logging +logger = logging.getLogger(__name__) + class BaseLogic(ABC): """ - Logica base comune per parsing RDF. + Common base logic for RDF parsing. + + Owns the instance cache, the triples-to-instance provenance map and the + generic machinery (get_or_create, populate_instance, SWRL, statements); + subclasses implement the phases that differ between formats. """ def __init__(self, graph: Graph, instance_cache: dict, strategy): + """Bind the logic to a graph, a shared instance cache and a config manager. + + Fails fast if the config names a handler this logic does not implement. + """ self.graph = graph self._instance_cache = instance_cache self._strategy = strategy @@ -28,10 +43,12 @@ def __init__(self, graph: Graph, instance_cache: dict, strategy): self._warnings = [] def add_warning(self, code, subject, message): + """Record a reader warning with a machine-readable code, its subject and a message.""" self._warnings.append({'code': code, 'subject': str(subject), 'message': message}) def get_warnings(self): - if not getattr(self, '_warnings_enabled', False): # self qui Γ¨ la LOGIC + """Return the collected warnings, or [] when they were not requested.""" + if not getattr(self, '_warnings_enabled', False): # self is the logic here return [] def save_warnings(self, filepath: str = None): @@ -49,6 +66,7 @@ def save_warnings(self, filepath: str = None): json.dump(self._warnings, f, indent=2, ensure_ascii=False) def _get_allowed_classes(self) -> set: + """Resolve the 'allowed_classes' names of the config into the model classes.""" class_names = self._strategy.config.get('allowed_classes', []) import lode.models as _models return {getattr(_models, name) for name in class_names if getattr(_models, name, None) is not None} @@ -66,25 +84,35 @@ def get_namespaces(self) -> dict: @abstractmethod def phase1_classify_from_predicates(self): + """Phase 1: create instances for subjects whose predicates reveal their class.""" pass @abstractmethod def phase2_create_from_types(self): + """Phase 2: create instances from the rdf:type declarations of the graph.""" pass @abstractmethod def phase3_populate_properties(self): + """Phase 3: fill every instance from its triples, through the property mapping.""" pass @abstractmethod def phase4_process_group_axioms(self): + """Phase 4: process axioms that involve several entities at once (e.g. AllDisjointClasses).""" pass @abstractmethod def phase5_fallback(self): + """Phase 5: give a class (and the format defaults) to whatever is still unclassified.""" pass def phase6_create_statements(self): + """Phase 6: reify every triple no earlier phase consumed as a Statement. + + Structural list triples and structural vocabulary subjects without a type + are skipped; triples already tracked by an instance are not duplicated. + """ for subj, pred, obj in self.graph: # if pred in self._property_mapping: # continue @@ -100,13 +128,13 @@ def phase6_create_statements(self): continue self._create_statement_for_triple(subj, pred, obj) - # ========== VALIDAZIONE CONFIG -> LOGIC ========== + # ========== CONFIG -> LOGIC VALIDATION ========== def _validate_handlers(self): """ - Fail-fast: verifica che tutti gli handler dichiarati nel config - esistano come metodi su questa istanza Logic. - Solleva AttributeError subito, prima di qualsiasi parsing. + Fail-fast: check that every handler declared in the config + exists as a method on this Logic instance. + Raises AttributeError immediately, before any parsing. """ errors = [] @@ -131,7 +159,7 @@ def _validate_handlers(self): + "\n".join(f" - {e}" for e in errors) ) - # ========== RISOLUZIONE CLASSI AMMESSE ========== + # ========== ALLOWED CLASS RESOLUTION ========== def _resolve_allowed_class(self, python_class: type, id: Node = None) -> type: """ @@ -145,7 +173,7 @@ def _resolve_allowed_class(self, python_class: type, id: Node = None) -> type: if python_class in self._allowed_classes: return python_class - # Hook per logica custom pre-MRO (es. OWL: controlla cache) + # Hook for custom pre-MRO logic (e.g. OWL: check the cache) resolved = self._pre_resolve_hook(python_class, id) if resolved: return resolved @@ -154,41 +182,41 @@ def _resolve_allowed_class(self, python_class: type, id: Node = None) -> type: if parent_class in self._allowed_classes: return parent_class - print(f" [WARN] {python_class.__name__} -> Resource (fallback finale)") + logger.warning("%s fell back to Resource", python_class.__name__) return Resource def _pre_resolve_hook(self, python_class: type, id: Node) -> type | None: """ - Hook opzionale per logica custom pre-MRO. - Le subclass possono override senza toccare _resolve_allowed_class. - Ritorna None per delegare al comportamento base. + Optional hook for custom pre-MRO logic. + Subclasses can override it without touching _resolve_allowed_class. + Returns None to delegate to the base behaviour. """ return None # ========== Namespaces resolution ========== def populate_namespaces(self): - """Attacca i prefissi del grafo a ogni Model in cache.""" + """Attach the graph prefixes to every cached Model.""" ns = {prefix: str(uri) for prefix, uri in self.graph.namespaces()} for instances in self._instance_cache.values(): for inst in instances: if isinstance(inst, Model): inst.set_has_namespaces(ns) - # ========== PROVENANCE ========== (nota: _triples_map viene scritto solo da questi tre metodi.) + # ========== PROVENANCE ========== (note: _triples_map is written only by these three methods.) def _track(self, instance, *triples): - """Attribuisce uno o piu' tripla alla provenance di `instance`.""" + """Attribute one or more triples to the provenance of `instance`.""" self._triples_map.setdefault(instance, set()).update(triples) def _untrack(self, instance, *triples): - """Rimuove tripla dalla provenance di `instance`.""" + """Remove triples from the provenance of `instance`.""" bucket = self._triples_map.get(instance) if bucket: bucket.difference_update(triples) def _move_tracking(self, old, new): - """Trasferisce la provenance quando un'istanza viene promossa/sostituita.""" + """Move the provenance from `old` to `new` when an instance is promoted or replaced.""" if old in self._triples_map: self._triples_map.setdefault(new, set()).update(self._triples_map.pop(old)) @@ -199,8 +227,8 @@ def _traverse_hierarchy( start: object, next_getter: str, direction: str = "up", # "up" | "down" | "both" - collect: callable = None, # (node) -> value | None β€” ferma e ritorna quando non None - visit_all: callable = None, # (node) -> None β€” visita ogni nodo senza fermarsi + collect: callable = None, # (node) -> value | None β€” stops and returns when not None + visit_all: callable = None, # (node) -> None β€” visits every node without stopping ) -> object | None: """ Generic BFS traversal along a property hierarchy. @@ -276,6 +304,11 @@ def _traverse_hierarchy( def _create_literal(self, rdflib_literal): + """Return the model Literal for an rdflib literal, one per distinct lexical form. + + The instance is cached under a LITERAL:: key; language and datatype are + copied over when present. + """ literal_key = f"LITERAL::{rdflib_literal}" if literal_key in self._instance_cache: return next(iter(self._instance_cache[literal_key])) @@ -295,9 +328,11 @@ def _create_literal(self, rdflib_literal): return literal def _is_rdf_collection(self, node: Node) -> bool: + """Return True if the node is the head of an RDF list (it has an rdf:first).""" return (node, RDF.first, None) in self.graph def _instance_matches_target(self, instance, target_classes: list) -> bool: + """Return True if the instance is of one of the target classes, or a subclass of one.""" instance_class = instance.__class__ if instance_class in target_classes: return True @@ -307,6 +342,14 @@ def _instance_matches_target(self, instance, target_classes: list) -> bool: return False def _apply_setters(self, instance, setters_config, obj): + """Call the setters declared in the config on `instance`, using `obj` as the value. + + Per entry: 'Literal' builds a Literal from obj; a bool or a string is passed + as a constant; a model class resolves obj to an instance of that class (the + punned instance of the same class if one is cached); anything else passes + obj as is. An entry with no value calls the setter with no argument. + Structural vocabulary URIs are never added to has_type. + """ for setter_item in setters_config: if isinstance(setter_item, dict): for setter_name, value_type in setter_item.items(): @@ -343,6 +386,10 @@ def _apply_setters(self, instance, setters_config, obj): getattr(instance, setter_item)() def _handle_collection_object(self, instance, predicate, collection_uri): + """Resolve an RDF list used as an object and pass its members to the first matching setter. + + Failures are logged and swallowed: one malformed list must not stop the read. + """ try: collection = RDFLibCollection(self.graph, collection_uri) items = [] @@ -361,15 +408,24 @@ def _handle_collection_object(self, instance, predicate, collection_uri): if hasattr(instance, setter_name): getattr(instance, setter_name)(items) break - except Exception as e: - print(f"Errore Collection: {e}") + except Exception: + logger.warning("Collection handling failed on %s (predicate %s)", + instance, predicate, exc_info=True) def clear_cache(self): + """Empty the shared instance cache.""" self._instance_cache.clear() # ========== LOGIC CORE ========== def get_or_create(self, id: Node, python_class: type = None, populate: bool = True): + """Return the model instance for a node, creating (and populating) it when needed. + + Literals go through _create_literal; built-in datatypes and undeclared XSD + terms are always Datatype. A cached instance is reused; a single cached + instance of a broader class is promoted to the requested one, so punning + keeps working. Returns None, after logging, when creation fails. + """ try: if isinstance(id, RDFlibLiteral): @@ -394,7 +450,7 @@ def get_or_create(self, id: Node, python_class: type = None, populate: bool = Tr # if uri_str.startswith(ns) and id not in (OWL.Thing, OWL.Nothing, RDFS.Literal): # return None - # Individual punning: non sovrascrivere tipi esistenti non-Individual + # Individual punning: do not override existing non-Individual types if python_class == Individual and id in self._instance_cache: is_named_individual = (id, RDF.type, OWL.NamedIndividual) in self.graph if not is_named_individual: @@ -433,17 +489,25 @@ def get_or_create(self, id: Node, python_class: type = None, populate: bool = Tr return instance - except Exception as e: - print(f"Cannot create {python_class.__name__ if python_class else 'Unknown'} for {id}: {e}") + except Exception: + logger.warning("Cannot create %s for %s", + python_class.__name__ if python_class else "Unknown", id, exc_info=True) return None def populate_instance(self, instance, uri: Node): + """Fill an instance from the triples of `uri`, following the property mapping. + + Only predicates in the allowed namespaces are read. A configured handler or + setters list runs for each predicate; failures are logged and skipped. Every + handled triple is recorded in the provenance map, except the rdf:type that + selected the instance's class. + """ if isinstance(uri, URIRef): instance.set_has_identifier(str(uri)) elif isinstance(uri, BNode): instance.has_identifier = str(uri) - is_subordinate = self._is_punning_subordinate(instance, uri) + # is_subordinate = self._is_punning_subordinate(instance, uri) for predicate, obj in self.graph.predicate_objects(uri): if iri_namespace(str(predicate)) not in self._allowed_namespaces: @@ -454,23 +518,26 @@ def populate_instance(self, instance, uri: Node): if config is not None: target_classes = config.get('target_classes', []) - if is_subordinate and target_classes and type(instance) not in target_classes: - continue + #if is_subordinate and target_classes and type(instance) not in target_classes: + # continue if target_classes and not self._instance_matches_target(instance, target_classes): continue if 'handler' in config: try: - getattr(self, config['handler'])(instance, uri, predicate, obj, None) - handled = True - except Exception as e: - print(f" Errore handler {config['handler']}: {e}") + # An explicit False means "not admissible for this instance". + result = getattr(self, config['handler'])(instance, uri, predicate, obj, None) + handled = result is not False + except Exception: + logger.warning("Handler %s failed on %s (predicate %s)", + config['handler'], uri, predicate, exc_info=True) elif 'setters' in config: try: self._apply_setters(instance, config['setters'], obj) handled = True - except Exception as e: - print(f" Errore setters: {e}") + except Exception: + logger.warning("Setters failed on %s (predicate %s)", + uri, predicate, exc_info=True) if not handled and config is None and self._is_rdf_collection(obj): self._handle_collection_object(instance, predicate, obj) @@ -482,6 +549,7 @@ def populate_instance(self, instance, uri: Node): # ========== HELPERS ========== def _is_triple_mapped(self, subj, pred, obj) -> bool: + """Return True if some instance already tracks this triple in its provenance.""" if subj not in self._instance_cache: return False instances = self._instance_cache[subj] @@ -493,6 +561,11 @@ def _is_triple_mapped(self, subj, pred, obj) -> bool: return False def _convert_collection_to_container(self, collection_uri): + """Return the Container that represents an RDF list, building and caching it once. + + The container is cached before its members are resolved, so a list that + contains itself cannot loop forever. + """ if collection_uri in self._instance_cache: for cached in self._instance_cache[collection_uri]: if isinstance(cached, Container): @@ -516,13 +589,18 @@ def _convert_collection_to_container(self, collection_uri): if member_instance: members.append(member_instance) container.set_has_members(members) - except Exception as e: - print(f"Errore Collection: {e}") + except Exception: + logger.warning("Collection to container conversion failed for %s", + collection_uri, exc_info=True) return container def _create_statement_for_triple(self, subj, pred, obj): - + """Reify one triple as a Statement, unless its predicate is already mapped. + + Lists in object position become Containers and literals become Literals. + """ + if pred in self._property_mapping: return @@ -636,6 +714,10 @@ def _create_nested_statement(self, node): return statement def _get_punning_dominant(self, uri): + """Return the instance that wins when an IRI is used as several things (punning). + + The winner follows the config's punning priority; with no match, any cached one. + """ if uri not in self._instance_cache: return None cached = self._instance_cache[uri] @@ -651,12 +733,14 @@ def _get_punning_dominant(self, uri): return next(iter(cached)) def _is_punning_subordinate(self, instance, uri): + """Return True if `instance` loses against another instance of the same IRI.""" dom = self._get_punning_dominant(uri) return dom is not None and dom is not instance # ========== SWRL ========== def handle_swrl_imp(self, uri): + """Build a Rule from a swrl:Imp, with its body atoms as antecedent and head atoms as consequent.""" # Force promotion to Rule if cached as something else if uri in self._instance_cache: existing = next(iter(self._instance_cache[uri])) @@ -681,8 +765,8 @@ def handle_swrl_imp(self, uri): atom = self._parse_swrl_atom(atom_node) if atom: rule.set_has_antecedent(atom) - except Exception as e: - print(f"Errore body AtomList: {e}") + except Exception: + logger.warning("SWRL body parsing failed for rule %s", uri, exc_info=True) head_node = self.graph.value(uri, SWRL_NS.head) if head_node: @@ -691,10 +775,11 @@ def handle_swrl_imp(self, uri): atom = self._parse_swrl_atom(atom_node) if atom: rule.set_has_consequent(atom) - except Exception as e: - print(f"Errore head AtomList: {e}") + except Exception: + logger.warning("SWRL head parsing failed for rule %s", uri, exc_info=True) def _parse_swrl_atom(self, atom_node): + """Build an Atom from a SWRL atom node: its predicate (by atom kind) and its arguments.""" atom = self.get_or_create(atom_node, Atom) if not atom: return None @@ -742,18 +827,20 @@ def _parse_swrl_atom(self, atom_node): if arg2: atom.set_has_arguments(self._resolve_swrl_arg(arg2)) - # arguments (BuiltinAtom β€” lista RDF) + # arguments (BuiltinAtom β€” RDF list) args_node = self.graph.value(atom_node, SWRL_NS.arguments) if args_node: try: for arg in RDFLibCollection(self.graph, args_node): atom.set_has_arguments(self._resolve_swrl_arg(arg)) - except Exception as e: - print(f"Errore builtin arguments: {e}") + except Exception: + logger.warning("SWRL builtin arguments failed for atom %s", + atom_node, exc_info=True) return atom def _resolve_swrl_arg(self, node): + """Resolve a SWRL argument: a Literal, a Variable or, by default, an Individual.""" if isinstance(node, RDFlibLiteral): return self._create_literal(node) if (node, RDF.type, SWRL_NS.Variable) in self.graph: diff --git a/lode/reader/logic/owl_logic.py b/lode/reader/logic/owl_logic.py index f8ba505..f80af02 100644 --- a/lode/reader/logic/owl_logic.py +++ b/lode/reader/logic/owl_logic.py @@ -1,4 +1,4 @@ -# owl_logic.py +"""Extraction logic for OWL artefacts.""" from rdflib import Graph, URIRef, Node, Literal as RDFlibLiteral, BNode from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD @@ -8,6 +8,8 @@ from lode.reader.logic.base_logic import BaseLogic from lode.reader.warnings import owl_warnings +import logging +logger = logging.getLogger(__name__) class OwlLogic(BaseLogic): """ @@ -21,9 +23,9 @@ class OwlLogic(BaseLogic): - Property type inference for properties not explicitly typed in the artefact """ - # _get_allowed_namespaces: ereditato da BaseLogic, legge config YAML + # _get_allowed_namespaces: inherited from BaseLogic, reads the YAML config - # ========== HOOK per resolve custom OWL ========== + # ========== HOOK for custom OWL resolution ========== def _pre_resolve_hook(self, python_class: type, id: Node) -> type | None: """ @@ -75,6 +77,12 @@ def phase1_classify_from_predicates(self): self.get_or_create(uri, py_class, populate=False) def phase2_create_from_types(self): + """Create instances from rdf:type declarations. + + Subjects typed with a mapped type get the mapped class and its static + setters; subjects whose type is not mapped become Individuals, except for + structural vocabulary types. + """ type_mapping = self._strategy.get_type_mapping() for rdf_type, config in type_mapping.items(): @@ -164,14 +172,38 @@ def phase5_fallback(self): self._enrich_or_apply_owl_defaults(instance, uri) def handle_domain(self, instance, uri, predicate, obj, setter=None): + """Handle rdfs:domain: link the property to its domain and the domain back to the property. + + A domain that cannot become a Concept (it is cached as something else) is + forced into one, because of OWL punning. + """ concept = self.get_or_create(obj, Concept) if concept is None: concept = self._force_as_concept(obj) instance.set_has_domain(concept) concept.set_is_in_domain_of(instance) + def _is_data_range(self, node) -> bool: + """True if node denotes a data range: XSD/RDF built-in datatypes, rdfs:Literal, + anything typed rdfs:Datatype, or a datatype restriction BNode.""" + if (node, RDF.type, RDFS.Datatype) in self.graph: + return True + if isinstance(node, BNode): + return (node, OWL.onDatatype, None) in self.graph + return str(node).startswith(str(XSD)) or node in ( + RDFS.Literal, RDF.PlainLiteral, RDF.XMLLiteral, RDF.langString, RDF.HTML) def handle_range(self, instance, uri, predicate, obj, setter=None): + """Handle rdfs:range: set the range of a property and link the target back to it. + + An Attribute accepts only data ranges and a Relation only class ranges: any + other triple returns False, so it is not tracked for that instance. + """ + # Punning: each instance takes only the ranges its class admits. + # Returning False marks the triple as not admissible (not tracked). + is_data = self._is_data_range(obj) + if isinstance(instance, Attribute) != is_data and isinstance(instance, (Attribute, Relation)): + return False if isinstance(instance, Attribute): resource = self.get_or_create(obj, Datatype) else: @@ -182,7 +214,7 @@ def handle_range(self, instance, uri, predicate, obj, setter=None): resource.set_is_in_range_of(instance) def handle_equivalent_class(self, instance, uri, predicate, obj, setter=None): - """Β§9.1.2 EquivalentClasses β€” simmetria garantita.""" + """Β§9.1.2 EquivalentClasses β€” symmetry guaranteed.""" # add WARNING if not isinstance(instance, Concept): @@ -199,7 +231,7 @@ def handle_equivalent_class(self, instance, uri, predicate, obj, setter=None): other.set_is_equivalent_to(instance) def handle_disjoint_with(self, instance, uri, predicate, obj, setter=None): - """Β§9.1.3 DisjointClasses β€” simmetria garantita.""" + """Β§9.1.3 DisjointClasses β€” symmetry guaranteed.""" other = self.get_or_create(obj, Concept) if other: if other not in instance.get_is_disjoint_with(): @@ -207,7 +239,8 @@ def handle_disjoint_with(self, instance, uri, predicate, obj, setter=None): if instance not in other.get_is_disjoint_with(): other.set_is_disjoint_with(instance) - def handle_equivalent_property(self, instance, uri, predicate, obj, setter=None): + def handle_equivalent_property(self, instance, uri, predicate, obj, setter=None): + """Handle owl:equivalentProperty: link the two properties to each other (symmetric).""" if obj in self._instance_cache: other = self.get_or_create(obj, Property) else: @@ -219,7 +252,7 @@ def handle_equivalent_property(self, instance, uri, predicate, obj, setter=None) other.set_is_equivalent_to(instance) def handle_property_disjoint_with(self, instance, uri, predicate, obj, setter=None): - """Β§9.2.3 DisjointObjectProperties β€” simmetria garantita.""" + """Β§9.2.3 DisjointObjectProperties β€” symmetry guaranteed.""" other = self.get_or_create(obj, Property) if other: if other not in instance.get_is_disjoint_with(): @@ -241,7 +274,7 @@ def _ensure_individual(self, node): return ind def handle_same_as(self, instance, uri, predicate, obj, setter=None): - """Β§9.6.1 SameIndividual β€” simmetria garantita.""" + """Β§9.6.1 SameIndividual β€” symmetry guaranteed.""" subj_ind = self._ensure_individual(uri) other = self._ensure_individual(obj) if subj_ind and other: @@ -251,7 +284,7 @@ def handle_same_as(self, instance, uri, predicate, obj, setter=None): other.set_is_same_as(subj_ind) def handle_different_from(self, instance, uri, predicate, obj, setter=None): - """Β§9.6.2 DifferentIndividuals β€” simmetria garantita.""" + """Β§9.6.2 DifferentIndividuals β€” symmetry guaranteed.""" subj_ind = self._ensure_individual(uri) other = self._ensure_individual(obj) if subj_ind and other: @@ -261,6 +294,11 @@ def handle_different_from(self, instance, uri, predicate, obj, setter=None): other.set_is_different_from(subj_ind) def handle_inverse_of(self, instance, uri, predicate, obj, setter=None): + """Handle owl:inverseOf. + + On a restriction it sets the property the restriction applies to; on a + relation it links the two relations to each other (symmetric). + """ if isinstance(instance, Restriction) and not isinstance(instance, Relation): prop = self.get_or_create(obj, Property) if prop: @@ -294,7 +332,10 @@ def handle_on_property(self, instance, uri, predicate, obj, setter=None): instance.set_applies_on_property(prop) def _force_as_concept(self, uri): - """Aggiunge Concept in cache per URI giΓ  classificato come Individual/Resource (punning OWL).""" + """Add a Concept to the cache for an IRI already classified as Individual or Resource (OWL punning). + + It copies the attributes of the first cached instance, when there is one. + """ cached = list(self._instance_cache.get(uri, set())) concept = Concept() if cached: @@ -307,7 +348,12 @@ def _force_as_concept(self, uri): def _enrich_or_apply_owl_defaults(self, instance, uri): """ Applies OWL-mandated defaults to instances lacking explicit declarations. - [...docstring invariata...] + A Relation or Attribute without a domain (or with only owl:Thing) inherits + it from its super properties or its inverses, falling back to owl:Thing. + The range falls back to owl:Thing for a Relation and to rdfs:Literal for + an Attribute. An Individual without a type gets owl:Thing. Instances that + share an IRI (punning) are cross-linked through also_defined_as, except + for punning subordinates. """ if uri not in self._instance_cache: @@ -317,6 +363,7 @@ def _enrich_or_apply_owl_defaults(self, instance, uri): rdfs_string = self.get_or_create(RDFS.Literal, Datatype) def _safe_inherited(instance, getter_name): + """Inherited values, with ranges filtered to the right kind: datatypes for an Attribute, concepts for a Relation.""" values = self._get_inherited_property_values(instance, getter_name) if isinstance(instance, Attribute): if getter_name == "get_has_range": @@ -327,6 +374,7 @@ def _safe_inherited(instance, getter_name): return values def _is_only_default(values, default_id): + """True if the list is not empty and holds nothing but the default value.""" return values and all(d.get_has_identifier() == default_id for d in values) # Type-specific defaults: always apply, even for punning subordinates @@ -395,7 +443,14 @@ def _is_only_default(values, default_id): def _get_inherited_property_values(self, property_instance, getter_name: str) -> list: + """Return the domain or range inherited by a property, or [] when nothing is found. + + For a Relation the inverse relations are tried first, with domain and range + swapped (the domain of a relation is the range of its inverse); then the + super properties are walked upwards. + """ def collect(node): + """Return the first non-empty value on an ancestor (or on its inverses, swapped), never on the property itself.""" if node is property_instance: return None getter = getattr(node, getter_name, None) @@ -474,6 +529,7 @@ def _infer_property_type(self, instance) -> type: # (2) UP THE HIERARCHY def collect_type(node): + """Return the node's class when it is already a concrete property type.""" if type(node) in CONCRETE: return type(node) return None @@ -583,8 +639,8 @@ def process_all_disjoint_classes(self, uri: Node): class_b = self.get_or_create(class_b_uri, Concept) class_a.set_is_disjoint_with(class_b) class_b.set_is_disjoint_with(class_a) - except Exception as e: - print(f"Errore AllDisjointClasses: {e}") + except Exception: + logger.warning("AllDisjointClasses failed for %s", uri, exc_info=True) def process_all_different(self, uri: Node): """ @@ -603,8 +659,8 @@ def process_all_different(self, uri: Node): ind_b = self.get_or_create(ind_b_uri, Individual) ind_a.set_is_different_from(ind_b) ind_b.set_is_different_from(ind_a) - except Exception as e: - print(f"Errore AllDifferent: {e}") + except Exception: + logger.warning("AllDifferent failed for %s", uri, exc_info=True) def process_all_disjoint_properties(self, uri: Node): """ @@ -623,8 +679,8 @@ def process_all_disjoint_properties(self, uri: Node): prop_b = self.get_or_create(prop_b_uri, Property) prop_a.set_is_disjoint_with(prop_b) prop_b.set_is_disjoint_with(prop_a) - except Exception as e: - print(f"Errore AllDisjointProperties: {e}") + except Exception: + logger.warning("AllDisjointProperties failed for %s", uri, exc_info=True) # ========== HANDLER CALLED BY PHASE 3 FOR POPULATING INSTANCES ========== @@ -641,13 +697,14 @@ def handle_property_chain(self, instance, uri, predicate, obj, setter=None): instance.set_has_property_chain(chain_instances) def handle_has_key(self, instance, uri, predicate, obj, setter=None): + """Handle owl:hasKey: add every property of the key list to the concept.""" try: for prop_uri in RDFLibCollection(self.graph, obj): prop = self.get_or_create(prop_uri, Property) if prop: instance.set_has_key(prop) - except Exception as e: - print(f"Errore handle_has_key: {e}") + except Exception: + logger.warning("handle_has_key failed for %s", uri, exc_info=True) # ========== RESTRICTIONS ================================================ @@ -680,10 +737,15 @@ def handle_disjoint_union(self, instance, uri, predicate, obj, setter=None): concept_a.set_is_disjoint_with(concept_b) concept_b.set_is_disjoint_with(concept_a) - except Exception as e: - print(f"Errore disjointUnion: {e}") + except Exception: + logger.warning("disjointUnion failed for %s", uri, exc_info=True) def handle_datatype_restriction(self, instance, uri, predicate, obj, setter=None): + """Handle owl:withRestrictions on a datatype: set the datatype and one facet value per facet. + + Facet nodes (e.g. xsd:minInclusive) are cached and tracked, so phase 6 + does not turn them into Statements. + """ on_datatype = self.graph.value(uri, OWL.onDatatype) if on_datatype: dt = self.get_or_create(on_datatype, Datatype) @@ -719,20 +781,23 @@ def handle_datatype_restriction(self, instance, uri, predicate, obj, setter=None self._instance_cache[facet_node].add(instance) self._track(instance, *((facet_node, fp, fv) for fp, fv in self.graph.predicate_objects(facet_node))) - except Exception as e: - print(f"Errore handle_datatype_restriction: {e}") + except Exception: + logger.warning("Datatype restriction failed for %s", uri, exc_info=True) def handle_cardinality_exactly(self, instance, uri, predicate, obj, setter=None): + """Handle an exact cardinality (plain or qualified): type "exactly", the number, and owl:Thing as the default concept.""" instance.set_has_cardinality_type("exactly") instance.set_has_cardinality(obj) instance.set_applies_on_concept(self.get_or_create(OWL.Thing, Concept)) def handle_cardinality_min(self, instance, uri, predicate, obj, setter=None): + """Handle a minimum cardinality (plain or qualified): type "min", the number, and owl:Thing as the default concept.""" instance.set_has_cardinality_type("min") instance.set_has_cardinality(obj) instance.set_applies_on_concept(self.get_or_create(OWL.Thing, Concept)) def handle_cardinality_max(self, instance, uri, predicate, obj, setter=None): + """Handle a maximum cardinality (plain or qualified): type "max", the number, and owl:Thing as the default concept.""" instance.set_has_cardinality_type("max") instance.set_has_cardinality(obj) instance.set_applies_on_concept(self.get_or_create(OWL.Thing, Concept)) @@ -752,8 +817,8 @@ def _build_truth_function(self, instance, obj, operator): """ try: items = list(RDFLibCollection(self.graph, obj)) - except Exception as e: - print(f"Errore build_truth_function: {e}") + except Exception: + logger.warning("Truth function list failed for %s", obj, exc_info=True) return None if len(items) == 0: @@ -789,6 +854,7 @@ def _build_truth_function(self, instance, obj, operator): return tf def handle_intersection(self, instance, uri, predicate, obj, setter=None): + """Handle owl:intersectionOf as an "and" truth function; on a Concept it becomes an equivalent class.""" if type(instance) not in (TruthFunction, Concept): return tf = self._build_truth_function(instance, obj, "and") @@ -796,6 +862,7 @@ def handle_intersection(self, instance, uri, predicate, obj, setter=None): instance.set_is_equivalent_to(tf) def handle_union(self, instance, uri, predicate, obj, setter=None): + """Handle owl:unionOf as an "or" truth function; on a Concept it becomes an equivalent class.""" if type(instance) not in (TruthFunction, Concept): return tf = self._build_truth_function(instance, obj, "or") @@ -803,6 +870,7 @@ def handle_union(self, instance, uri, predicate, obj, setter=None): instance.set_is_equivalent_to(tf) def handle_complement(self, instance, uri, predicate, obj, setter=None): + """Handle owl:complementOf as a "not" truth function over the target concept.""" if type(instance) not in (TruthFunction, Concept): return if type(instance) is TruthFunction: @@ -820,6 +888,10 @@ def handle_complement(self, instance, uri, predicate, obj, setter=None): instance.set_is_equivalent_to(tf) def handle_one_of(self, instance, uri, predicate, obj, setter=None): + """Handle owl:oneOf: add the listed individuals to an enumeration. + + On a Concept a OneOf is created and set as its equivalent class. + """ if type(instance) not in (OneOf, Concept): return if type(instance) is Concept: @@ -831,8 +903,8 @@ def handle_one_of(self, instance, uri, predicate, obj, setter=None): resource = self.get_or_create(item, Individual) if resource: one_of.set_applies_on_resource(resource) - except Exception as e: - print(f"Errore oneOf su Concept: {e}") + except Exception: + logger.warning("oneOf on Concept failed for %s", uri, exc_info=True) instance.set_is_equivalent_to(one_of) else: # type(instance) is OneOf try: @@ -841,10 +913,11 @@ def handle_one_of(self, instance, uri, predicate, obj, setter=None): resource = self.get_or_create(item, Individual) if resource: instance.set_applies_on_resource(resource) - except Exception as e: - print(f"Errore oneOf: {e}") + except Exception: + logger.warning("oneOf failed for %s", uri, exc_info=True) def handle_has_value(self, instance, uri, predicate, obj, setter=None): + """Handle owl:hasValue: set the resource and, when the restriction names one, its property.""" resource = self.get_or_create(obj, Resource) if resource: instance.set_applies_on_resource(resource) @@ -857,21 +930,20 @@ def handle_has_value(self, instance, uri, predicate, obj, setter=None): def handle_rdf_type(self, instance, uri, predicate, obj, setter=None): """ - Handler bidirezionale per rdf:type. + Bidirectional handler for rdf:type. - - subject.has_type += object (lato Individual/Resource) - - object.individuals_with_this_type += subject (lato Concept, solo se l'oggetto e' - effettivamente classificato come Concept e non e' un URI strutturale - del vocabolario) + - subject.has_type += object (Individual/Resource side) + - object.individuals_with_this_type += subject (Concept side, only if the object + is really classified as a Concept and is not a structural vocabulary URI) - Replica la filter logic di _apply_setters per set_has_type: scarta - oggetti nei namespace OWL/RDF/RDFS, eccetto owl:Thing, owl:Nothing, + Mirrors the filter logic of _apply_setters for set_has_type: it discards + objects in the OWL/RDF/RDFS namespaces, except owl:Thing, owl:Nothing, rdfs:Literal. """ if not isinstance(obj, URIRef) and not isinstance(obj, BNode): return - # Filtro URI strutturali (stessa logica di _apply_setters) + # Structural URI filter (same logic as _apply_setters) if isinstance(obj, URIRef): obj_str = str(obj) if any(obj_str.startswith(ns) for ns in self._allowed_namespaces) \ @@ -882,18 +954,18 @@ def handle_rdf_type(self, instance, uri, predicate, obj, setter=None): if concept is None: return - # Lato diretto + # Direct side if hasattr(instance, 'set_has_type'): instance.set_has_type(concept) - # Lato inverso: solo se l'oggetto e' realmente un Concept - # (get_or_create puo' promuovere; type() esatto evita di sporcare + # Inverse side: only if the object is really a Concept + # (get_or_create can promote; the exact type() check avoids polluting # TruthFunction/Restriction/OneOf/etc.) if type(concept) is Concept and isinstance(instance, Individual): concept.set_individuals_with_this_type(instance) - # ========== OVERRIDE Statement per OWL (typed subject/object) ========== + # ========== Statement OVERRIDE for OWL (typed subject/object) ========== def _create_statement_for_triple(self, subj, pred, obj): """ @@ -973,8 +1045,8 @@ def _create_statement_for_triple(self, subj, pred, obj): elif self._is_unmapped_structured_bnode(obj): obj_inst = self._create_nested_statement(obj) else: - # oggetto mai visto: Individual (fallback class β€” _resolve_statement_endpoint - # ritorna il dominante in cache se presente, altrimenti crea Individual) + # object never seen: Individual (fallback class β€” _resolve_statement_endpoint + # returns the cached dominant instance if any, otherwise creates one) obj_inst = self._resolve_statement_endpoint(obj, Resource) if obj_inst: @@ -989,6 +1061,10 @@ def _create_statement_for_triple(self, subj, pred, obj): self._enrich_or_apply_owl_defaults(obj_inst, obj) def _resolve_statement_endpoint(self, node, fallback_class): + """Return the instance to use as subject or object of a Statement. + + It is the punning-dominant cached instance, or a new one of `fallback_class`. + """ dom = self._get_punning_dominant(node) if dom is not None: return dom diff --git a/lode/reader/logic/rdf_logic.py b/lode/reader/logic/rdf_logic.py index 824360e..b7ef1a0 100644 --- a/lode/reader/logic/rdf_logic.py +++ b/lode/reader/logic/rdf_logic.py @@ -1,4 +1,4 @@ -# rdf_logic.py +"""Extraction logic for RDF/RDFS artefacts.""" from rdflib import URIRef, Node, Literal as RDFlibLiteral, BNode from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD @@ -7,6 +7,9 @@ from lode.models import * from lode.reader.logic.base_logic import BaseLogic +import logging +logger = logging.getLogger(__name__) + class RdfLogic(BaseLogic): """ @@ -54,6 +57,10 @@ def phase1_classify_from_predicates(self): self.get_or_create(uri, py_class, populate=False) def phase2_create_from_types(self): + """Create an instance for every subject typed with a mapped rdf:type. + + Static setters and the handler declared for the type are applied right away. + """ type_mapping = self._strategy.get_type_mapping() for rdf_type, config in type_mapping.items(): @@ -90,7 +97,7 @@ def phase5_fallback(self): """ exclude_predicates = {RDF.first, RDF.rest, RDF.nil} - # 1. predicati -> Property + # 1. predicates -> Property for pred in set(self.graph.predicates()): if pred in self._instance_cache or pred in exclude_predicates: continue @@ -98,7 +105,7 @@ def phase5_fallback(self): continue self.get_or_create(pred, Property, populate=False) - # 2. soggetti/oggetti URI -> Resource + # 2. URI subjects/objects -> Resource for subj in set(self.graph.subjects()): if isinstance(subj, URIRef) and subj not in self._instance_cache: if self._is_structural(subj) or self._is_rdf_collection(subj): @@ -111,7 +118,7 @@ def phase5_fallback(self): continue self.get_or_create(obj, Resource, populate=False) - # 3. defaults RDFS su Property + # 3. RDFS defaults on Property for uri, instances in list(self._instance_cache.items()): for instance in list(instances): if isinstance(instance, Property): @@ -166,6 +173,7 @@ def _get_inherited_property_values(self, property_instance, getter_name: str) -> of getter_name found on an ancestor. """ def collect(node): + """Return the first non-empty value found on an ancestor, never on the property itself.""" if node is property_instance: return None getter = getattr(node, getter_name, None) @@ -215,8 +223,8 @@ def create_python_container(self, instance, uri): if rest is not None: self._triples_map[instance].add((node, RDF.rest, rest)) node = rest - except Exception as e: - print(f"Errore create_python_container (List): {e}") + except Exception: + logger.warning("List container build failed for %s", uri, exc_info=True) return # rdf:Bag/Alt/Seq: rdf:_N indexed members @@ -239,6 +247,7 @@ def create_python_container(self, instance, uri): self._triples_map[instance].add((uri, pred, obj)) def _resolve_container_member(self, node): + """Resolve a container member: a Literal for literals, a Resource otherwise.""" if isinstance(node, RDFlibLiteral): return self._create_literal(node) return self.get_or_create(node, Resource) \ No newline at end of file diff --git a/lode/reader/logic/skos_logic.py b/lode/reader/logic/skos_logic.py index bf18ae1..39703ab 100644 --- a/lode/reader/logic/skos_logic.py +++ b/lode/reader/logic/skos_logic.py @@ -1,4 +1,4 @@ -# logic.py - LOGICHE SPECIFICHE PER FORMATO +"""Extraction logic for SKOS artefacts.""" from rdflib import Graph, URIRef, Node, Literal as RDFlibLiteral, BNode from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD from rdflib.collection import Collection as RDFLibCollection @@ -6,27 +6,30 @@ from lode.models import * from lode.reader.logic.base_logic import BaseLogic +import logging +logger = logging.getLogger(__name__) + class SkosLogic(BaseLogic): """ - Logica SKOS. - - Differenze: - - Focus su Concept/ConceptScheme - - Relazioni skos:broader/narrower + SKOS logic. + + Differences: + - Focus on Concept/ConceptScheme + - skos:broader/narrower relations - NO default domain/range - NO Restriction, Individual, CompositeClass, Datatype, Attribute """ def _get_allowed_namespaces(self) -> set: + """Return the RDF, RDFS and SKOS namespaces, ignoring the config 'namespaces' key.""" return {str(RDF), str(RDFS), str(SKOS)} def phase1_classify_from_predicates(self): - print("\n--- FASE 1: Classificazione SKOS ---") - print(" Skip (SKOS usa solo types)") + """Do nothing: in SKOS the class always comes from rdf:type.""" + logger.debug("Phase 1 (SKOS classification): skipped, SKOS uses only rdf:type") def phase2_create_from_types(self): - print("\n--- FASE 2: Types SKOS ---") - + """Create an unpopulated instance for every subject typed with a mapped SKOS type.""" type_mapping = self._strategy.get_type_mapping() created = 0 @@ -40,28 +43,27 @@ def phase2_create_from_types(self): self.get_or_create(uri, py_class, populate=False) created += 1 - print(f" Create: {created}") + logger.debug("Phase 2 (SKOS types): created %d instances", created) def phase3_populate_properties(self): - print("\n--- FASE 3: Popolamento SKOS ---") - + """Fill every cached instance from its triples.""" for uri in list(self._instance_cache.keys()): for instance in list(self._instance_cache[uri]): self.populate_instance(instance, uri) - print(f" Popolate: {len(self._instance_cache)}") + logger.debug("Phase 3 (SKOS population): populated %d cache entries", len(self._instance_cache)) def phase4_process_group_axioms(self): - print("\n--- FASE 4: Axioms SKOS ---") - print(" Nessun axiom SKOS") + """Do nothing: SKOS has no group axioms.""" + logger.debug("Phase 4 (SKOS axioms): no group axioms in SKOS") def phase5_fallback(self): - print("\n--- FASE 5: FALLBACK ---") - print("TO BE IMPLEMENTED SOON") + """Do nothing yet: the SKOS fallback is not implemented.""" + logger.debug("Phase 5 (SKOS fallback): not implemented yet") - # Handler specifici SKOS + # SKOS-specific handlers def handle_membership(self, instance, uri, predicate, obj, value): - """Gestisce skos:member""" + """Handle skos:member: add the cached members of a Collection.""" if not isinstance(instance, Collection): return @@ -75,7 +77,7 @@ def handle_membership(self, instance, uri, predicate, obj, value): instance.set_has_member(member_obj) def handle_narrower(self, instance, uri, predicate, obj, value): - """Gestisce skos:narrower (inverso)""" + """Handle skos:narrower (the inverse of broader): link the concept to its broader concepts.""" if not isinstance(instance, Concept): return diff --git a/lode/reader/modules.py b/lode/reader/modules.py index ee354ba..737a07b 100644 --- a/lode/reader/modules.py +++ b/lode/reader/modules.py @@ -36,6 +36,9 @@ from lode.helpers.rdf import iri_namespace, iri_stem from lode.reader.provenance_graph import ProvenanceGraph +import logging +logger = logging.getLogger(__name__) + # Core vocabularies: never treated as external terms to dereference. _SKIP_NS = (str(RDF), str(RDFS), str(OWL), str(XSD)) @@ -64,12 +67,12 @@ def _load_formal_import(graph: ProvenanceGraph, source: str, depth: int, from lode.reader.loader import Loader # local import: loader imports modules try: imported_loader = Loader(source) - except Exception: - print(f" [formal import] could not load {source}") + except Exception as e: + logger.warning("Formal import failed for %s: %s", source, e) return imported_graph = imported_loader.get_graph() - print(f" [formal import] {len(imported_graph)} triples from {source}") + logger.info("Formal import: %d triples from %s", len(imported_graph), source) # Context = ontology IRI declared in the document (fallback: source) graph.add_document(imported_graph, source) @@ -78,12 +81,13 @@ def _load_formal_import(graph: ProvenanceGraph, source: str, depth: int, def _load_informal_import(source: str, target: ProvenanceGraph, visited: set, failed: set, - homes: dict) -> None: + homes: dict, name: Optional[str] = None) -> None: """Load a single document reached by dereferencing an IRI (informal import) into its own named graph of `target`. Its owl:imports are not followed. Records the outcome: success -> visited + homes[source] (name of its named graph); error -> failed (never retried).""" + if source in visited or source in failed: return from lode.reader.loader import Loader # local import: loader imports modules @@ -91,11 +95,11 @@ def _load_informal_import(source: str, target: ProvenanceGraph, visited: set, fa g = Loader(source).get_graph() except Exception as e: failed.add(source) - print(f" [informal import] could not load {source}: {type(e).__name__}: {e}") + logger.warning("Informal import failed for %s: %s: %s", source, type(e).__name__, e) return visited.add(source) - print(f" [informal import] {len(g)} triples from {source}") - homes[source] = target.add_document(g, source) + logger.info("Informal import: %d triples from %s", len(g), source) + homes[source] = target.add_document(g, name or source) # ---------------------------------------------------------- # IMPORTED / CLOSURE MODULES (formal imports) @@ -123,10 +127,11 @@ def apply_closure(graph: ProvenanceGraph): # ---------------------------------------------------------- # # Boundary: an external term receives -# - what the documented ontology says about it (with priority, per predicate), and -# - the triples of its "home" graph, i.e. of the ontology that owns it. -# Redefinitions found in third-party ontologies (formally imported ones included) are -# NOT copied: they belong to the import closure, available through imported/closure. +# - what the documented ontology says about it, in the main graph, and +# - ALL the triples of its "home" graph (types included), in the home graph. +# No per-predicate priority here: both sources are kept with their provenance; +# conflicting types become punned instances in the Reader, and the Viewer decides +# what to show. Redefinitions in third-party ontologies are NOT copied. # If the home cannot be reached, the term receives no external definition. def _namespace(term) -> str: @@ -165,8 +170,9 @@ def _namespace_failed(term, failed: set) -> bool: def apply_partial_import(graph: ProvenanceGraph): - """Dereference (1 hop) the external IRIs mentioned and add ONLY their definition, - taken from their home ontology (not the redefinitions in other ontologies).""" + """Dereference (1 hop) the external IRIs described by the documented ontology and + add ONLY their definition, taken from their home ontology (not the redefinitions + in other ontologies). Terms that are merely used stay plain external links.""" # 1. External terms: IRIs mentioned in the graph, excluding the import IRIs, the # core vocabularies and the ontology's own terms. import_iris = set(graph.objects(None, OWL.imports)) @@ -175,48 +181,49 @@ def apply_partial_import(graph: ProvenanceGraph): external = {t for t in mentioned if not str(t).startswith(_SKIP_NS) and not (own and str(t).startswith(own))} - # 2. Load into a separate graph (one named graph per document). The owl:imports + # 2. Keep only terms the documented ontology describes (subject of at least one of + # its triples): terms it merely uses stay plain external links. + described = {s for s, _, _ in graph if s in external} + + # 3. Load into a separate graph (one named graph per document). The owl:imports # documents are loaded first, as informal imports (no recursion): they are often - # the home of the mentioned terms, so they are not downloaded again. Then one + # the home of the described terms, so they are not downloaded again. Then one # attempt per namespace not covered yet. fetched = ProvenanceGraph() visited, failed, homes = set(), set(), {} # homes: source -> loaded ontology IRI - for iri in import_iris: + for iri in (import_iris if described else ()): _load_informal_import(str(iri), fetched, visited, failed, homes) - for term in external: + for term in described: if _home(term, homes) is None and not _namespace_failed(term, failed): - _load_informal_import(urldefrag(str(term))[0], fetched, visited, failed, homes) - - # 3. Headers of external ontologies (owl:Ontology) are not terms to document. - external -= set(fetched.subjects(RDF.type, OWL.Ontology)) + doc, frag = urldefrag(str(term)) + # Slash IRIs dereference the term itself: name the graph after the + # namespace, not after the first term fetched (e.g. dcterms:created). + _load_informal_import(doc, fetched, visited, failed, homes, + name=doc if frag else iri_namespace(term)) - # 4. Priority to the documented ontology: predicates it already asserts on an - # external term (e.g. rdfs:label) are not copied again from the home. - base_pairs = {(s, p) for s, p, _ in graph if s in external} + # 4. Headers of external ontologies (owl:Ontology) are not terms to document. + described -= set(fetched.subjects(RDF.type, OWL.Ontology)) - # 5. Copy from the home graph only, into its own named graph. + # 5. Copy the whole home description, into the home's named graph. seen: set = set() - for term in external: + for term in described: home = _home(term, homes) if home is not None: # home unreachable: no external definition - _copy_description(fetched, term, graph, seen, skip=base_pairs, ctx=home) + _copy_description(fetched, term, graph, seen, ctx=home) return graph, visited def _copy_description(src: ProvenanceGraph, node, dst: ProvenanceGraph, seen: set, - skip=frozenset(), ctx=None) -> None: + ctx=None) -> None: """Copy into `dst` the triples of `src` whose subject is `node`, in the same named graph. ctx restricts the copy to a single graph (the home); None = all graphs. - skip (subject, predicate) pairs already populated in the base: not overwritten. Blank nodes (restrictions, lists) are copied in full and stay in their parent's context; `seen` prevents cycles and repeated copies.""" if node in seen: return seen.add(node) for _, p, o, c in src.quads((node, None, None, ctx)): - if (node, p) in skip: - continue dst.graph(c).add((node, p, o)) if isinstance(o, BNode): _copy_description(src, o, dst, seen, ctx=c) \ No newline at end of file diff --git a/lode/reader/provenance_graph.py b/lode/reader/provenance_graph.py index 47d89d5..ced5a3f 100644 --- a/lode/reader/provenance_graph.py +++ b/lode/reader/provenance_graph.py @@ -1,3 +1,9 @@ +"""Named-graph container that keeps track of which ontology each triple comes from. + +Used when imports or partial imports are enabled: every loaded document lives +in its own named graph, named after its ontology IRI, while the rest of the +pipeline keeps reading the union of all of them as if it were a single Graph. +""" import os from pathlib import Path from urllib.parse import urlparse, quote @@ -8,21 +14,40 @@ QUAD_FORMATS = frozenset({"trig", "nquads"}) class ProvenanceGraph(Dataset): - """Dataset a named graph (uno per ontologia sorgente) che si presenta al pipeline come un Graph.""" + """Dataset with one named graph per source ontology, presented to the pipeline as a Graph. + + default_union makes queries see the union of all named graphs, so readers + and viewers need no knowledge of the provenance; only the code that cares + about the origin of a triple asks for a specific named graph. + """ def __init__(self, **kw): + """Create an empty dataset whose default graph is the union of its named graphs.""" super().__init__(default_union=True, **kw) def __iter__(self): + """Iterate over the plain triples of the union, like a Graph does.""" return self.triples((None, None, None)) def add_document(self, g: Graph, fallback: str) -> URIRef: - ctx = self.ontology_iri(g, fallback) + """Copy the triples of `g` into the named graph of its ontology and return its IRI. + + The graph is named by `ontology_iri`; `fallback` (usually the source URL + or path) is used when the document declares no ontology. + """ + ctx =self.ontology_iri(g, fallback) self.graph(ctx).__iadd__(g) return ctx @staticmethod def ontology_iri(g: Graph, fallback: str) -> URIRef: + """Return the IRI that names the graph of a document. + + Uses the first owl:Ontology or skos:ConceptScheme subject with an IRI. + Without one, falls back to the source itself: http(s)/file URLs as they + are, local paths as file:// URIs (a one-letter scheme is a Windows + drive), anything else as a urn:lode:source: URN. + """ for t in _ONTOLOGY_TYPES: for s in g.subjects(RDF.type, t): if isinstance(s, URIRef): diff --git a/lode/reader/reader.py b/lode/reader/reader.py index 723e2af..8465f23 100644 --- a/lode/reader/reader.py +++ b/lode/reader/reader.py @@ -1,7 +1,27 @@ -# reader.py - ORCHESTRATOR GENERICO +"""Generic orchestrator that turns a loaded RDF graph into model instances. + +The Reader sits between the Loader (parsing) and the Viewer (rendering): it +picks the configuration for the artefact type and runs the logic phases, so +CLI, API and viewers never depend on parsing details. +""" from lode.reader.loader import Loader from lode.reader.config_manager import get_configuration from lode.models import * +from rdflib import URIRef +from lode.reader.provenance_graph import ProvenanceGraph + +import logging +logger = logging.getLogger(__name__) + +def _value_key(value) -> str: + """Comparable form of a model value: lexical form for literals, IRI otherwise.""" + get_value = getattr(value, "get_has_value", None) + if get_value is not None and get_value() is not None: + return str(get_value()) + get_id = getattr(value, "get_has_identifier", None) + if get_id is not None and get_id(): + return str(get_id()) + return str(value) class Reader: """ @@ -14,25 +34,52 @@ class Reader: """ def __init__(self): + """Create an empty Reader; call load_instances() to populate it.""" self._instance_cache = {} - self._logic = None # Logic specializzata (OWL, SKOS, RDF, RDFS) + self._logic = None # specialised logic (OWL, SKOS, RDF/RDFS) self._graph = None self._configuration = None self._imported_uris = set() # needed to get the model before imported/closure models self._ontology_iri = None def get_warnings(self) -> list: + """Return the warnings collected while reading, or [] when they were not requested.""" if not getattr(self, '_warnings_enabled', False): return [] if self._logic and hasattr(self._logic, '_warnings'): return self._logic._warnings return [] - + + def _prioritize_documented_values(self) -> None: + """Reorder every multi-valued field so that values asserted by the documented + ontology (main named graph) come before those from imported or home graphs. + + Needed because the graph does not preserve insertion order across named graphs. + The sort is stable: within each group the original order is kept.""" + main = self._graph.graph(self._ontology_iri) + for uri, instances in self._instance_cache.items(): + if not isinstance(uri, URIRef): + continue + local = {str(o) for o in main.objects(uri, None)} + + if not local: + continue + for instance in instances: + for value in vars(instance).values(): + if isinstance(value, list) and len(value) > 1: + value.sort(key=lambda v: _value_key(v) not in local) + + def load_instances(self, graph_path: str, read_as: str, imported=None, closure=None, partial_import=None, warnings=False): - """Carica e processa grafo RDF""" + """Load an artefact and extract its model instances. + + `read_as` selects the configuration (owl, rdf, skos). Imports, closure and + partial_import are forwarded to the Loader; `warnings` turns on the reader + warnings. Blocking: it may fetch remote documents. + """ self._warnings_enabled = warnings - # 1. Parse generico + # 1. Generic parsing loader = Loader(graph_path, imported=imported, closure=closure, partial_import=partial_import) self._graph = loader.get_graph() self._ontology_iri = loader.ontology_iri @@ -40,18 +87,26 @@ def load_instances(self, graph_path: str, read_as: str, imported=None, closure=N # 1.5 saves the imported/closure uris to be reused in viewer so the model metadata are not overwritten self._imported_uris = getattr(loader, "imported_uris", set()) - # 2. Seleziona strategia + # 2. Select the configuration for the artefact type self._configuration = get_configuration(read_as) - # 3. Crea Logic specializzata + # 3. Create the specialised logic self._logic = self._configuration.create_logic(self._graph, self._instance_cache) - # 4. Esecuzione fasi + # 4. Run the extraction phases self._extract_instances() + # 5. Documented ontology first (only when a module is active) + if isinstance(self._graph, ProvenanceGraph): + self._prioritize_documented_values() + def get_instance(self, uri: str, instance_type=None): - """Ottiene istanze per URI""" + """Return the instances registered under an IRI. + + With `instance_type`, only the first instance of that type (or None); + otherwise the whole set. Returns None when the IRI is unknown. + """ uri_identifier = None for identifier in self._instance_cache.keys(): @@ -74,7 +129,7 @@ def get_instance(self, uri: str, instance_type=None): return None def get_instances(self) -> dict: - """Raggruppa istanze per tipo""" + """Group all instances by model class name; literal cache entries are skipped.""" grouped = {} for uri_identifier, instances in self._instance_cache.items(): @@ -92,13 +147,13 @@ def get_instances(self) -> dict: return grouped def get_triples_for_instance(self, instance): - """Ottiene le triple RDF associate a un'istanza Python""" + """Return the RDF triples that produced a model instance (empty set if unknown).""" if self._logic and hasattr(self._logic, '_triples_map'): return self._logic._triples_map.get(instance, set()) return set() def get_all_triples_map(self) -> dict: - """Ottiene la mappa completa instance β†’ triple""" + """Return the full instance -> triples map (empty dict if no logic is loaded).""" if self._logic and hasattr(self._logic, '_triples_map'): return self._logic._triples_map return {} @@ -113,7 +168,10 @@ def get_provenance_subgraph(self, instance): # function reused by the api to push instances def to_dict(self, instance) -> dict: - """Serializza un'istanza Python in dict JSON, includendo le triple RDF""" + """Serialize a model instance into a JSON-friendly dict, including its RDF triples. + + Empty values are left out of "properties"; every value is converted to a string. + """ result = { "instance": str(instance), "type": type(instance).__name__, @@ -122,14 +180,14 @@ def to_dict(self, instance) -> dict: "rdf_triples": [] } - # Serializza le proprietΓ  dell'istanza + # Serialize the instance properties for attr_name in instance.__dict__.keys(): value = getattr(instance, attr_name) # removes empty values from the properties if value is not None and not (isinstance(value, (list, set)) and not value): result["properties"][attr_name] = str(value) - # Aggiungi le triple RDF associate + # Add the associated RDF triples triples = self.get_triples_for_instance(instance) for s, p, o in triples: triple_dict = { @@ -141,51 +199,51 @@ def to_dict(self, instance) -> dict: return result - def _serialize_value(self, value): - """Helper per serializzare diversi tipi di valori""" - if isinstance(value, (str, int, float, bool)): - return value - elif isinstance(value, (list, set)): - return [self._serialize_value(v) for v in value] - elif hasattr(value, 'uri'): - return str(value.uri) - else: - return str(value) + # def _serialize_value(self, value): + # """Convert a value into a JSON-friendly form, recursing into lists and sets.""" + # if isinstance(value, (str, int, float, bool)): + # return value + # elif isinstance(value, (list, set)): + # return [self._serialize_value(v) for v in value] + # elif hasattr(value, 'uri'): + # return str(value.uri) + # else: + # return str(value) def get_viewer(self): - """Ottiene il viewer appropriato per il formato corrente.""" + """Return the viewer matching the loaded artefact type. + + Raises ValueError if load_instances() has not been called yet. + """ if not self._configuration: raise ValueError("No configuration loaded. Call load_instances() first.") return self._configuration.create_viewer(self) def clear_cache(self): - """Pulisce la cache""" + """Drop every cached instance, in the Reader and in its logic.""" self._instance_cache.clear() if self._logic: self._logic.clear_cache() - # ==================== ESTRAZIONE (ORCHESTRAZIONE) ==================== + # ==================== EXTRACTION (ORCHESTRATION) ==================== def _extract_instances(self): - """Estrazione in 6 fasi orchestrate""" - # print("\n" + "="*60) - # print("ESTRAZIONE INSTANCES") - # print("="*60) - - # FASE 0: Pre-crea datatypes (comune a tutti) - # self._phase0_create_datatypes() - - # FASE 1-4: Delegate alla Logic specifica + """Run the extraction phases in order, delegating each one to the logic. + + Phases 1-4 depend on the artefact type; fallback, statements and + namespaces are shared. Post-pipeline warning checks run only when enabled. + """ + # PHASES 1-4: delegated to the specific logic self._logic.phase1_classify_from_predicates() self._logic.phase2_create_from_types() self._logic.phase3_populate_properties() self._logic.phase4_process_group_axioms() - # FASE 5: Fallback (comune) + # PHASE 5: fallback (shared) self._logic.phase5_fallback() - # FASE 6: Statements + # PHASE 6: statements self._logic.phase6_create_statements() # FASE 7: Namespaces @@ -197,50 +255,18 @@ def _extract_instances(self): owl_warnings.run_post_checks(self._logic) if self._logic._warnings: - print(f"WARNINGS ({len(self._logic._warnings)}):") + logger.warning("%d reader warnings", len(self._logic._warnings)) for w in self._logic._warnings: - print(f" [{w['code']}] {w['message']}") + logger.warning("[%s] %s", w['code'], w['message']) self._logic.save_warnings() - # def _phase0_create_datatypes(self): - # """Pre-crea tutti i Datatype (comune a tutti i formati)""" - # print("\n--- FASE 0: Datatypes ---") - - # created = 0 - - # # 1. URI XSD - # for s, p, o in self._graph: - # if isinstance(s, URIRef) and str(s).startswith(str(XSD)): - # if s not in self._instance_cache: - # self._logic.create_empty_instance(s, Datatype) - # created += 1 - - # if isinstance(o, URIRef) and str(o).startswith(str(XSD)): - # if o not in self._instance_cache: - # self._logic.create_empty_instance(o, Datatype) - # created += 1 - - # # 2. rdfs:Literal - # if RDFS.Literal not in self._instance_cache: - # self._logic.create_empty_instance(RDFS.Literal, Datatype) - # created += 1 - - # # 3. BNode Datatypes - # for bnode in self._graph.subjects(RDF.type, RDFS.Datatype): - # if isinstance(bnode, BNode) and bnode not in self._instance_cache: - # self._logic.create_empty_instance(bnode, Datatype) - # created += 1 - - # print(f" Creati {created} datatypes") - def _phase5_fallback(self): """Fallback per risorse non categorizzate (comune)""" - #print("\n--- FASE 5: Fallback ---") fallback_class = self._configuration.get_fallback_class() if not fallback_class: - print(" Nessun fallback configurato") + logger.warning("No fallback class configured") return all_subjects = set(self._graph.subjects()) @@ -249,71 +275,4 @@ def _phase5_fallback(self): for subj in all_subjects: if subj not in self._instance_cache: self._logic.get_or_create(subj, fallback_class) - fallback_count += 1 - - #print(f" Fallback: {fallback_count} risorse -> {fallback_class.__name__}") - - # def get_ontology_metadata(self) -> Model: - # """ - # Extracts metadata and returns a populated Model object. - # """ - # if self._graph is None: - # return Model() - - # # 1. Create the Model instance - # ontology_model = Model() - - # # 2. Find the owl:Ontology node - # ontology_node = self._graph.value(predicate=RDF.type, object=OWL.Ontology) - - # if ontology_node: - # # --- IDENTIFIER (IRI) --- - # ontology_model.set_has_identifier(str(ontology_node)) - - # # --- TITLE (Label) --- - # title = ( - # self._graph.value(ontology_node, DCTERMS.title) or - # self._graph.value(ontology_node, DC.title) or - # self._graph.value(ontology_node, RDFS.label) - # ) - # if title: - # ontology_model.set_has_label(str(title)) - - # # --- DESCRIPTION (Comment) --- - # comment = ( - # self._graph.value(ontology_node, DCTERMS.description) or - # self._graph.value(ontology_node, RDFS.comment) - # ) - # if comment: - # ontology_model.set_has_comment(str(comment)) - - # # --- VERSION INFO --- - # version_info = self._graph.value(ontology_node, OWL.versionInfo) - # if version_info: - # ontology_model.set_has_version_info(str(version_info)) - - # # --- VERSION IRI --- - # version_iri = self._graph.value(ontology_node, OWL.versionIRI) - # if version_iri: - # v_model = Model() - # v_model.set_has_identifier(str(version_iri)) - # ontology_model.set_has_version(v_model) - - # # --- IMPORTS --- - # for imported_iri in self._graph.objects(ontology_node, OWL.imports): - # imported_model = Model() - # imported_model.set_has_identifier(str(imported_iri)) - # ontology_model.set_imports(imported_model) - - # # --- CREATORS (Custom handling) --- - # for pred in [DCTERMS.creator, DC.creator]: - # for creator in self._graph.objects(ontology_node, pred): - # ontology_model.set_has_creator(creator) - - - # # --- CONTRIBUTORS (Custom handling) --- - # for pred in [DCTERMS.contributor, DC.contributor]: - # for contributor in self._graph.objects(ontology_node, pred): - # ontology_model.set_has_contributor(contributor) - - # return ontology_model \ No newline at end of file + fallback_count += 1 \ No newline at end of file diff --git a/lode/reader/warnings/owl_warnings.py b/lode/reader/warnings/owl_warnings.py index ae225d5..8fb1652 100644 --- a/lode/reader/warnings/owl_warnings.py +++ b/lode/reader/warnings/owl_warnings.py @@ -25,6 +25,10 @@ def has_warning(logic, code: str, subject=None) -> bool: # ==================================================================== def flag_malformed_restrictions(logic, classified): + """Warn about BNodes that mix several restriction axioms (not valid OWL 2 DL). + + Warnings are grouped by the parent resource that holds the malformed BNodes. + """ axiom_preds = { p for p, cfg in logic._property_mapping.items() if isinstance(cfg.get('inferred_class'), type) @@ -48,11 +52,13 @@ def flag_malformed_restrictions(logic, classified): f"OWL 2 DL: malformed restrictions on {parent} ({', '.join(names)})") def empty_truth_function(logic, instance, bnode, operator): + """Warn that a truth function has an empty operand list and was ignored.""" ident = instance.get_has_identifier() logic.add_warning('empty_truth_function', ident, f'Β§8.1.1: Truth Function ({ident}, {operator}) is empty β€” ignored (collection BNode: {bnode})') def singleton_truth_function(logic, instance, bnode, operator): + """Warn that a truth function has a single operand, which usually points to an ontology mistake.""" ident = instance.get_has_identifier() logic.add_warning('singleton_truth_function', ident, f'Β§8.1.1: TruthFunction ({ident}, {operator}) has a single element β€” ontology should be checked (collection BNode: {bnode})') @@ -78,6 +84,7 @@ def run_post_checks(logic): def _check_orphan_restriction(logic, instance, uri): + """Warn about a restriction with no property; an anonymous one without owl:onProperty is also removed.""" if not isinstance(instance, PropertyConceptRestriction): return if instance.get_applies_on_property() is not None: @@ -91,6 +98,7 @@ def _check_orphan_restriction(logic, instance, uri): f'{type(instance).__name__} has owl:onProperty but applies_on_property is None') def _check_inverse_multiplicity(logic, instance, uri): + """Warn about a relation declared inverse of more than one relation.""" if not isinstance(instance, Relation): return inv = instance.get_is_inverse_of() @@ -109,11 +117,13 @@ def _check_inverse_multiplicity(logic, instance, uri): # f'Β§6.1.1: Relation {uri} is inverse of itself') def _check_empty_truth_function_post(logic, instance, uri): + """Warn about a truth function that still has no concepts after the whole read.""" if isinstance(instance, TruthFunction) and len(instance.get_applies_on_concept()) == 0: logic.add_warning('empty_restriction', uri, f'{type(instance).__name__} has empty applies_on_concept') def _check_property_hierarchy(logic, instance, uri): + """Warn when a property is a sub property of a property of a different kind (relation, attribute, annotation).""" if not isinstance(instance, (Relation, Attribute, Annotation)): return for sup in (instance.get_is_sub_property_of() or []): @@ -123,6 +133,7 @@ def _check_property_hierarchy(logic, instance, uri): f'{type(sup).__name__} {sup.get_has_identifier()}') def _check_class_datatype_hierarchy(logic, instance, uri): + """Warn when a class is a subclass of a datatype, or the other way round.""" if not isinstance(instance, (Concept, Datatype)) or isinstance(instance, DatatypeRestriction): return for sup in (instance.get_is_sub_concept_of() or []): @@ -132,6 +143,7 @@ def _check_class_datatype_hierarchy(logic, instance, uri): f'{type(sup).__name__} {sup.get_has_identifier()}') def _check_punning_conflicts(logic, uri, instances): + """Warn about an IRI used as both class and datatype, or as several property kinds (illegal punning).""" types = {type(i) for i in instances} if Concept in types and Datatype in types: logic.add_warning('class_datatype_conflict', uri, @@ -142,6 +154,7 @@ def _check_punning_conflicts(logic, uri, instances): f'Β§5.8.1: {uri} has multiple property types: {sorted(t.__name__ for t in prop_types)}') def _check_config_mismatches(logic): + """Warn when the subject of a mapped predicate was not given one of the classes the config expects.""" for pred, cfg in logic._property_mapping.items(): target_classes = cfg.get('target_classes', []) if not target_classes: diff --git a/lode/viewer/__init__.py b/lode/viewer/__init__.py index 73fabd5..1511a1c 100644 --- a/lode/viewer/__init__.py +++ b/lode/viewer/__init__.py @@ -1,4 +1,4 @@ -# viewer/__init__.py +"""Viewer layer: renders the model as view data for the HTML templates.""" from .base_viewer import BaseViewer from .owl_viewer import OwlViewer @@ -14,7 +14,10 @@ def get_viewer(read_as: str, reader): - """Factory per il viewer corretto in base al formato.""" + """Return the viewer that matches the artefact format (owl, rdf/rdfs, skos). + + An unknown format falls back to the generic BaseViewer. + """ key = read_as.lower() if key == 'owl': return OwlViewer(reader) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..726b4d8 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,40 @@ +site_name: LODE 2.0 +site_description: Live OWL Documentation Environment β€” extract and document RDF/OWL/SKOS semantic artefacts as browsable HTML. +site_url: https://opencitations.github.io/lode/ +repo_url: https://github.com/opencitations/lode +repo_name: opencitations/lode + +theme: + name: material + features: + - navigation.sections + - navigation.top + - content.code.copy + palette: + - scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + - scheme: slate + toggle: + icon: material/weather-sunny + name: Switch to light mode + +markdown_extensions: + - admonition + - attr_list + - tables + - toc: + permalink: true + - pymdownx.highlight + - pymdownx.superfences + +nav: + - Home: index.md + - Getting started: getting_started.md + - Usage: + - Web form: usage/web_form.md + - CLI: usage/cli.md + - Docker: usage/docker.md + - API reference: + - Endpoints: api/endpoints.md diff --git a/pyproject.toml b/pyproject.toml index 4f9e035..a706a01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "lode" +name = "oc-lode" version = "0.3.15" description = "New reengineered version of LODE, maintained by OpenCitations" authors = [{name = "Valentina Pasqual, Silvio Peroni", email = "valentina.pasqual2@unibo.it"}] @@ -18,18 +18,21 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "fastapi>=0.104.1", - "uvicorn[standard]>=0.24.0", - "gunicorn>=21.0.0", - "python-multipart>=0.0.6", "rdflib>=7.4.0", "jinja2>=3.1.2", "requests>=2.31.0", - "owlready2>=0.50", "minify-html>=0.18.1", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +server = [ + "fastapi>=0.104.1", + "uvicorn[standard]>=0.24.0", + "gunicorn>=21.0.0", + "python-multipart>=0.0.6", ] -# AGGIUNTO [project.scripts] lode = "lode.cli:main" @@ -52,7 +55,5 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["lode"] -# include template e static nel wheel -# [tool.hatch.build.targets.wheel.shared-data] -# "lode/templates" = "lode/templates" -# "lode/static" = "lode/static" +[tool.hatch.build.targets.sdist] +include = ["lode", "tests"] diff --git a/tests/data/provenance/main.ttl b/tests/data/provenance/main.ttl index a2d8a98..d980735 100644 --- a/tests/data/provenance/main.ttl +++ b/tests/data/provenance/main.ttl @@ -8,3 +8,6 @@ a owl:Class ; rdfs:subClassOf , , . rdfs:label "T from main"@en . + + rdfs:comment "V described by main" . + rdfs:comment "X described by main" . diff --git a/tests/test_modules.py b/tests/test_modules.py index 3a24cb7..8031110 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -261,11 +261,6 @@ def test_home_definition_in_home_graph(self, ds): assert contexts(ds, (H.T, RDF.type, OWL.Class)) == {HOME} assert contexts(ds, (H.T, RDFS.comment, Literal("T defined by home"))) == {HOME} - def test_documented_ontology_has_priority(self, ds): - """main restates the label of home#T: main's label wins, home's is not copied.""" - assert set(ds.objects(H.T, RDFS.label)) == {Literal("T from main", lang="en")} - assert contexts(ds, (H.T, RDFS.label, Literal("T from main", lang="en"))) == {MAIN} - def test_third_party_redefinition_is_not_copied(self, ds): """imp redefines home#T, but imp is not home#T's home.""" assert (H.T, RDFS.comment, Literal("T redefined by imp")) not in ds @@ -278,6 +273,23 @@ def test_blank_nodes_follow_parent_context(self, ds): assert contexts(ds, t) == {HOME} assert set(ds.predicates(restr)) == {RDF.type, OWL.onProperty, OWL.someValuesFrom} + # -- priority of documented ontology over imported ontos -------------- + + def test_both_definitions_kept_with_provenance(self, ds): + """main restates the label of home#T: both labels kept, each in its own graph.""" + assert contexts(ds, (H.T, RDFS.label, Literal("T from main", lang="en"))) == {MAIN} + assert contexts(ds, (H.T, RDFS.label, Literal("T from home", lang="en"))) == {HOME} + + def test_documented_values_come_first(self, main_path): + """The Reader puts main's label of home#T before home's.""" + from lode.reader.reader import Reader + reader = Reader() + reader.load_instances(graph_path=main_path, read_as="owl", partial_import=True) + for inst in reader._instance_cache[H.T]: + labels = [l.get_has_value() for l in inst.get_has_label()] + if labels: + assert labels[0] == "T from main", labels + # -- finding the home ------------------------------------------------- def test_home_by_dereferenced_document_when_iri_differs(self, ds): @@ -285,9 +297,10 @@ def test_home_by_dereferenced_document_when_iri_differs(self, ds): assert contexts(ds, (V.V, RDF.type, OWL.Class)) == {VER} def test_unreachable_home_gets_no_external_definition(self, ds, offline_fetch): - """missing is not served: imp's redefinition of missing#X must not fill the gap.""" + """missing is not served: only main's own triple, imp's redefinition must not fill the gap.""" assert "http://ex.org/missing" in offline_fetch - assert set(ds.predicate_objects(X.X)) == set() + assert set(ds.predicate_objects(X.X)) == {(RDFS.comment, Literal("X described by main"))} + assert contexts(ds, (X.X, RDFS.comment, Literal("X described by main"))) == {MAIN} # -- fetching --------------------------------------------------------- @@ -307,6 +320,8 @@ def test_vocabulary_without_ontology_fetched_once(self, tmp_path, offline_fetch, path = write_ttl(tmp_path, "m.ttl", """ a owl:Ontology . rdfs:seeAlso , . + rdfs:comment "a described by m2" . + rdfs:comment "b described by m2" . """) ds = Loader(path, partial_import=True).get_graph() assert len([u for u in offline_fetch if u.startswith("http://ex.org/voc/")]) == 1 @@ -317,6 +332,9 @@ def test_failed_namespace_not_retried_per_term(self, tmp_path, offline_fetch): path = write_ttl(tmp_path, "m.ttl", """ a owl:Ontology . rdfs:seeAlso , , . + rdfs:comment "a" . + rdfs:comment "b" . + rdfs:comment "c" . """) Loader(path, partial_import=True) assert len([u for u in offline_fetch if u.startswith("http://ex.org/dead/")]) == 1 @@ -553,9 +571,20 @@ def ds(self, client): def test_graphs_are_main_and_homes_only(self, ds): assert graphs(ds) == {MAIN, HOME, VER} - def test_documented_ontology_has_priority(self, ds): - assert set(ds.objects(H.T, RDFS.label)) == {Literal("T from main", lang="en")} + def test_both_definitions_kept_with_provenance(self, ds): + """main restates the label of home#T: both labels kept, each in its own graph.""" assert contexts(ds, (H.T, RDFS.label, Literal("T from main", lang="en"))) == {MAIN} + assert contexts(ds, (H.T, RDFS.label, Literal("T from home", lang="en"))) == {HOME} + + def test_documented_values_come_first(self, main_path): + """The Reader puts main's label of home#T before home's.""" + from lode.reader.reader import Reader + reader = Reader() + reader.load_instances(graph_path=main_path, read_as="owl", partial_import=True) + for inst in reader._instance_cache[H.T]: + labels = [l.get_has_value() for l in inst.get_has_label()] + if labels: + assert labels[0] == "T from main", labels def test_home_definition_in_home_graph(self, ds): assert contexts(ds, (H.T, RDFS.comment, Literal("T defined by home"))) == {HOME} @@ -573,7 +602,7 @@ def test_versioned_home_found_via_dereferenced_document(self, ds): assert contexts(ds, (V.V, RDF.type, OWL.Class)) == {VER} def test_unreachable_home_gives_no_external_definition(self, ds): - assert set(ds.predicate_objects(X.X)) == set() + assert set(ds.predicate_objects(X.X)) == {(RDFS.comment, Literal("X described by main"))} def test_external_ontology_headers_absent(self, ds): assert set(ds.subjects(RDF.type, OWL.Ontology)) == {MAIN} @@ -701,4 +730,5 @@ def test_same_upload_reloaded_gives_same_quads(self, client, flag): b = as_dataset(get(client, token, "trig", **{flag: True}), "trig") assert graphs(a) == graphs(b) for g in graphs(a): - assert isomorphic(a.graph(g), b.graph(g)), g \ No newline at end of file + assert isomorphic(a.graph(g), b.graph(g)), g + \ No newline at end of file diff --git a/uv.lock b/uv.lock index 57c7d7c..9a44003 100644 --- a/uv.lock +++ b/uv.lock @@ -444,51 +444,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "lode" -version = "0.3.15" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "gunicorn" }, - { name = "jinja2" }, - { name = "minify-html" }, - { name = "owlready2" }, - { name = "python-multipart" }, - { name = "rdflib" }, - { name = "requests" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "genbadge", extra = ["coverage"] }, - { name = "httpx" }, - { name = "pytest" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.104.1" }, - { name = "gunicorn", specifier = ">=21.0.0" }, - { name = "jinja2", specifier = ">=3.1.2" }, - { name = "minify-html", specifier = ">=0.18.1" }, - { name = "owlready2", specifier = ">=0.50" }, - { name = "python-multipart", specifier = ">=0.0.6" }, - { name = "rdflib", specifier = ">=7.4.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "genbadge", extras = ["coverage"], specifier = ">=1.1.3" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "pytest", specifier = ">=8.0.0" }, - { name = "pytest-cov", specifier = ">=7.0.0" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -618,10 +573,54 @@ wheels = [ ] [[package]] -name = "owlready2" -version = "0.50" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/67/6fb3a628889d97f8a3d3c64d4597f0637db61a1856c835fe7c65d8e89658/owlready2-0.50.tar.gz", hash = "sha256:7be74c53a35497df138c8223dddcf65510def9b38494ae1fac4aa2a61c4677eb", size = 27328931, upload-time = "2026-02-05T10:42:01.733Z" } +name = "oc-lode" +version = "0.3.15" +source = { editable = "." } +dependencies = [ + { name = "jinja2" }, + { name = "minify-html" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "requests" }, +] + +[package.optional-dependencies] +server = [ + { name = "fastapi" }, + { name = "gunicorn" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "genbadge", extra = ["coverage"] }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.104.1" }, + { name = "gunicorn", marker = "extra == 'server'", specifier = ">=21.0.0" }, + { name = "jinja2", specifier = ">=3.1.2" }, + { name = "minify-html", specifier = ">=0.18.1" }, + { name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.6" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rdflib", specifier = ">=7.4.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.24.0" }, +] +provides-extras = ["server"] + +[package.metadata.requires-dev] +dev = [ + { name = "genbadge", extras = ["coverage"], specifier = ">=1.1.3" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, +] [[package]] name = "packaging" From 9f3a12f2cdd6afcf4b27a890e3a1325843740724 Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 12:24:51 +0200 Subject: [PATCH 03/21] fix: nested statements rendering in ontology annotation --- lode/reader/logic/base_logic.py | 12 +++---- lode/templates/_entity_card.html | 56 +------------------------------ lode/templates/_value.html | 55 ++++++++++++++++++++++++++++++ lode/templates/viewer.html | 7 +++- lode/viewer/base_viewer.py | 10 ++++-- tests/test_owl_viewer.py | 57 ++++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 64 deletions(-) create mode 100644 lode/templates/_value.html diff --git a/lode/reader/logic/base_logic.py b/lode/reader/logic/base_logic.py index 1224d35..a718670 100644 --- a/lode/reader/logic/base_logic.py +++ b/lode/reader/logic/base_logic.py @@ -635,14 +635,15 @@ def _create_statement_for_triple(self, subj, pred, obj): def _is_unmapped_structured_bnode(self, node) -> bool: """A BNode is a structured Statement value if: - it's a BNode (not URIRef, not Literal) - - it's not already in the instance cache (not classified by any phase) + - it's not already classified by any phase (a bare Resource in the cache is + only a placeholder made by a mapped predicate such as rdfs:seeAlso) - it has at least one outgoing triple - none of its outgoing predicates carry a useful rdf:type for our model (i.e. it's not declaring itself as a typed entity) """ if not isinstance(node, BNode): return False - if node in self._instance_cache: + if any(type(i) is not Resource for i in self._instance_cache.get(node, ())): return False has_out = False for _ in self.graph.predicate_objects(node): @@ -663,9 +664,8 @@ def _create_nested_statement(self, node): statement = Statement() statement.set_has_identifier(str(node)) - if node not in self._instance_cache: - self._instance_cache[node] = set() - self._instance_cache[node].add(statement) + # drop the placeholder Resource: the node is represented by the Statement + self._instance_cache[node] = {statement} for p, o in self.graph.predicate_objects(node): self._track(statement, (node, p, o)) @@ -691,7 +691,7 @@ def _create_nested_statement(self, node): elif self._is_unmapped_structured_bnode(o): o_inst = self._create_nested_statement(o) else: - o_inst = self.get_or_create(o, Individual) + o_inst = self.get_or_create(o, Resource) # The first (predicate, object) pair fills the Statement slots; # additional pairs are stored as side annotations on the Statement diff --git a/lode/templates/_entity_card.html b/lode/templates/_entity_card.html index 0e59b9c..55fd06b 100644 --- a/lode/templates/_entity_card.html +++ b/lode/templates/_entity_card.html @@ -1,58 +1,4 @@ -{% macro render_value(v, depth=0) %} -{% if v.link and 'erlangen' in v.link %} - -{% endif %} - {% if v.type == 'Statement' and v.nested %} -

- {% set main = v.nested.main %} - {% if main and main.value and main.value.text %} -
{{ main.predicate }}: {{ render_value(main.value, depth + 1) }}
- {% endif %} - {% if v.nested.siblings %} - {% for s in v.nested.siblings %} -
{{ s.predicate }}: {{ render_value(s.value, depth + 1) }}
- {% endfor %} - {% endif %} -
- {% else %} - {% set v_type = v.type.lower() if v.type else '' %} - {% set is_md = ('str' in v_type) or ('literal' in v_type) %} - {% set dep_class = 'text-decoration-line-through text-muted' if v.is_deprecated else '' %} - {% set dep_title = ' -- Deprecated ' if v.is_deprecated else '' %} - - {% if v.link %} - {% set precise_key = v.link ~ '|' ~ v_type %} - {% if v.is_external %} - {{ v.text }} - {% elif precise_key in anchor_map %} - {{ v.text }} - {% elif v.link in anchor_map %} - {{ v.text }} - {% elif is_static %} - {{ v.text }} - {% else %} - {{ v.text }} - {% endif %} - - {% if v_type in type_map %} - {{ type_map[v_type].abb }} - {% endif %} - - {% else %} -
-
- {{ v.text }} -
- {% if is_md and v.lan %} - {{ v.lan }} - {% endif %} -
- {% endif %} - {% endif %} -{% endmacro %} +{% from '_value.html' import render_value with context %}
diff --git a/lode/templates/_value.html b/lode/templates/_value.html new file mode 100644 index 0000000..1c10ed9 --- /dev/null +++ b/lode/templates/_value.html @@ -0,0 +1,55 @@ +{# Shared renderer for a resolved value: literals, links, restrictions and nested statements. #} +{% macro render_value(v, depth=0, full_link=false) %} + {% if v.type == 'Statement' and v.nested %} +
+ {% set main = v.nested.main %} + {% if main and main.value and main.value.text %} +
{{ main.predicate }}:{{ render_value(main.value, depth + 1, full_link) }}
+ {% endif %} + {% if v.nested.siblings %} + {% for s in v.nested.siblings %} +
{{ s.predicate }}:{{ render_value(s.value, depth + 1, full_link) }}
+ {% endfor %} + {% endif %} +
+ {% else %} + {% set v_type = v.type.lower() if v.type else '' %} + {% set is_md = ('str' in v_type) or ('literal' in v_type) %} + {% set dep_class = 'text-decoration-line-through text-muted' if v.is_deprecated else '' %} + {% set dep_title = ' -- Deprecated ' if v.is_deprecated else '' %} + + {% if v.link %} + {% set precise_key = v.link ~ '|' ~ v_type %} + {% if v.is_external %} + {{ v.link if full_link and v_type == 'resource' else v.text }} + {% elif precise_key in anchor_map %} + {{ v.text }} + {% elif v.link in anchor_map %} + {{ v.text }} + {% elif is_static %} + {{ v.text }} + {% else %} + {{ v.text }} + {% endif %} + + {% if v_type in type_map %} + {{ type_map[v_type].abb }} + {% endif %} + + {% else %} + {# inline inside a nested statement so "name: value" stays on one line #} + {% set tag = 'span' if depth else 'div' %} + <{{ tag }}> + <{{ tag }} class="markdown-with-badge {{ dep_class }} {% if is_md %}render-markdown inline-markdown{% endif %}" + title="{{ v.link }}{{dep_title}}" + {% if is_md %}data-md="{{ v.text | e }}"{% endif %}> + {{ v.text }} + + {% if is_md and v.lan %} + {{ v.lan }} + {% endif %} + + {% endif %} + {% endif %} +{% endmacro %} diff --git a/lode/templates/viewer.html b/lode/templates/viewer.html index a7393b7..e6d4cb5 100644 --- a/lode/templates/viewer.html +++ b/lode/templates/viewer.html @@ -46,6 +46,9 @@ {% set md_types = ['Literal', 'Str', 'literal', 'str', 'string'] %} + {# after the sets above: "with context" only sees variables defined before the import #} + {% from '_value.html' import render_value with context %} + {% include '_toc_sidebar.html' %} {% if not single_resource %} @@ -157,6 +160,8 @@

{% endif %}

+ {% elif item.type == 'Statement' and item.nested %} + {{ render_value(item, 0, true) }} {% else %} {% endif %} @@ -284,7 +289,7 @@

← Back to full documentation + ← Back to full documentation {% else %} ← Back to full documentation {% endif %} diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 548898d..8170e05 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -581,7 +581,13 @@ def _resolve_resource_value(self, obj, language=None) -> dict: 'predicate': self._clean_name(self, attr), 'value': resolved, }) - handler_dic['nested'] = {'main': inner, 'siblings': siblings} + # Reader order depends on the source file: sort by label, then by value + pairs = sorted( + [inner] + siblings, + key=lambda p: ((p['predicate'] or '').lower(), + ((p['value'] or {}).get('text') or '').lower()), + ) + handler_dic['nested'] = {'main': pairs[0], 'siblings': pairs[1:]} # Pick a primary text for collapsed display: the inner value's text handler_dic['text'] = (inner.get('value') or {}).get('text') or '[Statement]' return handler_dic @@ -645,7 +651,7 @@ def _format_statement(self, instances, target_instance, language=None) -> Dict: if obj_data['text'] and obj_data not in values: values.append(obj_data) if values: - statements[pred_label] = values + statements[pred_label] = sorted(values, key=lambda v: (str(v.get('nested')), v['text'].lower())) return statements diff --git a/tests/test_owl_viewer.py b/tests/test_owl_viewer.py index b82aefd..1580cce 100644 --- a/tests/test_owl_viewer.py +++ b/tests/test_owl_viewer.py @@ -347,6 +347,63 @@ def test_metadata_has_only_requested_language_and_no_none(self): assert texts == ["Title"] +# --------------------------------------------------------------------------- +# Structured blank nodes in the ontology header (e.g. bibo:editor [ a foaf:Person ... ]) +# --------------------------------------------------------------------------- + +_HEADER_TTL = """ +@prefix owl: . +@prefix bibo: . +@prefix foaf: . +@prefix org: . +@prefix rdfs: . + + a owl:Ontology ; + bibo:editor %s . +""" + +_PERSON_A = ('[ a foaf:Person ; foaf:name "Alice Example" ; ' + 'org:memberOf [ foaf:name "Example Org" ] ]') +_PERSON_B = '[ a foaf:Person ; foaf:name "Bob Example" ; rdfs:seeAlso ]' + + +def _render_index(tmp_path, ttl): + """Load `ttl` through the Reader and return the HTML of the static index page.""" + from lode.builder import build_html + from lode.reader import Reader + + src = tmp_path / "onto.ttl" + src.write_text(ttl, encoding="utf-8") + reader = Reader() + reader.load_instances(str(src), read_as="owl") + out = tmp_path / "site" + build_html(reader.get_viewer(), out, reader=reader) + return (out / "index.html").read_text(encoding="utf-8") + + +class TestHeaderStructuredBnodes: + """Header values that are blank nodes must show their content, not their id.""" + + def test_header_shows_bnode_content_not_ids(self, tmp_path): + html = _render_index(tmp_path, _HEADER_TTL % f"{_PERSON_A}, {_PERSON_B}") + for expected in ("Alice Example", "Bob Example", "Example Org"): + assert expected in html + assert not re.search(r"\b(?:N[0-9a-f]{32}|n[0-9a-f]{20,})\b", html), "raw BNode id in header" + + def test_header_order_is_independent_of_source_order(self, tmp_path): + """Same graph, triples written in a different order: same rendered header.""" + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + html_a = _render_index(a, _HEADER_TTL % f"{_PERSON_A}, {_PERSON_B}") + html_b = _render_index(b, _HEADER_TTL % f"{_PERSON_B}, {_PERSON_A}") + def alice_first(h): + """True when Alice is rendered before Bob.""" + return h.index("Alice Example") < h.index("Bob Example") + assert alice_first(html_a) == alice_first(html_b) + + # =========================================================================== # Integration: real corpus, same loader pattern as test_owl_integrity. # =========================================================================== From b202ac867b2af3cbc41be39073bf0052959b83c2 Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 12:33:52 +0200 Subject: [PATCH 04/21] feat(builder): configurable artefact name for root page and serializations --- README.md | 2 +- docs/usage/cli.md | 8 ++++++++ lode/builder.py | 38 ++++++++++++++++++++++++++++-------- lode/cli.py | 9 ++++++--- tests/test_static_builder.py | 24 ++++++++++++++++++++--- 5 files changed, 66 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4915a17..5d47bfb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ pip install "oc-lode[server]" ```bash # Build static documentation from an ontology URL or a local file -lode build --url --read-as [--out ./docs] [--lang en] [--imported] [--closure] +lode build --url --read-as [--out ./docs] [--lang en] [--artefact-name index] [--imported] [--closure] lode build --file --read-as [--out ./docs] # Run the web service (requires the [server] extra) diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 2f154a6..7efddd6 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -29,6 +29,7 @@ lode build (--url URL | --file FILE) --read-as FORMAT [OPTIONS] | `--read-as` | yes | Artefact type: `owl`, `rdf`, `skos` | | `--out` | no | Output directory (default `./lode_output`) | | `--lang` | no | Language tag for labels (default `en`) | +| `--artefact-name` | no | File name (without extension) of the root page and its serializations (if omitted, falls back to `index`, so the site starts at `index.html`). Only letters, digits, `.`, `_` and `-` are allowed | | `--imported` | no | Load `owl:imports` at depth 1 | | `--closure` | no | Load the full `owl:imports` closure | | `--partial-import` | no | Load only the imported entities that are actually used | @@ -44,8 +45,15 @@ lode build --file ontology.ttl --read-as owl --out ./site # with Italian labels and imports lode build --url http://purl.org/spar/fabio --read-as owl --lang it --imported --out ./fabio + +# rename the root page: writes fabio.html, fabio.ttl, fabio.rdf, ... +lode build --url http://purl.org/spar/fabio --read-as owl --artefact-name fabio --out ./site ``` +The artefact name applies only to the root files. Resource pages keep the name +derived from their IRI, and their "Back to full documentation" button points to +`.html`. + ## Environment variables | Variable | Default | Description | diff --git a/lode/builder.py b/lode/builder.py index eaf76fc..58d0969 100644 --- a/lode/builder.py +++ b/lode/builder.py @@ -16,6 +16,7 @@ from lode.viewer.base_viewer import SERIALIZATION_FORMATS from lode.helpers.html_utils import minify as _minify from lode.models.model import Model +from lode.exceptions import ArtefactValidationError _SER = [(f["fmt"], f["ext"]) for f in SERIALIZATION_FORMATS] @@ -79,6 +80,21 @@ def _local_name(uri: str) -> str: """Return the part of an IRI after '#', or its last path segment.""" return uri.split('#', 1)[1] if '#' in uri else uri.rstrip('/').rsplit('/', 1)[-1] +def _check_artefact_name(name: str) -> str: + """Return `name` if it is a plain file stem, else raise. + + The name is user-chosen (CLI) and becomes file names in the output folder + and links in every page: path separators or '..' would let the root files + escape the output directory. + """ + if not re.fullmatch(r"[A-Za-z0-9._-]+", name) or set(name) == {"."}: + raise ArtefactValidationError( + "Invalid artefact name: use only letters, digits, '.', '_' and '-' (no extension)", + context={"artefact_name": name}, + ) + return name + + def _copy_static(out_dir: Path) -> None: """Copy the package's static assets into the output folder, once. @@ -89,16 +105,21 @@ def _copy_static(out_dir: Path) -> None: if static_src.exists() and not static_dst.exists(): shutil.copytree(static_src, static_dst) -def build_html(viewer, out_dir: Path, lang: str = "en", reader=None) -> None: +def build_html(viewer, out_dir: Path, lang: str = "en", reader=None, + artefact_name: str = "index") -> None: """Write the whole static site for a viewer into `out_dir`. - Writes index.html, the serializations of the artefact and, under resources/, + Writes the index page, the serializations of the artefact and, under resources/, one page plus serializations for each resource that belongs to the ontology namespace or has local triples. Blocking and disk-heavy; existing files are overwritten. A page is never written outside resources/, whatever the IRI. + `artefact_name` is the file stem of the root files (.html, .ttl, ...): + links back to the index use it, so a renamed index stays reachable. Resource + files are not affected, they are named after their IRI. """ from collections import defaultdict + artefact_name = _check_artefact_name(artefact_name) out_dir.mkdir(parents=True, exist_ok=True) _copy_static(out_dir) @@ -134,7 +155,7 @@ def resource_url_resource(uri, section=None): return uri rel = _rel_slug(uri, graph) if rel.rsplit("/", 1)[-1] == "": # namespace IRI - return "../../index.html" + return f"../../{artefact_name}.html" return f"../{rel}.html" def resource_url_index(uri, section=None): @@ -143,7 +164,7 @@ def resource_url_index(uri, section=None): return uri rel = _rel_slug(uri, graph) if rel.rsplit("/", 1)[-1] == "": - return "index.html" + return f"{artefact_name}.html" return f"resources/{rel}.html" env_index = _get_template_env(static_path="static") @@ -154,7 +175,7 @@ def resource_url_index(uri, section=None): out = reader._graph.serialize(format=fmt) if isinstance(out, bytes): out = out.decode("utf-8") - (out_dir / f"ontology.{ext}").write_text(out, encoding="utf-8") + (out_dir / f"{artefact_name}.{ext}").write_text(out, encoding="utf-8") template_index = env_index.get_template("viewer.html") template_resource = env_resource.get_template("viewer.html") @@ -166,9 +187,9 @@ def resource_url_index(uri, section=None): data["request"] = _FakeRequest("/") data["is_static"] = True data["resource_url"] = resource_url_index - data["export_base"] = "ontology" - (out_dir / "index.html").write_text(_minify(template_index.render(**data)), encoding="utf-8") - print(" [build] index.html") + data["export_base"] = artefact_name + (out_dir / f"{artefact_name}.html").write_text(_minify(template_index.render(**data)), encoding="utf-8") + print(f" [build] {artefact_name}.html") if not toc_config: return @@ -207,6 +228,7 @@ def resource_url_index(uri, section=None): data_r["is_static"] = True data_r["resource_url"] = resource_url_resource data_r["export_base"] = _local_name(uri) + data_r["artefact_name"] = artefact_name page.write_text(_minify(template_resource.render(**data_r)), encoding="utf-8") class _FakeRequest: diff --git a/lode/cli.py b/lode/cli.py index c061a72..c8ad198 100644 --- a/lode/cli.py +++ b/lode/cli.py @@ -1,8 +1,8 @@ # lode/cli.py """ lode serve [--port 8000] -lode build --url --read-as [--out ./docs] [--lang en] [--imported] [--closure] -lode build --file --read-as [--out ./docs] [--lang en] [--imported] [--closure] +lode build --url --read-as [--out ./docs] [--lang en] [--artefact-name index] [--imported] [--closure] +lode build --file --read-as [--out ./docs] [--lang en] [--artefact-name index] [--imported] [--closure] """ import argparse @@ -47,7 +47,8 @@ def cmd_build(args): closure=args.closure or None, partial_import=args.partial_import or None, ) - build_html(reader.get_viewer(), out_dir, lang=args.lang, reader=reader) + build_html(reader.get_viewer(), out_dir, lang=args.lang, reader=reader, + artefact_name=args.artefact_name) print(f"Done -> {out_dir}") @@ -72,6 +73,8 @@ def main(): p_build.add_argument("--read-as", required=True, choices=["owl", "rdf", "skos"]) p_build.add_argument("--out", default="./lode_output") p_build.add_argument("--lang", default="en") + p_build.add_argument("--artefact-name", default="index", + help="File stem of the root page and its serializations (default: index)") p_build.add_argument("--imported", action="store_true") p_build.add_argument("--closure", action="store_true") p_build.add_argument("--partial-import", action="store_true") diff --git a/tests/test_static_builder.py b/tests/test_static_builder.py index da900b6..d14e74f 100644 --- a/tests/test_static_builder.py +++ b/tests/test_static_builder.py @@ -15,6 +15,7 @@ from rdflib import Graph from lode.reader import Reader +from lode.exceptions import ArtefactValidationError from lode.builder import build_html, _rel_slug, _prefix_map from rdflib import URIRef @@ -51,7 +52,7 @@ def test_index_and_ontology_serializations(built): out = built["out"] assert (out / "index.html").exists() for ext in _EXTS: - assert (out / f"ontology.{ext}").exists(), f"missing ontology.{ext}" + assert (out / f"index.{ext}").exists(), f"missing index.{ext}" def test_resources_folder_created(built): @@ -59,9 +60,26 @@ def test_resources_folder_created(built): def test_index_export_base(built): - """Index header must export the whole graph (ontology.*), not a single resource.""" + """Index header must export the whole graph (index.*), not a single resource.""" html = (built["out"] / "index.html").read_text(encoding="utf-8") - assert "ontology.ttl" in html + assert "index.ttl" in html + + +def test_renamed_artefact(built, tmp_path): + """A renamed artefact renames root page and RDF; resource pages link back to it.""" + out = tmp_path / "site" + build_html(built["viewer"], out, reader=built["reader"], artefact_name="home") + assert (out / "home.html").exists() and (out / "home.ttl").exists() + assert not (out / "index.html").exists() + page = next((out / "resources").rglob("*.html")) + assert "../../home.html" in page.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("bad", ["../x", "a/b", "..", "x.html "]) +def test_artefact_name_rejected(bad, tmp_path): + """Names with path parts or odd characters are rejected before any file is written.""" + with pytest.raises(ArtefactValidationError): + build_html(None, tmp_path / "s", artefact_name=bad) # --- INVARIANT: every HTML resource has all three serializations --- From 8dc084409aa9b33fd677d3f0cba8847570908f63 Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 13:07:07 +0200 Subject: [PATCH 05/21] feat(export): export loaded artefact as triples, provenance always as quads --- lode/api.py | 10 +- lode/builder.py | 15 +- lode/reader/loader.py | 16 +- lode/reader/reader.py | 24 ++- lode/viewer/base_viewer.py | 51 ++++--- tests/test_modules.py | 304 ++++++++++++++----------------------- 6 files changed, 194 insertions(+), 226 deletions(-) diff --git a/lode/api.py b/lode/api.py index 0bbff78..abf38ea 100644 --- a/lode/api.py +++ b/lode/api.py @@ -46,7 +46,7 @@ from lode.reader import Reader from lode.reader import security from lode.exceptions import LODEError, ArtefactValidationError -from lode.viewer.base_viewer import formats_for +from lode.viewer.base_viewer import SERIALIZATION_FORMATS from lode.reader.provenance_graph import ProvenanceGraph from lode.helpers.rdf import iri_local_name @@ -210,7 +210,7 @@ def _load_url(url, read_as, imported, closure, partial_import, warnings, use_cac # cache hit: rebuild from the saved Turtle reader = Reader() reader.load_instances(path, read_as, imported=imported, closure=closure, partial_import=partial_import, - warnings=warnings) + warnings=warnings, source_name=url) return reader if not use_cache: # cache=false: drop the stale copy so the fresh fetch replaces it @@ -434,9 +434,9 @@ def extract_get( reader = _resolve_reader(read_as.value, url, upload_id, imported, closure, partial_import, warnings, use_cache=cache) - # Content negotiation: the available formats depend on the graph (quads only with modules) + # Content negotiation: triples of the loaded artefact only, quads are never negotiable accept = request.headers.get("accept", "text/html") - available = formats_for(reader._graph) + available = SERIALIZATION_FORMATS serial = None if format: @@ -456,7 +456,7 @@ def extract_get( serialized = reader.get_viewer().export_resource(resource, serial["fmt"]) filename = iri_local_name(resource) or "resource" else: - serialized = reader._graph.serialize(format=serial["fmt"]) + serialized = reader.get_main_graph().serialize(format=serial["fmt"]) filename = (iri_local_name(url) if url else "") or "graph" return Response(content=serialized, media_type=serial["mime"], headers={"Content-Disposition": f'inline; filename="{filename}.{serial["ext"]}"'}) diff --git a/lode/builder.py b/lode/builder.py index 58d0969..2975fdb 100644 --- a/lode/builder.py +++ b/lode/builder.py @@ -125,6 +125,15 @@ def build_html(viewer, out_dir: Path, lang: str = "en", reader=None, ontology_ns = viewer._get_ontology_ns() graph = reader._graph if reader else viewer.reader._graph + main_graph = viewer.reader.get_main_graph() + + def _in_main_graph(uri) -> bool: + """Tell whether the loaded artefact itself says something about this IRI. + + Resources described only by imported modules get no page: their export + would be empty, as exports carry the triples of the loaded artefact only. + """ + return (URIRef(str(uri)), None, None) in main_graph grouped = defaultdict(list) for inst in viewer.get_all_instances(): @@ -139,7 +148,7 @@ def build_html(viewer, out_dir: Path, lang: str = "en", reader=None, u = inst.get_has_identifier() if not u: continue - if not (u.startswith(ontology_ns) or viewer._has_local_triples(u)): + if not (u.startswith(ontology_ns) or _in_main_graph(u)): continue if _rel_slug(u, graph).rsplit("/", 1)[-1] == "": # namespace IRI, no local continue @@ -172,7 +181,7 @@ def resource_url_index(uri, section=None): if reader is not None: for fmt, ext in _SER: - out = reader._graph.serialize(format=fmt) + out = reader.get_main_graph().serialize(format=fmt) if isinstance(out, bytes): out = out.decode("utf-8") (out_dir / f"{artefact_name}.{ext}").write_text(out, encoding="utf-8") @@ -203,7 +212,7 @@ def resource_url_index(uri, section=None): if not uri: continue native = uri.startswith(ontology_ns) - if not (native or viewer._has_local_triples(uri)): + if not (native or _in_main_graph(uri)): continue rel = _rel_slug(uri, graph) diff --git a/lode/reader/loader.py b/lode/reader/loader.py index 8d25027..25c02e5 100644 --- a/lode/reader/loader.py +++ b/lode/reader/loader.py @@ -20,18 +20,23 @@ class Loader: """Load an RDF artefact into a graph, optionally together with its imports. The loaded graph is a plain Graph, or a ProvenanceGraph (one named graph per - ontology) when imported, closure or partial_import is requested. + ontology) when imported, closure or partial_import is requested. In both cases + `ontology_iri` names the loaded artefact. """ - def __init__(self, file_path: Optional[str] = None, imported=None, closure=None, partial_import=None): + def __init__(self, file_path: Optional[str] = None, imported=None, closure=None, partial_import=None, + source_name: Optional[str] = None): """Prepare the loader and, when `file_path` is given, load it immediately. `imported`, `closure` and `partial_import` select the module applied after loading (see lode.reader.modules); at most one of them is used. + `source_name` is the name of the artefact when it differs from `file_path` + (e.g. a cached copy of a URL): it names the graph if the ontology declares no IRI. """ self.graph = Graph() self.ontology_iri = None + self._source_name = source_name self._imported = imported self._closure = closure self.imported_uris = set() # needed to keep track of original model to document @@ -69,10 +74,15 @@ def load(self, source: str) -> None: context={"source": source} ) + name = self._source_name or source if self._imported or self._closure or self._partial_import: raw, self.graph = self.graph, ProvenanceGraph() - self.ontology_iri = self.graph.add_document(raw, source) + self.ontology_iri = self.graph.add_document(raw, name) self._apply_modules() + else: + # no modules: the graph stays plain, only its name is needed so that + # the provenance of an entity can still be written as a quad + self.ontology_iri = ProvenanceGraph.ontology_iri(self.graph, name) # ---------------------------------------------------------- # MODULES MAIN HANDLER diff --git a/lode/reader/reader.py b/lode/reader/reader.py index 8465f23..4d5a7e4 100644 --- a/lode/reader/reader.py +++ b/lode/reader/reader.py @@ -7,7 +7,8 @@ from lode.reader.loader import Loader from lode.reader.config_manager import get_configuration from lode.models import * -from rdflib import URIRef +from typing import Optional +from rdflib import Graph, URIRef from lode.reader.provenance_graph import ProvenanceGraph import logging @@ -70,17 +71,20 @@ def _prioritize_documented_values(self) -> None: value.sort(key=lambda v: _value_key(v) not in local) - def load_instances(self, graph_path: str, read_as: str, imported=None, closure=None, partial_import=None, warnings=False): + def load_instances(self, graph_path: str, read_as: str, imported=None, closure=None, partial_import=None, + warnings=False, source_name: Optional[str] = None): """Load an artefact and extract its model instances. `read_as` selects the configuration (owl, rdf, skos). Imports, closure and partial_import are forwarded to the Loader; `warnings` turns on the reader - warnings. Blocking: it may fetch remote documents. + warnings. `source_name` names the artefact when `graph_path` is only a local + copy of it. Blocking: it may fetch remote documents. """ self._warnings_enabled = warnings # 1. Generic parsing - loader = Loader(graph_path, imported=imported, closure=closure, partial_import=partial_import) + loader = Loader(graph_path, imported=imported, closure=closure, partial_import=partial_import, + source_name=source_name) self._graph = loader.get_graph() self._ontology_iri = loader.ontology_iri @@ -158,11 +162,21 @@ def get_all_triples_map(self) -> dict: return self._logic._triples_map return {} + def get_main_graph(self) -> Graph: + """Return the triples of the loaded artefact only, as a plain Graph view. + + Imported and partially imported ontologies are left out: this is what + exports and content negotiation must return. Without modules it is the + loaded graph itself. + """ + if isinstance(self._graph, ProvenanceGraph): + return self._graph.graph(self._ontology_iri) + return self._graph + def get_provenance_subgraph(self, instance): """Return the rdflib.Graph subgraph that provenanced `instance`.""" if self._logic and hasattr(self._logic, 'build_provenance_subgraph'): return self._logic.build_provenance_subgraph(instance) - from rdflib import Graph return Graph() diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 8170e05..457e786 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -14,27 +14,21 @@ from lode.reader.provenance_graph import ProvenanceGraph from lode.helpers.rdf import iri_local_name +# Export formats: triples of the loaded artefact only. SERIALIZATION_FORMATS = [ - {"label": "Turtle", "fmt": "turtle", "mime": "text/turtle", "ext": "ttl", "icon": "bi-filetype-raw"}, + {"label": "Turtle", "fmt": "turtle", "mime": "text/turtle", "ext": "ttl", "icon": "bi-filetype-raw"}, {"label": "RDF/XML", "fmt": "xml", "mime": "application/rdf+xml", "ext": "rdf", "icon": "bi-file-code"}, {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, {"label": "N-Triples", "fmt": "nt", "mime": "application/n-triples", "ext": "nt", "icon": "bi-file-text"}, ] +# Provenance formats: quads, the named graph of each triple is always shown. QUAD_SERIALIZATION_FORMATS = [ {"label": "TriG", "fmt": "trig", "mime": "application/trig", "ext": "trig", "icon": "bi-filetype-raw"}, {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, {"label": "N-Quads", "fmt": "nquads", "mime": "application/n-quads", "ext": "nq", "icon": "bi-file-text"}, ] -def formats_for(graph): - """Return the serialization formats available for a graph. - - Quad formats are offered only for a ProvenanceGraph, because only it keeps - the named graph of each triple. - """ - return QUAD_SERIALIZATION_FORMATS if isinstance(graph, ProvenanceGraph) else SERIALIZATION_FORMATS - class BaseViewer: """Base viewer to display the instances extracted by the Reader.""" @@ -67,20 +61,27 @@ def _has_local_triples(self, uri) -> bool: return (URIRef(str(uri)), None, None) in self.reader._graph def _formats(self): - """Return the serialization formats available for the loaded graph.""" - return formats_for(self.reader._graph) + """Return the export formats (triples): the same whatever modules are active.""" + return SERIALIZATION_FORMATS def _with_provenance(self, sub): - """With modules active, put the triples of `sub` back into their original named graphs.""" + """Return `sub` as quads, each triple in the named graph(s) it comes from. + + With modules active the graphs are the ones of the source ontologies; + without, every triple goes in the graph of the loaded artefact. + """ ds = self.reader._graph - if not isinstance(ds, ProvenanceGraph): - return sub out = ProvenanceGraph() for prefix, ns in ds.namespaces(): out.bind(prefix, ns) - for s, p, o in sub: - for *_, c in ds.quads((s, p, o, None)): - out.graph(c).add((s, p, o)) + if isinstance(ds, ProvenanceGraph): + for s, p, o in sub: + for *_, c in ds.quads((s, p, o, None)): + out.graph(c).add((s, p, o)) + else: + main = out.graph(self.reader._ontology_iri) + for t in sub: + main.add(t) return out def _is_internal(self, uri) -> bool: @@ -955,7 +956,7 @@ def _build_provenance_subgraph(self, instance) -> List[Dict[str, str]]: sub = self._with_provenance(sub) return [ {"label": f["label"], "code": self._safe_serialize(sub, f["fmt"])} - for f in self._formats() + for f in QUAD_SERIALIZATION_FORMATS ] @staticmethod @@ -970,14 +971,20 @@ def _safe_serialize(g, fmt: str) -> str: return f"# serialization error ({fmt}): {e}" def export_resource(self, resource_uri: str, fmt: str = "turtle") -> str: - """Serialize the subgraph of a single resource (the union of its punned instances).""" + """Serialize the subgraph of a single resource (the union of its punned instances). + + Only triples of the loaded artefact are exported; the ones that come from + imported modules belong to the provenance, not to the export. + """ g = Graph() for prefix, ns in self.reader._graph.namespaces(): g.bind(prefix, ns) + main = self.reader.get_main_graph() instances = self.get_instances_from_single_resource(resource_uri) if instances: inst_iter = instances if isinstance(instances, set) else [instances] for inst in inst_iter: - for t in self.reader.get_provenance_subgraph(inst): # already exists - g.add(t) - return self._safe_serialize(self._with_provenance(g), fmt) \ No newline at end of file + for t in self.reader.get_provenance_subgraph(inst): + if t in main: + g.add(t) + return self._safe_serialize(g, fmt) \ No newline at end of file diff --git a/tests/test_modules.py b/tests/test_modules.py index 8031110..99faac6 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -5,8 +5,9 @@ Each module (imported, closure, partial_import) loads external documents into the graph of the documented ontology. With any module active the graph is a ProvenanceGraph: every triple sits in the named graph of the document that asserts -it, and keeps that context through the URL cache, per-entity export and the -quad serializations (TriG, N-Quads, JSON-LD). +it, and keeps that context through the URL cache and the per-entity provenance +(quads: TriG, N-Quads, JSON-LD). Exports and content negotiation, instead, return +only the triples of the loaded artefact, with or without modules. Sections -------- @@ -16,7 +17,7 @@ 4. partial_import only the home definition of each mentioned external term 5. URL cache only the main document is cached; modules re-applied on a hit 6. Per-entity provenance (viewer) -7. HTTP API the same guarantees end-to-end, through serialization +7. HTTP API exports are triples of the loaded artefact only, end-to-end Fixtures -------- @@ -45,9 +46,8 @@ from lode.exceptions import ArtefactNotFoundError from lode.reader.loader import Loader from lode.reader.provenance_graph import ProvenanceGraph -from lode.viewer.base_viewer import ( - QUAD_SERIALIZATION_FORMATS, SERIALIZATION_FORMATS, BaseViewer, formats_for, -) +from lode.reader.reader import Reader +from lode.viewer.base_viewer import QUAD_SERIALIZATION_FORMATS, BaseViewer # ========================================================================== # FIXTURES @@ -374,13 +374,14 @@ def test_caching_the_union_breaks_provenance(self, main_path, tmp_path): class FakeReader: """Minimal reader for BaseViewer: only the graph and an empty instance cache.""" - def __init__(self, graph): + def __init__(self, graph, ontology_iri=None): self._graph = graph self._instance_cache = {} + self._ontology_iri = ontology_iri def entity_provenance(ds, subject): - """What an entity card exports: its triples, placed back in their origin graphs.""" + """What an entity card shows as provenance: its triples, placed in their origin graphs.""" sub = Graph() for t in ds.triples((subject, None, None)): sub.add(t) @@ -390,19 +391,25 @@ def entity_provenance(ds, subject): class TestEntityProvenance: """Formats offered, and each triple of an entity in the graph(s) it comes from.""" - def test_formats_without_modules_are_triple_formats(self): - assert formats_for(Graph()) is SERIALIZATION_FORMATS - - def test_formats_with_modules_are_quad_formats(self): - assert formats_for(ProvenanceGraph()) is QUAD_SERIALIZATION_FORMATS + def test_quad_formats_have_no_rdfxml(self): assert "xml" not in {f["fmt"] for f in QUAD_SERIALIZATION_FORMATS} - def test_without_modules_provenance_is_unchanged(self): + def test_without_modules_provenance_is_a_quad_in_the_artefact_graph(self): g = Graph() g.add((M.A, RDF.type, OWL.Class)) sub = Graph() sub.add((M.A, RDF.type, OWL.Class)) - assert BaseViewer(FakeReader(g))._with_provenance(sub) is sub + out = BaseViewer(FakeReader(g, MAIN))._with_provenance(sub) + assert isinstance(out, ProvenanceGraph) + assert graphs(out) == {MAIN} + + def test_card_provenance_names_the_graph_without_modules(self, main_path): + reader = Reader() + reader.load_instances(main_path, "owl") + viewer = reader.get_viewer() + codes = [f["code"] for inst in reader._instance_cache[H.T] + for f in viewer._build_provenance_subgraph(inst) if f["label"] == "TriG"] + assert codes and all(f"<{MAIN}>" in c for c in codes) def test_partial_import_term_spans_main_and_home(self, main_path): """home#T: label from main, definition from home, nothing from imp.""" @@ -440,12 +447,14 @@ def test_no_blank_graph_names(self, main_path, flag): # 7. HTTP API (end-to-end) # ========================================================================== # +# Exports and content negotiation return the triples of the loaded artefact, with or +# without modules: imported documents show up only in the provenance of each card. # Uploads go through the spool (spool.save) and are requested with -# GET /extract?upload_id=..., the same path as _resolve_reader. Quad serializations -# are parsed back into a Dataset (default_union=True, so objects()/subjects()/`in` -# see the named graphs) to check that contexts survive serialization. +# GET /extract?upload_id=..., the same path as _resolve_reader. -RDFLIB_FMT = {"trig": "trig", "nq": "nquads", "jsonld": "json-ld"} +TRIPLE_FORMATS = {"ttl": "turtle", "nt": "nt", "rdf": "xml", "jsonld": "json-ld"} +QUAD_EXTS = ["trig", "nq"] +ALL_FLAGS = [None, *MODULES] @pytest.fixture(scope="module") @@ -468,6 +477,11 @@ def _flags(flags) -> dict: return {k: "true" for k, v in flags.items() if v} +def flags_of(flag) -> dict: + """Request flags for one module, or none.""" + return {flag: True} if flag else {} + + def get(client, token, fmt, resource=None, **flags): """GET /extract on an upload, with an explicit ?format=.""" params = {"read_as": "owl", "upload_id": token, "format": fmt, **_flags(flags)} @@ -494,16 +508,11 @@ def get_url(client, url, fmt=None, resource=None, cache=False, **flags): return client.get("/extract", params=params) -def as_dataset(resp, fmt) -> Dataset: +def as_graph(resp, fmt) -> Graph: + """Parse an export response back into a plain Graph (a quad format would not parse here).""" assert resp.status_code == 200, resp.text[:500] assert "text/html" not in resp.headers.get("content-type", ""), f"{fmt}: got HTML instead of RDF" - ds = Dataset(default_union=True) - ds.parse(data=resp.text, format=RDFLIB_FMT[fmt]) - return ds - - -def default_graph_is_empty(ds) -> bool: - return len(ds.graph(DATASET_DEFAULT_GRAPH_ID)) == 0 + return Graph().parse(data=resp.text, format=TRIPLE_FORMATS[fmt]) def filename(resp) -> str: @@ -512,73 +521,29 @@ def filename(resp) -> str: return cd.split('filename="', 1)[1].rstrip('"') -class TestUploadWithoutModules: - """No modules: plain graph, no quad formats.""" - - @pytest.mark.parametrize("fmt", ["trig", "nq"]) - def test_quad_formats_rejected(self, client, fmt): - assert get(client, upload(MAIN_TTL), fmt).status_code == 406 - - @pytest.mark.parametrize("fmt", ["ttl", "nt", "rdf", "jsonld"]) - def test_triple_formats_available(self, client, fmt): - assert get(client, upload(MAIN_TTL), fmt).status_code == 200 - - def test_html_offers_triple_formats_only(self, client): - html = client.post("/extract", data={"read_as": "owl"}, - files={"file": ("main.ttl", MAIN_TTL, "text/turtle")}).text - assert "N-Triples" in html - assert "TriG" not in html and "N-Quads" not in html - - -class TestUploadImportedClosure: - """Formal imports through the API: graphs survive serialization.""" - - def test_html_offers_quad_formats(self, client): - html = client.post("/extract", data={"read_as": "owl", "imported": "true"}, - files={"file": ("main.ttl", MAIN_TTL, "text/turtle")}).text - assert "TriG" in html and "N-Quads" in html - assert "N-Triples" not in html and "RDF/XML" not in html - - @pytest.mark.parametrize("fmt", ["trig", "nq", "jsonld"]) - def test_imported_graphs_survive_serialization(self, client, fmt): - ds = as_dataset(get(client, upload(MAIN_TTL), fmt, imported=True), fmt) - assert graphs(ds) == {MAIN, IMP} - assert default_graph_is_empty(ds) - assert contexts(ds, (I.I, RDF.type, OWL.Class)) == {IMP} - assert (D.D, None, None) not in ds - - def test_upload_context_is_declared_ontology_not_spool_path(self, client): - ds = as_dataset(get(client, upload(MAIN_TTL), "trig", imported=True), "trig") - assert not any(str(g).startswith("file:") for g in graphs(ds)) - - def test_closure_fallback_context_is_url(self, client): - ds = as_dataset(get(client, upload(MAIN_TTL), "nq", closure=True), "nq") - assert graphs(ds) == {MAIN, IMP, DEEP} - assert contexts(ds, (D.D, RDF.type, OWL.Class)) == {DEEP} - - def test_imported_redefinition_stays_in_imported_graph(self, client): - ds = as_dataset(get(client, upload(MAIN_TTL), "trig", imported=True), "trig") - assert contexts(ds, (H.T, RDFS.comment, Literal("T redefined by imp"))) == {IMP} - - -class TestUploadPartialImport: - """partial_import through the API: same boundary as TestPartialImport.""" +class TestExportIsLoadedArtefactOnly: + """Whatever the modules, an export holds the triples of the loaded document and nothing else.""" - @pytest.fixture - def ds(self, client): - return as_dataset(get(client, upload(MAIN_TTL), "trig", partial_import=True), "trig") + @pytest.mark.parametrize("flag", ALL_FLAGS) + @pytest.mark.parametrize("fmt", list(TRIPLE_FORMATS)) + def test_whole_export_is_the_main_document(self, client, fmt, flag): + g = as_graph(get(client, upload(MAIN_TTL), fmt, **flags_of(flag)), fmt) + assert isomorphic(g, doc("main")) - def test_graphs_are_main_and_homes_only(self, ds): - assert graphs(ds) == {MAIN, HOME, VER} + @pytest.mark.parametrize("flag", ALL_FLAGS) + @pytest.mark.parametrize("fmt", QUAD_EXTS) + def test_quad_formats_rejected(self, client, fmt, flag): + assert get(client, upload(MAIN_TTL), fmt, **flags_of(flag)).status_code == 406 - def test_both_definitions_kept_with_provenance(self, ds): - """main restates the label of home#T: both labels kept, each in its own graph.""" - assert contexts(ds, (H.T, RDFS.label, Literal("T from main", lang="en"))) == {MAIN} - assert contexts(ds, (H.T, RDFS.label, Literal("T from home", lang="en"))) == {HOME} + def test_resource_export_has_main_triples_only(self, client): + """home#T: the label restated by main is exported, the definition of home is not.""" + g = as_graph(get(client, upload(MAIN_TTL), "ttl", resource=H.T, partial_import=True), "ttl") + assert ground(g) <= ground(doc("main")) + assert (H.T, RDFS.label, Literal("T from main", lang="en")) in g + assert (H.T, RDFS.comment, Literal("T defined by home")) not in g def test_documented_values_come_first(self, main_path): """The Reader puts main's label of home#T before home's.""" - from lode.reader.reader import Reader reader = Reader() reader.load_instances(graph_path=main_path, read_as="owl", partial_import=True) for inst in reader._instance_cache[H.T]: @@ -586,71 +551,43 @@ def test_documented_values_come_first(self, main_path): if labels: assert labels[0] == "T from main", labels - def test_home_definition_in_home_graph(self, ds): - assert contexts(ds, (H.T, RDFS.comment, Literal("T defined by home"))) == {HOME} - - def test_third_party_redefinition_absent(self, ds): - assert (H.T, RDFS.comment, Literal("T redefined by imp")) not in ds - assert (H.T, OWL.equivalentClass, I.I) not in ds - - def test_restriction_blank_nodes_in_home_graph(self, ds): - (restr,) = [o for o in ds.objects(H.T, RDFS.subClassOf) if isinstance(o, BNode)] - for t in ds.triples((restr, None, None)): - assert contexts(ds, t) == {HOME} - def test_versioned_home_found_via_dereferenced_document(self, ds): - assert contexts(ds, (V.V, RDF.type, OWL.Class)) == {VER} - - def test_unreachable_home_gives_no_external_definition(self, ds): - assert set(ds.predicate_objects(X.X)) == {(RDFS.comment, Literal("X described by main"))} - - def test_external_ontology_headers_absent(self, ds): - assert set(ds.subjects(RDF.type, OWL.Ontology)) == {MAIN} +class TestHtmlOffers: + """The page offers triples to export and quads as provenance, with or without modules.""" - @pytest.mark.parametrize("fmt", ["trig", "nq", "jsonld"]) - def test_resource_export_has_origin_graphs(self, client, fmt): - ds = as_dataset(get(client, upload(MAIN_TTL), fmt, resource=H.T, partial_import=True), fmt) - assert graphs(ds) == {MAIN, HOME} - assert default_graph_is_empty(ds) - assert contexts(ds, (H.T, RDF.type, OWL.Class)) == {HOME} + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_export_in_triples_provenance_in_quads(self, client, flag): + html = client.post("/extract", data={"read_as": "owl", **_flags(flags_of(flag))}, + files={"file": ("main.ttl", MAIN_TTL, "text/turtle")}).text + assert "N-Triples" in html and "RDF/XML" in html + assert "TriG" in html and "N-Quads" in html class TestDownloadAccept: """Accept is a preference: an unavailable format falls back to HTML (406 only for an explicit ?format=).""" - @pytest.mark.parametrize("mime,fmt", [("application/trig", "trig"), - ("application/n-quads", "nq"), - ("application/ld+json", "jsonld")]) - def test_quad_mime_with_modules(self, client, mime, fmt): - resp = get_accept(client, upload(MAIN_TTL), mime, imported=True) - assert resp.headers["content-type"].startswith(mime) - assert graphs(as_dataset(resp, fmt)) == {MAIN, IMP} - - def test_first_available_mime_wins(self, client): - resp = get_accept(client, upload(MAIN_TTL), "application/n-quads;q=0.9, text/html;q=0.8", - imported=True) - assert resp.headers["content-type"].startswith("application/n-quads") - - def test_resource_export_via_accept(self, client): - resp = get_accept(client, upload(MAIN_TTL), "application/trig", resource=H.T, partial_import=True) - assert graphs(as_dataset(resp, "trig")) == {MAIN, HOME} - - def test_quad_mime_without_modules_falls_back_to_html(self, client): - resp = get_accept(client, upload(MAIN_TTL), "application/trig") + @pytest.mark.parametrize("flag", ALL_FLAGS) + @pytest.mark.parametrize("mime", ["application/trig", "application/n-quads"]) + def test_quad_mime_falls_back_to_html(self, client, mime, flag): + resp = get_accept(client, upload(MAIN_TTL), mime, **flags_of(flag)) assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/html") - def test_triple_mime_without_modules(self, client): - resp = get_accept(client, upload(MAIN_TTL), "text/turtle") + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_triple_mime(self, client, flag): + resp = get_accept(client, upload(MAIN_TTL), "text/turtle", **flags_of(flag)) assert resp.headers["content-type"].startswith("text/turtle") - assert len(Graph().parse(data=resp.text, format="turtle")) > 0 + assert isomorphic(as_graph(resp, "ttl"), doc("main")) - def test_triple_mime_with_modules_falls_back_to_html(self, client): - """Current behaviour: with modules, Turtle is not offered. Update this test if the - union is ever served as Turtle alongside the quad formats.""" - resp = get_accept(client, upload(MAIN_TTL), "text/turtle", imported=True) - assert resp.headers["content-type"].startswith("text/html") + def test_first_available_mime_wins(self, client): + resp = get_accept(client, upload(MAIN_TTL), "application/n-quads;q=0.9, text/turtle;q=0.8", + imported=True) + assert resp.headers["content-type"].startswith("text/turtle") + + def test_resource_export_via_accept(self, client): + resp = get_accept(client, upload(MAIN_TTL), "text/turtle", resource=H.T, partial_import=True) + assert (H.T, RDFS.comment, Literal("T defined by home")) not in as_graph(resp, "ttl") def test_browser_accept_gets_html(self, client): resp = get_accept(client, upload(MAIN_TTL), "text/html,application/xhtml+xml,*/*;q=0.8", @@ -662,73 +599,64 @@ class TestDownloadFilename: """Content-Disposition file name: resource -> local name; graph -> last URL segment, or 'graph' for uploads.""" - @pytest.mark.parametrize("fmt", ["trig", "nq", "jsonld"]) + @pytest.mark.parametrize("fmt", ["ttl", "jsonld"]) def test_upload_whole_graph(self, client, fmt): assert filename(get(client, upload(MAIN_TTL), fmt, imported=True)) == f"graph.{fmt}" - @pytest.mark.parametrize("fmt", ["trig", "nq"]) - def test_resource_uses_local_name(self, client, fmt): - resp = get(client, upload(MAIN_TTL), fmt, resource=H.T, partial_import=True) - assert filename(resp) == f"T.{fmt}" + def test_resource_uses_local_name(self, client): + resp = get(client, upload(MAIN_TTL), "ttl", resource=H.T, partial_import=True) + assert filename(resp) == "T.ttl" def test_url_whole_graph_uses_last_segment(self, client, served_main): - assert filename(get_url(client, served_main, "trig", imported=True)) == "main.trig" + assert filename(get_url(client, served_main, "ttl", imported=True)) == "main.ttl" def test_url_trailing_slash(self, client, served_main): - assert filename(get_url(client, served_main + "/", "trig", imported=True)) == "main.trig" - - def test_triple_format_without_modules(self, client): - assert filename(get(client, upload(MAIN_TTL), "ttl")) == "graph.ttl" + assert filename(get_url(client, served_main + "/", "ttl", imported=True)) == "main.ttl" class TestDownloadUrl: """?url= path: fetch of the main document plus the spool cache.""" - def test_imported_graphs(self, client, served_main): - ds = as_dataset(get_url(client, served_main, "trig", imported=True), "trig") - assert graphs(ds) == {MAIN, IMP} - assert contexts(ds, (I.I, RDF.type, OWL.Class)) == {IMP} + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_export_is_the_main_document(self, client, served_main, flag): + g = as_graph(get_url(client, served_main, "ttl", **flags_of(flag)), "ttl") + assert isomorphic(g, doc("main")) - def test_partial_import_homes(self, client, served_main): - ds = as_dataset(get_url(client, served_main, "nq", partial_import=True), "nq") - assert graphs(ds) == {MAIN, HOME, VER} - assert (H.T, RDFS.comment, Literal("T redefined by imp")) not in ds - - def test_quad_format_without_modules_is_406(self, client, served_main): - assert get_url(client, served_main, "trig").status_code == 406 + @pytest.mark.parametrize("flag", ALL_FLAGS) + def test_quad_format_is_406(self, client, served_main, flag): + assert get_url(client, served_main, "trig", **flags_of(flag)).status_code == 406 @pytest.mark.parametrize("flag", MODULES) def test_cache_stores_main_document_only(self, client, served_main, flag): """Regression: the cache held the enriched union, and on a hit the imported triples ended up in the main graph.""" - get_url(client, served_main, "trig", cache=False, **{flag: True}) + get_url(client, served_main, "ttl", cache=False, **{flag: True}) token = api._url_token(served_main, "owl", *(True if f == flag else None for f in MODULES)) cached = Graph().parse(spool.get_path(token), format="turtle") - assert isomorphic(cached, Graph().parse(data=MAIN_TTL, format="turtle")) - - @pytest.mark.parametrize("flag", MODULES) - def test_cache_hit_preserves_provenance(self, client, served_main, flag): - fresh = as_dataset(get_url(client, served_main, "trig", cache=False, **{flag: True}), "trig") - hit = as_dataset(get_url(client, served_main, "trig", cache=True, **{flag: True}), "trig") - assert graphs(hit) == graphs(fresh) - for g in graphs(fresh): - assert isomorphic(hit.graph(g), fresh.graph(g)), g - - -class TestUploadEdgeCases: - def test_upload_without_ontology_uses_spool_path(self, client): - """Known and accepted: without owl:Ontology the context is the spool path.""" - ds = as_dataset(get(client, upload(DOCS["http://ex.org/anon"]), "trig", imported=True), "trig") - local = {g for g in graphs(ds) if str(g).startswith("file:")} - assert len(local) == 1 - assert contexts(ds, (URIRef("http://ex.org/anon#A"), RDF.type, OWL.Class)) == local + assert isomorphic(cached, doc("main")) @pytest.mark.parametrize("flag", MODULES) - def test_same_upload_reloaded_gives_same_quads(self, client, flag): - token = upload(MAIN_TTL) - a = as_dataset(get(client, token, "trig", **{flag: True}), "trig") - b = as_dataset(get(client, token, "trig", **{flag: True}), "trig") - assert graphs(a) == graphs(b) - for g in graphs(a): - assert isomorphic(a.graph(g), b.graph(g)), g - \ No newline at end of file + def test_cache_hit_gives_the_same_export(self, client, served_main, flag): + fresh = as_graph(get_url(client, served_main, "ttl", cache=False, **{flag: True}), "ttl") + hit = as_graph(get_url(client, served_main, "ttl", cache=True, **{flag: True}), "ttl") + assert isomorphic(hit, fresh) + + def test_cache_hit_keeps_the_url_as_graph_name(self, monkeypatch): + """Without a declared owl:Ontology the graph is named after the URL, not the spool copy.""" + monkeypatch.setattr(security, "check_url_safe", lambda url: None) + url = "http://ex.org/anon" + for use_cache in (False, True): + reader = api._load_url(url, "owl", None, None, None, False, use_cache=use_cache) + assert reader._ontology_iri == URIRef(url) + + +class TestSourceName: + """`source_name` names the graph when the ontology declares no IRI.""" + + def test_names_the_graph_without_declared_ontology(self, tmp_path): + p = tmp_path / "x.ttl" + p.write_text(" a .", encoding="utf-8") + assert Loader(str(p), source_name="http://ex.org/x.ttl").ontology_iri == URIRef("http://ex.org/x.ttl") + + def test_declared_ontology_wins(self, main_path): + assert Loader(main_path, source_name="http://ex.org/other").ontology_iri == MAIN \ No newline at end of file From df7f805a105c22a24aa8ae781a403fe2fb7fc0b1 Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 15:43:21 +0200 Subject: [PATCH 06/21] fix(viewer): deterministic order for ontology header metadata --- lode/viewer/base_viewer.py | 42 ++++++++++++++++++- ...del_annotations_deterministic_sorting.yaml | 42 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 lode/viewer/config/model_annotations_deterministic_sorting.yaml diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 457e786..86c9c34 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -5,7 +5,10 @@ """ import hashlib import re +from pathlib import Path from typing import Dict, List, Optional, Tuple + +import yaml from lode.models import Literal, Model, Resource, Statement import urllib.parse @@ -29,6 +32,41 @@ {"label": "N-Quads", "fmt": "nquads", "mime": "application/n-quads", "ext": "nq", "icon": "bi-file-text"}, ] +_ANNOTATION_ORDER_FILE = Path(__file__).parent / "config" / "model_annotations_deterministic_sorting.yaml" + + +def _normalize_annotation_key(key: str) -> str: + """Reduce a displayed metadata key to a comparable form. + + Case, spaces, underscores and hyphens are dropped so that 'version info', + 'versionInfo' and 'version_info' land in the same slot of the order list. + """ + return re.sub(r"[\s_\-]+", "", key.lower()) + + +def _load_annotation_order() -> Dict[str, int]: + """Read the YAML order list and map each normalized key to its rank. + + Loaded once at import time. A missing or malformed file raises: a silent + fallback would make the page order depend on the environment again. + """ + with open(_ANNOTATION_ORDER_FILE, encoding="utf-8") as f: + order = yaml.safe_load(f)["order"] + return {_normalize_annotation_key(k): i for i, k in enumerate(order)} + + +_ANNOTATION_RANK = _load_annotation_order() + + +def _annotation_sort_key(key: str) -> Tuple[int, str]: + """Sort key for a header metadata entry: configured rank, then alphabetical. + + Unlisted keys get a rank past the last listed one, so they follow the known + ones and stay in alphabetical order among themselves. + """ + return (_ANNOTATION_RANK.get(_normalize_annotation_key(key), len(_ANNOTATION_RANK)), key.lower()) + + class BaseViewer: """Base viewer to display the instances extracted by the Reader.""" @@ -457,9 +495,9 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None # 9. Statements data.update(self._format_statement(all_instances, ontology_model, language)) - # Alphabetical order on all the metadata (uri/label first) + # uri/label first, then the order from the YAML, then alphabetical pinned = ['uri', 'label'] - rest = sorted(k for k in data if k not in pinned) + rest = sorted((k for k in data if k not in pinned), key=_annotation_sort_key) data = {k: data[k] for k in pinned if k in data} | {k: data[k] for k in rest} return data diff --git a/lode/viewer/config/model_annotations_deterministic_sorting.yaml b/lode/viewer/config/model_annotations_deterministic_sorting.yaml new file mode 100644 index 0000000..6edb35c --- /dev/null +++ b/lode/viewer/config/model_annotations_deterministic_sorting.yaml @@ -0,0 +1,42 @@ +# Deterministic display order of the ontology header metadata. +# Each entry is matched against the displayed key, ignoring case, spaces, +# underscores and hyphens, so dc:creator and dcterms:creator share one slot. +# Keys not listed here come after these, in alphabetical order. +order: + # versioning + - version info + - version + - prior version + - replaces + - is version of + - imports + - backward compatible with + - incompatible with + - top concept + - changes + # title and description + - title + - alternative + - description + - abstract + - comment + - keywords + # responsibility + - author + - creator + - contributor + - publisher + - rights holder + - funder + # dates + - created + - issued + - modified + - date + # rights + - license + - rights + # references + - source + - see also + - is defined by \ No newline at end of file From dab07df932eedd696f2d1f6a2aeac9911fe56869 Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 15:45:39 +0200 Subject: [PATCH 07/21] fix(viewer): deterministic sorting for statements in cards - alphabetical order --- lode/viewer/base_viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 86c9c34..e612cea 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -692,7 +692,7 @@ def _format_statement(self, instances, target_instance, language=None) -> Dict: if values: statements[pred_label] = sorted(values, key=lambda v: (str(v.get('nested')), v['text'].lower())) - return statements + return dict(sorted(statements.items(), key=lambda kv: kv[0].lower())) @staticmethod def _clean_name(self, name: str) -> str: From 8471d4fbd6ba03da76cf5cfa654d4b64bf7e8299 Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 16:27:27 +0200 Subject: [PATCH 08/21] refactor(viewer): per-format YAML config, shared helpers and per-format card + configurable vocabulary --- lode/static/style.css | 118 ++++++++- .../{_entity_card.html => _card_owl.html} | 0 lode/templates/_card_skos.html | 50 ++++ lode/templates/owl_viewer.html | 133 ---------- lode/templates/skos_viewer.html | 245 ------------------ lode/templates/viewer.html | 2 +- lode/viewer/base_viewer.py | 232 +++++------------ lode/viewer/config/owl_viewer.yaml | 49 ++++ lode/viewer/config/rdf_viewer.yaml | 39 +++ lode/viewer/config/skos_viewer.yaml | 17 ++ lode/viewer/helpers.py | 190 ++++++++++++++ lode/viewer/owl_viewer.py | 43 +-- lode/viewer/rdf_viewer.py | 41 +-- lode/viewer/skos_viewer.py | 45 +--- 14 files changed, 533 insertions(+), 671 deletions(-) rename lode/templates/{_entity_card.html => _card_owl.html} (100%) create mode 100644 lode/templates/_card_skos.html delete mode 100644 lode/templates/owl_viewer.html delete mode 100644 lode/templates/skos_viewer.html create mode 100644 lode/viewer/config/owl_viewer.yaml create mode 100644 lode/viewer/config/rdf_viewer.yaml create mode 100644 lode/viewer/config/skos_viewer.yaml create mode 100644 lode/viewer/helpers.py diff --git a/lode/static/style.css b/lode/static/style.css index ac30622..55996f3 100644 --- a/lode/static/style.css +++ b/lode/static/style.css @@ -914,4 +914,120 @@ body.toc-sidebar-closed { margin-left: 5rem; } } .toggle-slider.url-mode { left: 0; width: 50%; } -.toggle-slider.file-mode { left: 50%; width: 50%; } \ No newline at end of file +.toggle-slider.file-mode { left: 50%; width: 50%; } + +/* ===== SKOS entity card (templates/_card_skos.html) ===== */ +/* LODE-style entity card */ +.entity-card { + border: 1px solid #dee2e6; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + background: #fff; +} + +.entity-header { + background: #f8f9fa; + padding: 1rem 1.25rem; + border-bottom: 1px solid #dee2e6; + border-radius: 0.5rem 0.5rem 0 0; +} + +.entity-label { + font-size: 1.25rem; + font-weight: 600; + color: #0d6efd; + margin: 0; +} + +.entity-label a { + color: inherit; + text-decoration: none; +} + +.entity-label a:hover { + text-decoration: underline; +} + +.back-to-toc { + font-size: 0.8rem; + color: #6c757d; +} + +.back-to-toc a { + color: #6c757d; + text-decoration: none; +} + +.back-to-toc a:hover { + color: #0d6efd; +} + +.entity-uri { + font-family: monospace; + font-size: 0.85rem; + color: #495057; + word-break: break-all; +} + +.entity-body { + padding: 1.25rem; +} + +.entity-definition { + font-size: 1rem; + color: #212529; + margin-bottom: 1.25rem; + line-height: 1.6; +} + +/* Semantic sections */ +.semantic-section { + margin-bottom: 0.75rem; +} + +.semantic-title { + font-weight: 600; + color: #495057; + margin-right: 0.5rem; +} + +.semantic-items { + display: inline; +} + +.semantic-item { + color: #0d6efd; + text-decoration: none; +} + +.semantic-item:hover { + text-decoration: underline; +} + +.semantic-item.external { + color: #6f42c1; +} + +.semantic-item.external::after { + content: ' β†—'; + font-size: 0.75em; +} + +.semantic-separator { + color: #6c757d; + margin: 0 0.25rem; +} + +/* Subscript style for type indicators */ +.type-indicator { + font-size: 0.7rem; + vertical-align: sub; + color: #6c757d; + margin-left: 0.1rem; +} + +/* Anchor offset */ +.anchor-offset { + scroll-margin-top: 1rem; +} + diff --git a/lode/templates/_entity_card.html b/lode/templates/_card_owl.html similarity index 100% rename from lode/templates/_entity_card.html rename to lode/templates/_card_owl.html diff --git a/lode/templates/_card_skos.html b/lode/templates/_card_skos.html new file mode 100644 index 0000000..d43effd --- /dev/null +++ b/lode/templates/_card_skos.html @@ -0,0 +1,50 @@ +
+
+
+
+

+ {{ item.label }}c +

+ {% if section.id %} + + back to ToC or {{ section.title }} ToC + + {% endif %} +
+ + {{ item.type }} + +
+
+ IRI: {{ item.uri }} +
+
+ +
+ {% if item.definition %} +

{{ item.definition }}

+ {% endif %} + + {% if item.semantic_sections %} + {% for sem_section in item.semantic_sections %} +
+ {{ sem_section.title }} + + {% for concept in sem_section.concepts %} + {% if concept.external %} + {{ concept.label }} + {% else %} + {{ concept.label }}c + {% endif %} + {% if not loop.last %},{% endif %} + {% endfor %} + +
+ {% endfor %} + {% endif %} + + {% if not item.definition and not item.semantic_sections %} +

No definition or semantic relations defined.

+ {% endif %} +
+
diff --git a/lode/templates/owl_viewer.html b/lode/templates/owl_viewer.html deleted file mode 100644 index 42d0ef0..0000000 --- a/lode/templates/owl_viewer.html +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - LODE 2.0 - Semantic Artefact Viewer - - - - -
-
-

LODE 2.0

-

Semantic Artefact Documentation

-
- - {% if error %} - - - {% else %} - - {% if grouped_view and sections %} -
- -
-
-
Table of Contents
-
- {% for section in sections %} - - {{ section.title }} - {{ section.entities|length }} - - {% endfor %} -
-
-
-
- - {% for section in sections %} -
-

- {{ section.title }} - {{ section.entities|length }} -

- -
-
-

Jump to:

-
- {% for item in section.entities %} - - {{ item.label }} - - {% endfor %} -
-
-
- - {% for item in section.entities %} -
-
-

{{ item.label }}

- - {{ item.type }} - -
- -
-
- URI -
- {{ item.uri }} - β†— -
-
- -
- {% if item.relations %} - {% for rel_name, rel_value in item.relations.items() %} -
-
-
- {{ rel_name }} -
-
- {% if rel_value is iterable and rel_value is not string %} -
    - {% for val in rel_value %} -
  • {{ val }}
  • - {% endfor %} -
- {% else %} - {{ rel_value }} - {% endif %} -
-
-
- {% endfor %} - {% else %} -
-

No attributes or relations defined.

-
- {% endif %} -
-
-
- {% if item.statements != [] %} - {% for statement in item.statements %} -
{{ statement.predicate }}: {{ statement.object }}
- {% endfor %} - {% endif %} -
-
- {% endfor %} -
- {% endfor %} - - {% if single_resource %} - - {% endif %} - - {% endif %} {% endif %} - -
- - \ No newline at end of file diff --git a/lode/templates/skos_viewer.html b/lode/templates/skos_viewer.html deleted file mode 100644 index a512bf1..0000000 --- a/lode/templates/skos_viewer.html +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - LODE 2.0 - SKOS Viewer - - - - - -
-
-

LODE 2.0

-

SKOS Vocabulary Documentation

-
- - {% if error %} - - - {% else %} - - {% if grouped_view and sections %} - -
-
-
Table of Contents
-
- {% for section in sections %} - - {{ section.title }} - {{ section.entities|length }} - - {% endfor %} -
-
-
- - - {% for section in sections %} -
-

- {{ section.title }} - {{ section.entities|length }} -

- - -
-
-

Jump to:

-
- {% for item in section.entities %} - - {{ item.label }} - - {% endfor %} -
-
-
- - - {% for item in section.entities %} -
-
-
- - - {{ item.type }} - -
-
- IRI: {{ item.uri }} -
-
- -
- {% if item.definition %} -

{{ item.definition }}

- {% endif %} - - {% if item.semantic_sections %} - {% for sem_section in item.semantic_sections %} -
- {{ sem_section.title }} - - {% for concept in sem_section.concepts %} - {% if concept.external %} - {{ concept.label }} - {% else %} - {{ concept.label }}c - {% endif %} - {% if not loop.last %},{% endif %} - {% endfor %} - -
- {% endfor %} - {% endif %} - - {% if not item.definition and not item.semantic_sections %} -

No definition or semantic relations defined.

- {% endif %} -
-
- {% endfor %} -
- {% endfor %} - - {% endif %} - - {% if single_resource %} - - {% endif %} - - {% endif %} -
- - - - \ No newline at end of file diff --git a/lode/templates/viewer.html b/lode/templates/viewer.html index e6d4cb5..9acd7aa 100644 --- a/lode/templates/viewer.html +++ b/lode/templates/viewer.html @@ -264,7 +264,7 @@

{% for item in section.entities %} - {% include '_entity_card.html' %} + {% include card_template %} {% endfor %}

diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index e612cea..6ea2112 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -4,78 +4,57 @@ and display names. It also serializes the provenance of each entity. """ import hashlib -import re -from pathlib import Path from typing import Dict, List, Optional, Tuple -import yaml from lode.models import Literal, Model, Resource, Statement -import urllib.parse from rdflib import Graph, URIRef, BNode, Literal as RDFlibLiteral from lode.reader.provenance_graph import ProvenanceGraph from lode.helpers.rdf import iri_local_name -# Export formats: triples of the loaded artefact only. -SERIALIZATION_FORMATS = [ - {"label": "Turtle", "fmt": "turtle", "mime": "text/turtle", "ext": "ttl", "icon": "bi-filetype-raw"}, - {"label": "RDF/XML", "fmt": "xml", "mime": "application/rdf+xml", "ext": "rdf", "icon": "bi-file-code"}, - {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, - {"label": "N-Triples", "fmt": "nt", "mime": "application/n-triples", "ext": "nt", "icon": "bi-file-text"}, -] +from lode.viewer.helpers import ( + QUAD_SERIALIZATION_FORMATS, + SERIALIZATION_FORMATS, + annotation_sort_key, + clean_name, + is_likely_ontology, + load_viewer_config, + safe_serialize, +) -# Provenance formats: quads, the named graph of each triple is always shown. -QUAD_SERIALIZATION_FORMATS = [ - {"label": "TriG", "fmt": "trig", "mime": "application/trig", "ext": "trig", "icon": "bi-filetype-raw"}, - {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, - {"label": "N-Quads", "fmt": "nquads", "mime": "application/n-quads", "ext": "nq", "icon": "bi-file-text"}, -] -_ANNOTATION_ORDER_FILE = Path(__file__).parent / "config" / "model_annotations_deterministic_sorting.yaml" - - -def _normalize_annotation_key(key: str) -> str: - """Reduce a displayed metadata key to a comparable form. - - Case, spaces, underscores and hyphens are dropped so that 'version info', - 'versionInfo' and 'version_info' land in the same slot of the order list. - """ - return re.sub(r"[\s_\-]+", "", key.lower()) - - -def _load_annotation_order() -> Dict[str, int]: - """Read the YAML order list and map each normalized key to its rank. - - Loaded once at import time. A missing or malformed file raises: a silent - fallback would make the page order depend on the environment again. - """ - with open(_ANNOTATION_ORDER_FILE, encoding="utf-8") as f: - order = yaml.safe_load(f)["order"] - return {_normalize_annotation_key(k): i for i, k in enumerate(order)} - - -_ANNOTATION_RANK = _load_annotation_order() - - -def _annotation_sort_key(key: str) -> Tuple[int, str]: - """Sort key for a header metadata entry: configured rank, then alphabetical. +class BaseViewer: + """Base viewer to display the instances extracted by the Reader. - Unlisted keys get a rank past the last listed one, so they follow the known - ones and stay in alphabetical order among themselves. + It orchestrates the view: everything that differs between formats (display + names, table of contents, hierarchy, card template, vocabulary) is read from + the YAML file named by CONFIG_NAME. Subclasses set that name and override + only the steps that really need code. """ - return (_ANNOTATION_RANK.get(_normalize_annotation_key(key), len(_ANNOTATION_RANK)), key.lower()) - -class BaseViewer: - """Base viewer to display the instances extracted by the Reader.""" + # Name of config/_viewer.yaml; None gives the generic flat list view. + CONFIG_NAME: Optional[str] = None def __init__(self, reader): - """Bind the viewer to a Reader and share its instance cache.""" + """Bind the viewer to a Reader, share its instance cache and load its config.""" self.reader = reader - self._cache = reader._instance_cache # it uses + self._cache = reader._instance_cache # it uses self._internal_iris = None - self._ontology_ns = None + self._ontology_ns = None + self._config = ( + load_viewer_config(self.CONFIG_NAME) if self.CONFIG_NAME + else {"type_map": {}, "toc": [], "card": "_card_owl.html", + "vocabulary": {}, "hierarchy": {}, "characteristics": []} + ) + + def get_toc_config(self) -> List[Tuple[str, str, str]]: + """Return the table of contents: (class name, section id, type_map key) in display order.""" + return [tuple(entry) for entry in self._config["toc"]] + + def _clean_name(self, name: str) -> str: + """Turn an attribute or getter name into a display label, using this viewer's vocabulary.""" + return clean_name(name, self._config["vocabulary"]) def _get_ontology_ns(self) -> str: """Return the namespace of the documented ontology (its IRI ending in '/' or '#'), or ''. @@ -198,33 +177,36 @@ def _get_best_label(self, resource, language: Optional[str] = None) -> Optional[ if resource_id: clean_resource_id = iri_local_name(resource_id) # ONLY clean the identifier! - return self._clean_name(self, clean_resource_id) + return self._clean_name(clean_resource_id) return None def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - """ - Main entry point called by the API - Subclasses should override this to define their specific view strategy + """Build the template data for one resource, or for the whole artefact grouped by entity type. + + Main entry point called by the API and by the static builder. Both views also + carry the header metadata, the export formats, the display names of the types + and the card template. Without a table of contents (generic viewer) the + artefact is shown as a flat list. """ # If no language is provided, the default would be English language = language.strip() if language else "en" - # Fallback: generic flat list all_instances = self.get_all_instances() metadata_dict = self._find_and_format_metadata(all_instances, language) if resource_uri: data = self._handle_single_resource(resource_uri, language) - data['metadata'] = metadata_dict - data['export_formats'] = self._formats() - return data + elif self._config["toc"]: + data = self._build_grouped_view(self.get_toc_config(), language) + else: + data = {'entities': self._format_entities(all_instances, language)} - return { - 'metadata': metadata_dict, - 'entities': self._format_entities(all_instances, language), - 'export_formats': self._formats(), - } + data['metadata'] = metadata_dict + data['export_formats'] = self._formats() + data['type_map'] = self._config["type_map"] + data['card_template'] = self._config["card"] + return data def _is_toc_entity(self, instance) -> bool: """Browsable (own card + clickable link) iff its type is in get_toc_config. @@ -282,7 +264,7 @@ def _build_grouped_view(self, group_definitions: List[Tuple[str, str, str]], lan 'entities': self._format_entities(instances, language), } - parent_getter = self._HIERARCHY_PARENT_GETTERS.get(class_key) + parent_getter = self._config["hierarchy"].get(class_key) if parent_getter: section['tree'] = self._build_hierarchy(instances, parent_getter, language) @@ -318,7 +300,7 @@ def _format_entities(self, instances: List, language: Optional[str] = None) -> L if not attr.startswith('_') and value: # Skip attributes that are handled elsewhere or are empty # Clean up name: - clean_name = self._clean_name(self,attr) + clean_name = self._clean_name(attr) # Process value (could be a list of objects)clean # We use the helper to get clean text for each item @@ -372,12 +354,7 @@ def _format_entities(self, instances: List, language: Optional[str] = None) -> L ordered_relations[key] = relations[key] characteristics = {} - char_attributes = [ - 'is_functional', 'is_inverse_functional', 'is_transitive', - 'is_symmetric', 'is_asymmetric', 'is_reflexive', 'is_irreflexive' - ] - - for attr in char_attributes: + for attr in self._config["characteristics"]: getter = f"get_{attr}" # Safely get the boolean value is_true = getattr(instance, getter)() if hasattr(instance, getter) else getattr(instance, attr, False) @@ -461,7 +438,7 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None if values: # 4. Auto-format the key name - clean_key = self._clean_name(self, attr_name) + clean_key = self._clean_name(attr_name) # 5. Ensure values are in a list if not isinstance(values, list): @@ -474,7 +451,7 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None if entry['text']: # 7. Check if the link if an visualizable ontology or not if entry.get('link'): - entry['is_visualizable'] = self._is_likely_ontology(entry['link']) + entry['is_visualizable'] = is_likely_ontology(entry['link']) else: entry['is_visualizable'] = False @@ -497,7 +474,7 @@ def _find_and_format_metadata(self, all_instances: List[Resource], language=None # uri/label first, then the order from the YAML, then alphabetical pinned = ['uri', 'label'] - rest = sorted((k for k in data if k not in pinned), key=_annotation_sort_key) + rest = sorted((k for k in data if k not in pinned), key=annotation_sort_key) data = {k: data[k] for k in pinned if k in data} | {k: data[k] for k in rest} return data @@ -617,7 +594,7 @@ def _resolve_resource_value(self, obj, language=None) -> dict: resolved = self._resolve_resource_value(v, language) if resolved.get('text') or resolved.get('nested'): siblings.append({ - 'predicate': self._clean_name(self, attr), + 'predicate': self._clean_name(attr), 'value': resolved, }) # Reader order depends on the source file: sort by label, then by value @@ -694,29 +671,6 @@ def _format_statement(self, instances, target_instance, language=None) -> Dict: return dict(sorted(statements.items(), key=lambda kv: kv[0].lower())) - @staticmethod - def _clean_name(self, name: str) -> str: - """Turn an attribute or getter name into a display label. - - Drops the has_/get_ prefix, splits camelCase, replaces underscores with - spaces and lowercases. It is a staticmethod that still takes `self` as its - first argument, so callers pass it explicitly. - """ - if not name: return "" - - name = re.sub(r'^(get_has_|get_|has_)', '', name) - # Put space between camelCase letters - name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name) - name = name.replace('_', ' ') - - # Strip extra spaces - name = ' '.join(name.split()) - - # lowercase everything - name = name.lower() - - return name - def _parse_restriction(self, obj, language=None, _seen=None) -> list: """ Recursively unpacks nested restrictions into a list of display parts @@ -864,65 +818,6 @@ def _get(instance, prop_name, default=None): return [] - def _is_likely_ontology(self, url: str) -> bool: - """Determines if a URL likely points to an ontology based on naming patterns.""" - if not url or not isinstance(url, str) or not url.startswith('http'): - return False - - # 1. Clean the string - clean_url = url.strip().lower() - - # 2. Safely parse the URL to ignore fragments (#) and queries (?) for extension checking - parsed = urllib.parse.urlparse(clean_url) - path = parsed.path.rstrip('/') - - # 3. Standard Ontology File Extensions - ontology_exts = ['.owl', '.rdf', '.ttl', '.nt', '.n3', '.jsonld'] - if any(path.endswith(ext) for ext in ontology_exts): - return True - - # Match registries against the parsed hostname, not the raw string: - # a substring check would also match e.g. https://evil.com/w3id.org/x - host = parsed.hostname or '' - - def _host_is(domain: str) -> bool: - """True if the URL host is the domain or one of its subdomains.""" - return host == domain or host.endswith('.' + domain) - - # 4. Common Ontology Registries & Permanent URLs - ontology_domains = [ - 'w3id.org', - 'purl.org', - 'xmlns.com' # Often used for FOAF - ] - if any(_host_is(domain) for domain in ontology_domains): - return True - - # 5. Core W3C Namespaces - w3c_core = ['/1999/02/22-rdf-syntax-ns', '/2000/01/rdf-schema', '/2002/07/owl'] - if _host_is('w3.org') and any(ns in path for ns in w3c_core): - return True - - # 6. Broad Keywords & Famous Standard Acronyms - # Looking for these anywhere in the URL string - path_keywords = [ - 'ontology', 'vocab', 'schema', # General terms - 'skos', 'foaf', 'crm', 'cidoc' # Famous standards - ] - if any(keyword in clean_url for keyword in path_keywords): - return True - - return False - - # Map class_key -> name of the parent getter (missing = no hierarchy) - _HIERARCHY_PARENT_GETTERS = { - 'Concept': 'get_is_sub_concept_of', - 'Relation': 'get_is_sub_property_of', - 'Attribute': 'get_is_sub_property_of', - 'Property': 'get_is_sub_property_of', - # Annotation, Individual: no hierarchy -> not in the map - } - _FULL_IRI_ATTRS = {'is_defined_by'} def _build_hierarchy(self, instances, parent_getter_name, language=None): @@ -993,21 +888,10 @@ def _build_provenance_subgraph(self, instance) -> List[Dict[str, str]]: return [] sub = self._with_provenance(sub) return [ - {"label": f["label"], "code": self._safe_serialize(sub, f["fmt"])} + {"label": f["label"], "code": safe_serialize(sub, f["fmt"])} for f in QUAD_SERIALIZATION_FORMATS ] - @staticmethod - def _safe_serialize(g, fmt: str) -> str: - """Serialize a graph, returning a comment with the error instead of raising. - - One format that fails must not break the page that shows all of them. - """ - try: - return g.serialize(format=fmt) - except Exception as e: - return f"# serialization error ({fmt}): {e}" - def export_resource(self, resource_uri: str, fmt: str = "turtle") -> str: """Serialize the subgraph of a single resource (the union of its punned instances). @@ -1025,4 +909,4 @@ def export_resource(self, resource_uri: str, fmt: str = "turtle") -> str: for t in self.reader.get_provenance_subgraph(inst): if t in main: g.add(t) - return self._safe_serialize(g, fmt) \ No newline at end of file + return safe_serialize(g, fmt) \ No newline at end of file diff --git a/lode/viewer/config/owl_viewer.yaml b/lode/viewer/config/owl_viewer.yaml new file mode 100644 index 0000000..d826d78 --- /dev/null +++ b/lode/viewer/config/owl_viewer.yaml @@ -0,0 +1,49 @@ +# Settings of the OWL viewer. Read by lode.viewer.helpers.load_viewer_config. + +# Partial included once per entity by viewer.html. +card: _card_owl.html + +# Display vocabulary: internal word -> shown word. Applied to whole words of the +# property labels (e.g. "is sub concept of" -> "is sub class of"). +vocabulary: + concept: class + concepts: classes + relation: object property + relations: object properties + attribute: data property + attributes: data properties + +# Display names of the entity types, keyed by the lowercase model class name. +# abb is the badge shown next to a label. +type_map: + concept: {singular: Class, plural: Classes, abb: c} + relation: {singular: Object Property, plural: Object Properties, abb: op} + attribute: {singular: Data Property, plural: Data Properties, abb: dp} + annotation: {singular: Annotation Property, plural: Annotation Properties, abb: ap} + individual: {singular: Named Individual, plural: Named Individuals, abb: ni} + +# Table of contents in display order: model class name, section id, type_map key. +# Only these classes are browsable (own card and link). +toc: + - [Concept, concepts, Concept] + - [Relation, relations, Relation] + - [Attribute, attributes, Attribute] + - [Individual, individuals, Individual] + - [Annotation, annotations, Annotation] + +# Model class -> getter of its parents, for the hierarchy tree of a section. +# A class that is missing has no hierarchy. +hierarchy: + Concept: get_is_sub_concept_of + Relation: get_is_sub_property_of + Attribute: get_is_sub_property_of + +# Boolean flags shown as characteristics of a property. +characteristics: + - is_functional + - is_inverse_functional + - is_transitive + - is_symmetric + - is_asymmetric + - is_reflexive + - is_irreflexive diff --git a/lode/viewer/config/rdf_viewer.yaml b/lode/viewer/config/rdf_viewer.yaml new file mode 100644 index 0000000..02a1f9d --- /dev/null +++ b/lode/viewer/config/rdf_viewer.yaml @@ -0,0 +1,39 @@ +# Settings of the RDF/RDFS viewer. Read by lode.viewer.helpers.load_viewer_config. + +# Same card as OWL for now; point to a dedicated partial when the two diverge. +card: _card_owl.html + +# Display vocabulary: internal word -> shown word. Applied to whole words of the +# property labels (e.g. "is sub concept of" -> "is sub class of"). +vocabulary: + concept: class + concepts: classes + +# Display names of the entity types, keyed by the lowercase model class name. +type_map: + concept: {singular: Class, plural: Classes, abb: c} + property: {singular: Property, plural: Properties, abb: p} + container: {singular: Container, plural: Containers, abb: cont} + resource: {singular: Resource, plural: Resources, abb: r} + model: {singular: Vocabulary, plural: Vocabularies, abb: v} + +# Table of contents in display order: model class name, section id, type_map key. +toc: + - [Concept, concepts, Concept] + - [Property, properties, Property] + - [Container, containers, Container] + +# Model class -> getter of its parents, for the hierarchy tree of a section. +hierarchy: + Concept: get_is_sub_concept_of + Property: get_is_sub_property_of + +# Boolean flags shown as characteristics of a property. +characteristics: + - is_functional + - is_inverse_functional + - is_transitive + - is_symmetric + - is_asymmetric + - is_reflexive + - is_irreflexive diff --git a/lode/viewer/config/skos_viewer.yaml b/lode/viewer/config/skos_viewer.yaml new file mode 100644 index 0000000..7ce90ec --- /dev/null +++ b/lode/viewer/config/skos_viewer.yaml @@ -0,0 +1,17 @@ +# Settings of the SKOS viewer. Read by lode.viewer.helpers.load_viewer_config. + +# Partial included once per entity by viewer.html. +card: _card_skos.html + +# SKOS keeps the model terms as they are. +vocabulary: {} + +# Display names of the entity types, keyed by the lowercase model class name. +type_map: + collection: {singular: Collection, plural: Collections, abb: col} + concept: {singular: Concept, plural: Concepts, abb: c} + +# Table of contents in display order: model class name, section id, type_map key. +toc: + - [Collection, collections, Collection] + - [Concept, concepts, Concept] diff --git a/lode/viewer/helpers.py b/lode/viewer/helpers.py new file mode 100644 index 0000000..21a81d7 --- /dev/null +++ b/lode/viewer/helpers.py @@ -0,0 +1,190 @@ +"""Stateless helpers shared by the viewers. + +Nothing here needs a Reader or a model instance: serialization formats, the +deterministic order of the header metadata, label cleaning, URL heuristics and +the loader of the per-format YAML configuration. +""" +import re +import urllib.parse +from pathlib import Path +from typing import Dict, Optional, Tuple + +import yaml + +_CONFIG_DIR = Path(__file__).parent / "config" + +# ========================================================================== +# 1. SERIALIZATION FORMATS +# ========================================================================== + +# Export formats: triples of the loaded artefact only. +SERIALIZATION_FORMATS = [ + {"label": "Turtle", "fmt": "turtle", "mime": "text/turtle", "ext": "ttl", "icon": "bi-filetype-raw"}, + {"label": "RDF/XML", "fmt": "xml", "mime": "application/rdf+xml", "ext": "rdf", "icon": "bi-file-code"}, + {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, + {"label": "N-Triples", "fmt": "nt", "mime": "application/n-triples", "ext": "nt", "icon": "bi-file-text"}, +] + +# Provenance formats: quads, the named graph of each triple is always shown. +QUAD_SERIALIZATION_FORMATS = [ + {"label": "TriG", "fmt": "trig", "mime": "application/trig", "ext": "trig", "icon": "bi-filetype-raw"}, + {"label": "JSON-LD", "fmt": "json-ld", "mime": "application/ld+json", "ext": "jsonld", "icon": "bi-filetype-json"}, + {"label": "N-Quads", "fmt": "nquads", "mime": "application/n-quads", "ext": "nq", "icon": "bi-file-text"}, +] + + +def safe_serialize(g, fmt: str) -> str: + """Serialize a graph, returning a comment with the error instead of raising. + + One format that fails must not break the page that shows all of them. + """ + try: + return g.serialize(format=fmt) + except Exception as e: + return f"# serialization error ({fmt}): {e}" + + +# ========================================================================== +# 2. HEADER METADATA ORDER +# ========================================================================== + +_ANNOTATION_ORDER_FILE = _CONFIG_DIR / "model_annotations_deterministic_sorting.yaml" + + +def _normalize_annotation_key(key: str) -> str: + """Reduce a displayed metadata key to a comparable form. + + Case, spaces, underscores and hyphens are dropped so that 'version info', + 'versionInfo' and 'version_info' land in the same slot of the order list. + """ + return re.sub(r"[\s_\-]+", "", key.lower()) + + +def _load_annotation_order() -> Dict[str, int]: + """Read the YAML order list and map each normalized key to its rank. + + Loaded once at import time. A missing or malformed file raises: a silent + fallback would make the page order depend on the environment again. + """ + with open(_ANNOTATION_ORDER_FILE, encoding="utf-8") as f: + order = yaml.safe_load(f)["order"] + return {_normalize_annotation_key(k): i for i, k in enumerate(order)} + + +_ANNOTATION_RANK = _load_annotation_order() + + +def annotation_sort_key(key: str) -> Tuple[int, str]: + """Sort key for a header metadata entry: configured rank, then alphabetical. + + Unlisted keys get a rank past the last listed one, so they follow the known + ones and stay in alphabetical order among themselves. + """ + return (_ANNOTATION_RANK.get(_normalize_annotation_key(key), len(_ANNOTATION_RANK)), key.lower()) + + +# ========================================================================== +# 3. LABELS AND URLS +# ========================================================================== + +def clean_name(name: str, vocabulary: Optional[Dict[str, str]] = None) -> str: + """Turn an attribute or getter name into a display label. + + Drops the has_/get_ prefix, splits camelCase, replaces underscores with + spaces and lowercases. The vocabulary of the viewer (internal word -> shown + word) is applied last, on whole words only, so that e.g. "concept" does not + touch "conceptual". + """ + if not name: + return "" + + name = re.sub(r'^(get_has_|get_|has_)', '', name) + # Put space between camelCase letters + name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name) + name = name.replace('_', ' ') + + # Strip extra spaces and lowercase everything + name = ' '.join(name.split()).lower() + + for word, shown in (vocabulary or {}).items(): + name = re.sub(rf'\b{re.escape(word)}\b', shown, name) + + return name + + +def is_likely_ontology(url: str) -> bool: + """Determines if a URL likely points to an ontology based on naming patterns.""" + if not url or not isinstance(url, str) or not url.startswith('http'): + return False + + # 1. Clean the string + clean_url = url.strip().lower() + + # 2. Safely parse the URL to ignore fragments (#) and queries (?) for extension checking + parsed = urllib.parse.urlparse(clean_url) + path = parsed.path.rstrip('/') + + # 3. Standard Ontology File Extensions + ontology_exts = ['.owl', '.rdf', '.ttl', '.nt', '.n3', '.jsonld'] + if any(path.endswith(ext) for ext in ontology_exts): + return True + + # Match registries against the parsed hostname, not the raw string: + # a substring check would also match e.g. https://evil.com/w3id.org/x + host = parsed.hostname or '' + + def _host_is(domain: str) -> bool: + """True if the URL host is the domain or one of its subdomains.""" + return host == domain or host.endswith('.' + domain) + + # 4. Common Ontology Registries & Permanent URLs + ontology_domains = [ + 'w3id.org', + 'purl.org', + 'xmlns.com' # Often used for FOAF + ] + if any(_host_is(domain) for domain in ontology_domains): + return True + + # 5. Core W3C Namespaces + w3c_core = ['/1999/02/22-rdf-syntax-ns', '/2000/01/rdf-schema', '/2002/07/owl'] + if _host_is('w3.org') and any(ns in path for ns in w3c_core): + return True + + # 6. Broad Keywords & Famous Standard Acronyms + # Looking for these anywhere in the URL string + path_keywords = [ + 'ontology', 'vocab', 'schema', # General terms + 'skos', 'foaf', 'crm', 'cidoc' # Famous standards + ] + if any(keyword in clean_url for keyword in path_keywords): + return True + + return False + + +# ========================================================================== +# 4. PER-FORMAT VIEWER CONFIGURATION +# ========================================================================== + +_REQUIRED_CONFIG_KEYS = ("type_map", "toc", "card") + + +def load_viewer_config(name: str) -> dict: + """Read config/_viewer.yaml and fill in the optional keys. + + The file holds everything that differs between formats (display names, table + of contents, hierarchy getters, card template, display vocabulary), so the + viewer classes carry no per-format data. A missing file or required key + raises: a silent default would change the page without any trace. + """ + path = _CONFIG_DIR / f"{name}_viewer.yaml" + with open(path, encoding="utf-8") as f: + config = yaml.safe_load(f) + missing = [k for k in _REQUIRED_CONFIG_KEYS if k not in config] + if missing: + raise ValueError(f"{path.name}: missing required keys {missing}") + config.setdefault("vocabulary", {}) + config.setdefault("hierarchy", {}) + config.setdefault("characteristics", []) + return config diff --git a/lode/viewer/owl_viewer.py b/lode/viewer/owl_viewer.py index db01eed..135d543 100644 --- a/lode/viewer/owl_viewer.py +++ b/lode/viewer/owl_viewer.py @@ -1,45 +1,8 @@ """Viewer for OWL artefacts.""" -from typing import Dict, Optional from lode.viewer.base_viewer import BaseViewer -class OwlViewer(BaseViewer): - """OWL viewer""" - - TYPE_MAP = { - 'concept': {'singular': 'Class', 'plural': 'Classes', 'abb': 'c'}, - 'relation': {'singular': 'Object Property', 'plural': 'Object Properties', 'abb': 'op'}, - 'attribute': {'singular': 'Data Property', 'plural': 'Data Properties', 'abb': 'dp'}, - 'annotation': {'singular': 'Annotation Property', 'plural': 'Annotation Properties', 'abb': 'ap'}, - 'individual': {'singular': 'Named Individual', 'plural': 'Named Individuals', 'abb': 'ni'}, - } - - def get_toc_config(self): - """Return the table of contents: (class name, section id, TYPE_MAP key) in display order.""" - return [ - ('Concept', 'concepts', 'Concept'), - ('Relation', 'relations', 'Relation'), - ('Attribute', 'attributes', 'Attribute'), - ('Individual', 'individuals', 'Individual'), - ('Annotation', 'annotations', 'Annotation'), - ] - - - def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - """Build the template data for one resource, or for the whole ontology grouped by entity type. - Both views also carry the ontology metadata and the TYPE_MAP of display names. - """ - - anguage = language.strip() if language else "en" - - all_instances = self.get_all_instances() - metadata_dict = self._find_and_format_metadata(all_instances, language) - - if resource_uri: - data = super().get_view_data(resource_uri, language) - else: - data = self._build_grouped_view(self.get_toc_config(), language) +class OwlViewer(BaseViewer): + """OWL viewer: display names, table of contents and card come from config/owl_viewer.yaml.""" - data['metadata'] = metadata_dict - data['type_map'] = self.TYPE_MAP - return data \ No newline at end of file + CONFIG_NAME = "owl" diff --git a/lode/viewer/rdf_viewer.py b/lode/viewer/rdf_viewer.py index 34840a0..7b10986 100644 --- a/lode/viewer/rdf_viewer.py +++ b/lode/viewer/rdf_viewer.py @@ -1,45 +1,8 @@ """Viewer for RDF/RDFS artefacts.""" -from typing import Dict, Optional from lode.viewer.base_viewer import BaseViewer class RdfViewer(BaseViewer): - """Viewer RDF / RDFS""" + """RDF / RDFS viewer: display names, table of contents and card come from config/rdf_viewer.yaml.""" - TYPE_MAP = { - 'concept': {'singular': 'Class', 'plural': 'Classes', 'abb': 'c'}, - 'property': {'singular': 'Property', 'plural': 'Properties', 'abb': 'p'}, - 'container': {'singular': 'Container', 'plural': 'Containers', 'abb': 'cont'}, - 'resource': {'singular': 'Resource', 'plural': 'Resources', 'abb': 'r'}, - 'model': {'singular': 'Vocabulary','plural': 'Vocabularies','abb': 'v'}, - } - - def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - """Build the template data for one resource, or for the vocabulary grouped by concepts, properties and containers. - - Both views also carry the vocabulary metadata and the TYPE_MAP of display names. - """ - - language = language.strip() if language else "en" - - all_instances = self.get_all_instances() - metadata_dict = self._find_and_format_metadata(all_instances, language) - - if resource_uri: - data = super().get_view_data(resource_uri, language) - data['metadata'] = metadata_dict - data['type_map'] = self.TYPE_MAP - return data - - # title in SINGULAR to match the TYPE_MAP keys - toc_config = [ - ('Concept', 'concepts', 'Concept'), - ('Property', 'properties', 'Property'), - ('Container', 'containers', 'Container'), - ] - - data = self._build_grouped_view(toc_config, language) - data['metadata'] = metadata_dict - data['type_map'] = self.TYPE_MAP - - return data \ No newline at end of file + CONFIG_NAME = "rdf" diff --git a/lode/viewer/skos_viewer.py b/lode/viewer/skos_viewer.py index f434aca..96ea462 100644 --- a/lode/viewer/skos_viewer.py +++ b/lode/viewer/skos_viewer.py @@ -5,47 +5,16 @@ from lode.helpers.rdf import iri_local_name class SkosViewer(BaseViewer): - """SKOS viewer with LODE-style rendering.""" + """SKOS viewer with LODE-style rendering. - def get_view_data(self, resource_uri: Optional[str] = None, language: Optional[str] = None) -> Dict: - """Build the template data for one resource, or for the scheme grouped by collections and concepts.""" - # 1. Single resource detail view - if resource_uri: - return super().get_view_data(resource_uri, language) + Display names, table of contents and card come from config/skos_viewer.yaml; + only the entity formatting differs from the base, because SKOS cards show + semantic relations between concepts instead of OWL-style statements. + """ - # 2. Define the Table of Contents structure for SKOS - toc_config = [ - ('Collection', 'collections', 'Collections'), - ('Concept', 'concepts', 'Concepts'), - ] + CONFIG_NAME = "skos" - # 3. Build grouped view with SKOS-specific formatting - return self._build_skos_grouped_view(toc_config, language) - - def _build_skos_grouped_view(self, group_definitions: List, language: Optional[str] = None) -> Dict: - """Build the grouped view with SKOS-style formatting.""" - all_instances = self.get_all_instances() - sections = [] - - for class_key, section_id, section_title in group_definitions: - instances = [ - inst for inst in all_instances - if type(inst).__name__ == class_key - ] - - if instances: - sections.append({ - 'id': section_id, - 'title': section_title, - 'entities': self._format_skos_entities(instances, language) - }) - - return { - 'grouped_view': True, - 'sections': sections - } - - def _format_skos_entities(self, instances: List, language: Optional[str] = None) -> List[Dict]: + def _format_entities(self, instances: List, language: Optional[str] = None) -> List[Dict]: """Format the SKOS entities in LODE style, sorted by label (or IRI when there is none).""" entities = [] From 8d20be5608831d4368de5c632c19bef9dca62e5b Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 16:33:47 +0200 Subject: [PATCH 09/21] fix(owlViewer): moved parse restrictions in specifically in owl viewer --- lode/viewer/base_viewer.py | 180 +++--------------------------------- lode/viewer/owl_viewer.py | 181 ++++++++++++++++++++++++++++++++++++- 2 files changed, 193 insertions(+), 168 deletions(-) diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 6ea2112..795fa70 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -505,6 +505,14 @@ def _resolve_chain_value(self, chain, language=None) -> dict: 'type': 'PropertyChain', } + def _resolve_special(self, obj, handler_dic: dict, language=None) -> Optional[dict]: + """Resolve a value only one format knows how to render, or return None. + + The base knows none: a format viewer overrides this to intercept its own + constructs (OWL class expressions) before the generic resolution runs. + """ + return None + def _resolve_resource_value(self, obj, language=None) -> dict: """Helper: Extracts text and link from any object.""" handler_dic = { @@ -520,27 +528,12 @@ def _resolve_resource_value(self, obj, language=None) -> dict: if not obj: return handler_dic - # --- 1. INTERCEPT RESTRICTIONS --- - restriction_types = ["Restriction", "PropertyConceptRestriction", "Quantifier", "Cardinality", "TruthFunction", - "OneOf", "Value", "DatatypeRestriction"] - obj_type = type(obj).__name__ + # --- 1. FORMAT-SPECIFIC VALUES (e.g. OWL restrictions) --- + special = self._resolve_special(obj, handler_dic, language) + if special is not None: + return special - if obj_type in restriction_types: - is_dep = getattr(obj, 'get_is_deprecated')() if hasattr(obj, 'get_is_deprecated') else getattr(obj, - 'is_deprecated', - False) - handler_dic['is_deprecated'] = bool(is_dep) - - # Recursively parse the restriction into clickable parts - parts = self._parse_restriction(obj, language) - - handler_dic['parts'] = parts - handler_dic['text'] = "".join([p['text'] for p in parts if p.get('text')]) - handler_dic['link'] = None # Forces Jinja to ignore the blank node URI - handler_dic['is_external'] = False - handler_dic['is_imported'] = False - handler_dic['type'] = obj_type - return handler_dic + obj_type = type(obj).__name__ # --- 2. String Handling --- if isinstance(obj, str): @@ -671,153 +664,6 @@ def _format_statement(self, instances, target_instance, language=None) -> Dict: return dict(sorted(statements.items(), key=lambda kv: kv[0].lower())) - def _parse_restriction(self, obj, language=None, _seen=None) -> list: - """ - Recursively unpacks nested restrictions into a list of display parts - (each part being a dict with 'text' and 'link'). - Guards against cyclic class expressions (possible once owl:imports - completes cross-ontology definitions) via a visited set. - """ - if not obj: return [] - - if _seen is None: - _seen = set() - - # 1. Handle lists of restrictions/concepts (e.g., in TruthFunctions or OneOf) - if isinstance(obj, list) or isinstance(obj, set): - parts = [] - for i, item in enumerate(obj): - if i > 0: - parts.append({'text': ', ', 'link': None}) - parts.extend(self._parse_restriction(item, language, _seen)) - return parts - - obj_type = type(obj).__name__ - restriction_types = ["Restriction", "PropertyConceptRestriction", "Quantifier", "Cardinality", "TruthFunction", - "OneOf", "Value", "DatatypeRestriction"] - - # 2. If it is a Restriction, recursively unpack its specific components - if obj_type in restriction_types: - # cycle guard: a class expression already being expanded upstream - if id(obj) in _seen: - return [{'text': '…', 'link': None}] - _seen.add(id(obj)) - - parts = [] - - # Helper to safely call getter methods (e.g., get_applies_on_property) - def _get(instance, prop_name, default=None): - """Read a property through its getter, or directly as an attribute, with a default for None.""" - getter = f"get_{prop_name}" - if hasattr(instance, getter): - res = getattr(instance, getter)() - return res if res is not None else default - return getattr(instance, prop_name, default) - - if obj_type == "Quantifier": - prop = _get(obj, 'applies_on_property') - quant = _get(obj, 'has_quantifier_type', "some") - concept = _get(obj, 'applies_on_concept') - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(prop, language, _seen)) - parts.append({'text': f' {quant} ', 'link': None}) - parts.extend(self._parse_restriction(concept, language, _seen)) - - elif obj_type == "Cardinality": - prop = _get(obj, 'applies_on_property') - card = _get(obj, 'has_cardinality_type', "exactly") - card_num = _get(obj, 'has_cardinality') - concept = _get(obj, 'applies_on_concept') - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(prop, language, _seen)) - parts.append({'text': f' {card} {card_num}', 'link': None}) - parts.extend(self._parse_restriction(concept, language, _seen)) - - elif obj_type == "TruthFunction": - operator = _get(obj, 'has_logical_operator', "and") - concepts = _get(obj, 'applies_on_concept', []) - if not isinstance(concepts, list): concepts = [concepts] - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.append({'text': '(', 'link': None}) - for i, c in enumerate(concepts): - if i > 0: - parts.append({'text': f' {operator} ', 'link': None}) - parts.extend(self._parse_restriction(c, language, _seen)) - parts.append({'text': ')', 'link': None}) - - elif obj_type == "OneOf": - resources = _get(obj, 'applies_on_resource', []) - if not isinstance(resources, list): resources = [resources] - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.append({'text': 'one of { ', 'link': None}) - for i, r in enumerate(resources): - if i > 0: - parts.append({'text': ', ', 'link': None}) - parts.extend(self._parse_restriction(r, language, _seen)) - parts.append({'text': ' }', 'link': None}) - - elif obj_type == "Value": - prop = _get(obj, 'applies_on_property') - resource = _get(obj, 'applies_on_resource') - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(prop, language, _seen)) - parts.append({'text': ' value ', 'link': None}) - parts.extend(self._parse_restriction(resource, language, _seen)) - - elif obj_type == "DatatypeRestriction": - concept = _get(obj, 'applies_on_concept') # Datatype(xsd:string) - constraint = _get(obj, 'has_constraint') # "pattern" - value = _get(obj, 'has_restriction_value') # Literal("[0-9]+") - - # Safely check if the is_inverse flag is True - is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) - if is_inv: - parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(concept, language, _seen)) - if constraint: - parts.append({'text': f' with {constraint} ', 'link': None, 'type': 'Text'}) - else: - parts.append({'text': ' restricted by ', 'link': None, 'type': 'Text'}) - - parts.extend(self._parse_restriction(value, language, _seen)) - - return parts - - # 3. Base Case: We hit a non-blank node (Concept, Relation, String) - # Send it to the main resolver to extract its URI link and clean text. - resolved = self._resolve_resource_value(obj, language) - - if resolved.get('text'): - return [{'text': resolved['text'], 'link': resolved.get('link'), 'type': resolved.get('type'), 'is_external': resolved.get('is_external', False)}] - - return [] - _FULL_IRI_ATTRS = {'is_defined_by'} def _build_hierarchy(self, instances, parent_getter_name, language=None): diff --git a/lode/viewer/owl_viewer.py b/lode/viewer/owl_viewer.py index 135d543..91aa2a4 100644 --- a/lode/viewer/owl_viewer.py +++ b/lode/viewer/owl_viewer.py @@ -1,8 +1,187 @@ """Viewer for OWL artefacts.""" +from typing import Optional + from lode.viewer.base_viewer import BaseViewer class OwlViewer(BaseViewer): - """OWL viewer: display names, table of contents and card come from config/owl_viewer.yaml.""" + """OWL viewer: display names, table of contents and card come from config/owl_viewer.yaml. + + It adds the rendering of class expressions (restrictions, cardinalities, + boolean combinations), which only OWL has. + """ CONFIG_NAME = "owl" + + # Model classes that are anonymous class expressions, shown inline as clickable parts. + _RESTRICTION_TYPES = ["Restriction", "PropertyConceptRestriction", "Quantifier", "Cardinality", "TruthFunction", + "OneOf", "Value", "DatatypeRestriction"] + + def _resolve_special(self, obj, handler_dic: dict, language=None) -> Optional[dict]: + """Render a class expression as clickable parts; None for any other value. + + The blank node of the expression is never linked: its parts carry the links. + """ + obj_type = type(obj).__name__ + if obj_type not in self._RESTRICTION_TYPES: + return None + + is_dep = getattr(obj, 'get_is_deprecated')() if hasattr(obj, 'get_is_deprecated') else getattr( + obj, 'is_deprecated', False) + handler_dic['is_deprecated'] = bool(is_dep) + + # Recursively parse the restriction into clickable parts + parts = self._parse_restriction(obj, language) + + handler_dic['parts'] = parts + handler_dic['text'] = "".join([p['text'] for p in parts if p.get('text')]) + handler_dic['link'] = None # Forces Jinja to ignore the blank node URI + handler_dic['is_external'] = False + handler_dic['is_imported'] = False + handler_dic['type'] = obj_type + return handler_dic + + def _parse_restriction(self, obj, language=None, _seen=None) -> list: + """ + Recursively unpacks nested restrictions into a list of display parts + (each part being a dict with 'text' and 'link'). + Guards against cyclic class expressions (possible once owl:imports + completes cross-ontology definitions) via a visited set. + """ + if not obj: return [] + + if _seen is None: + _seen = set() + + # 1. Handle lists of restrictions/concepts (e.g., in TruthFunctions or OneOf) + if isinstance(obj, list) or isinstance(obj, set): + parts = [] + for i, item in enumerate(obj): + if i > 0: + parts.append({'text': ', ', 'link': None}) + parts.extend(self._parse_restriction(item, language, _seen)) + return parts + + obj_type = type(obj).__name__ + + # 2. If it is a Restriction, recursively unpack its specific components + if obj_type in self._RESTRICTION_TYPES: + # cycle guard: a class expression already being expanded upstream + if id(obj) in _seen: + return [{'text': '…', 'link': None}] + _seen.add(id(obj)) + + parts = [] + + # Helper to safely call getter methods (e.g., get_applies_on_property) + def _get(instance, prop_name, default=None): + """Read a property through its getter, or directly as an attribute, with a default for None.""" + getter = f"get_{prop_name}" + if hasattr(instance, getter): + res = getattr(instance, getter)() + return res if res is not None else default + return getattr(instance, prop_name, default) + + if obj_type == "Quantifier": + prop = _get(obj, 'applies_on_property') + quant = _get(obj, 'has_quantifier_type', "some") + concept = _get(obj, 'applies_on_concept') + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(prop, language, _seen)) + parts.append({'text': f' {quant} ', 'link': None}) + parts.extend(self._parse_restriction(concept, language, _seen)) + + elif obj_type == "Cardinality": + prop = _get(obj, 'applies_on_property') + card = _get(obj, 'has_cardinality_type', "exactly") + card_num = _get(obj, 'has_cardinality') + concept = _get(obj, 'applies_on_concept') + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(prop, language, _seen)) + parts.append({'text': f' {card} {card_num}', 'link': None}) + parts.extend(self._parse_restriction(concept, language, _seen)) + + elif obj_type == "TruthFunction": + operator = _get(obj, 'has_logical_operator', "and") + concepts = _get(obj, 'applies_on_concept', []) + if not isinstance(concepts, list): concepts = [concepts] + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.append({'text': '(', 'link': None}) + for i, c in enumerate(concepts): + if i > 0: + parts.append({'text': f' {operator} ', 'link': None}) + parts.extend(self._parse_restriction(c, language, _seen)) + parts.append({'text': ')', 'link': None}) + + elif obj_type == "OneOf": + resources = _get(obj, 'applies_on_resource', []) + if not isinstance(resources, list): resources = [resources] + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.append({'text': 'one of { ', 'link': None}) + for i, r in enumerate(resources): + if i > 0: + parts.append({'text': ', ', 'link': None}) + parts.extend(self._parse_restriction(r, language, _seen)) + parts.append({'text': ' }', 'link': None}) + + elif obj_type == "Value": + prop = _get(obj, 'applies_on_property') + resource = _get(obj, 'applies_on_resource') + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(prop, language, _seen)) + parts.append({'text': ' value ', 'link': None}) + parts.extend(self._parse_restriction(resource, language, _seen)) + + elif obj_type == "DatatypeRestriction": + concept = _get(obj, 'applies_on_concept') # Datatype(xsd:string) + constraint = _get(obj, 'has_constraint') # "pattern" + value = _get(obj, 'has_restriction_value') # Literal("[0-9]+") + + # Safely check if the is_inverse flag is True + is_inv = obj.get_is_inverse() if hasattr(obj, 'get_is_inverse') else getattr(obj, 'is_inverse', False) + if is_inv: + parts.append({'text': 'inverse of ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(concept, language, _seen)) + if constraint: + parts.append({'text': f' with {constraint} ', 'link': None, 'type': 'Text'}) + else: + parts.append({'text': ' restricted by ', 'link': None, 'type': 'Text'}) + + parts.extend(self._parse_restriction(value, language, _seen)) + + return parts + + # 3. Base Case: We hit a non-blank node (Concept, Relation, String) + # Send it to the main resolver to extract its URI link and clean text. + resolved = self._resolve_resource_value(obj, language) + + if resolved.get('text'): + return [{'text': resolved['text'], 'link': resolved.get('link'), 'type': resolved.get('type'), 'is_external': resolved.get('is_external', False)}] + + return [] From a00a18aef88564d05bc548454751bc5f7d13ba6f Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 17:49:46 +0200 Subject: [PATCH 10/21] fix(warnings): show reader warnings as toasts in serve, report.txt and console output in build. --- lode/api.py | 2 +- lode/builder.py | 15 ++++++++- lode/cli.py | 14 ++++++++- lode/reader/logic/base_logic.py | 19 ------------ lode/reader/reader.py | 6 ++-- lode/templates/viewer.html | 43 +++++++++++++++++++------- lode/viewer/base_viewer.py | 17 +++++++++++ lode/warnings.json | 22 -------------- tests/test_packaging.py | 1 - tests/test_static_builder.py | 54 ++++++++++++++++++++++++++++++++- 10 files changed, 133 insertions(+), 60 deletions(-) delete mode 100644 lode/warnings.json diff --git a/lode/api.py b/lode/api.py index abf38ea..dbb58aa 100644 --- a/lode/api.py +++ b/lode/api.py @@ -168,7 +168,7 @@ def _render_view(request, reader, *, resource, lang, source_url, upload_id, read """ viewer = reader.get_viewer() data = viewer.get_view_data(resource_uri=resource, language=lang) - data["warnings"] = reader.get_warnings() + data["warnings"] = viewer.format_warnings(reader.get_warnings()) resp = templates.TemplateResponse(request, "viewer.html", { "source_url": source_url, "upload_id": upload_id, diff --git a/lode/builder.py b/lode/builder.py index 2975fdb..3e6f12a 100644 --- a/lode/builder.py +++ b/lode/builder.py @@ -105,6 +105,17 @@ def _copy_static(out_dir: Path) -> None: if static_src.exists() and not static_dst.exists(): shutil.copytree(static_src, static_dst) +def _write_warnings_report(warnings: list, out_dir: Path) -> None: + """Write the reader warnings to report.txt in the output folder. + + Written only when there is something to report, so a build without warnings + leaves no file. An older report.txt is not removed: it may be the user's own. + """ + if not warnings: + return + lines = [f"[{w['code']}] {w['message']}" for w in warnings] + (out_dir / "report.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + def build_html(viewer, out_dir: Path, lang: str = "en", reader=None, artefact_name: str = "index") -> None: """Write the whole static site for a viewer into `out_dir`. @@ -122,7 +133,9 @@ def build_html(viewer, out_dir: Path, lang: str = "en", reader=None, artefact_name = _check_artefact_name(artefact_name) out_dir.mkdir(parents=True, exist_ok=True) _copy_static(out_dir) - + if reader is not None: + _write_warnings_report(viewer.format_warnings(reader.get_warnings()), out_dir) + ontology_ns = viewer._get_ontology_ns() graph = reader._graph if reader else viewer.reader._graph main_graph = viewer.reader.get_main_graph() diff --git a/lode/cli.py b/lode/cli.py index c8ad198..c21e2fe 100644 --- a/lode/cli.py +++ b/lode/cli.py @@ -46,9 +46,19 @@ def cmd_build(args): imported=args.imported or None, closure=args.closure or None, partial_import=args.partial_import or None, + warnings=args.warnings, ) - build_html(reader.get_viewer(), out_dir, lang=args.lang, reader=reader, + viewer = reader.get_viewer() + build_html(viewer, out_dir, lang=args.lang, reader=reader, artefact_name=args.artefact_name) + if args.warnings: + warnings = viewer.format_warnings(reader.get_warnings()) + for w in warnings: + print(f"[{w['code']}] {w['message']}") + if warnings: + print(f"Warnings report -> {out_dir / 'report.txt'}") + else: + print("No reader warnings") print(f"Done -> {out_dir}") @@ -78,6 +88,8 @@ def main(): p_build.add_argument("--imported", action="store_true") p_build.add_argument("--closure", action="store_true") p_build.add_argument("--partial-import", action="store_true") + p_build.add_argument("--warnings", action="store_true", + help="Print reader warnings and write them to report.txt in the output folder") args = parser.parse_args() {"serve": cmd_serve, "build": cmd_build}[args.cmd](args) diff --git a/lode/reader/logic/base_logic.py b/lode/reader/logic/base_logic.py index a718670..1411e26 100644 --- a/lode/reader/logic/base_logic.py +++ b/lode/reader/logic/base_logic.py @@ -46,25 +46,6 @@ def add_warning(self, code, subject, message): """Record a reader warning with a machine-readable code, its subject and a message.""" self._warnings.append({'code': code, 'subject': str(subject), 'message': message}) - def get_warnings(self): - """Return the collected warnings, or [] when they were not requested.""" - if not getattr(self, '_warnings_enabled', False): # self is the logic here - return [] - - def save_warnings(self, filepath: str = None): - """Save warnings to JSON file.""" - import json - from pathlib import Path - - if not self._warnings: - return - - if filepath is None: - filepath = Path(__file__).parent.parent.parent / 'warnings.json' - - with open(filepath, 'w', encoding='utf-8') as f: - json.dump(self._warnings, f, indent=2, ensure_ascii=False) - def _get_allowed_classes(self) -> set: """Resolve the 'allowed_classes' names of the config into the model classes.""" class_names = self._strategy.config.get('allowed_classes', []) diff --git a/lode/reader/reader.py b/lode/reader/reader.py index 4d5a7e4..3679fad 100644 --- a/lode/reader/reader.py +++ b/lode/reader/reader.py @@ -270,10 +270,10 @@ def _extract_instances(self): if self._logic._warnings: logger.warning("%d reader warnings", len(self._logic._warnings)) + # details only at DEBUG: the viewer words them with display names + # for popups, report and CLI, so raw model names would be confusing for w in self._logic._warnings: - logger.warning("[%s] %s", w['code'], w['message']) - - self._logic.save_warnings() + logger.debug("[%s] %s", w['code'], w['message']) def _phase5_fallback(self): """Fallback per risorse non categorizzate (comune)""" diff --git a/lode/templates/viewer.html b/lode/templates/viewer.html index 9acd7aa..f3120b6 100644 --- a/lode/templates/viewer.html +++ b/lode/templates/viewer.html @@ -71,17 +71,6 @@ {% endif %}
- {% if warnings %} -
- {% for w in warnings %} - - {% endfor %} -
- {% endif %} - {% if metadata %} {% if single_resource %} {% set current = entities[0] if entities else None %} @@ -300,6 +289,37 @@

+ {% if warnings %} +
+ + {% endif %} +

+
Powered by LODE
\ No newline at end of file diff --git a/lode/viewer/base_viewer.py b/lode/viewer/base_viewer.py index 795fa70..18f1fbb 100644 --- a/lode/viewer/base_viewer.py +++ b/lode/viewer/base_viewer.py @@ -4,6 +4,7 @@ and display names. It also serializes the provenance of each entity. """ import hashlib +import re from typing import Dict, List, Optional, Tuple from lode.models import Literal, Model, Resource, Statement @@ -52,6 +53,22 @@ def get_toc_config(self) -> List[Tuple[str, str, str]]: """Return the table of contents: (class name, section id, type_map key) in display order.""" return [tuple(entry) for entry in self._config["toc"]] + def format_warnings(self, warnings: List[Dict]) -> List[Dict]: + """Return the reader warnings with model class names replaced by this viewer's display names. + + The reader words its messages with internal class names (Attribute, Annotation) and + cannot know how a format shows them; the display names live in the type_map of this + viewer, so the swap happens here, before the warnings reach any output. Only standalone + words are replaced: a class name inside an IRI (.../Concept, ...#Concept) is left alone. + Returns copies, the reader's own list is never modified. + """ + names = {key.capitalize(): entry["singular"] for key, entry in self._config["type_map"].items()} + if not names: + return [dict(w) for w in warnings] + pattern = re.compile(r"(? str: """Turn an attribute or getter name into a display label, using this viewer's vocabulary.""" return clean_name(name, self._config["vocabulary"]) diff --git a/lode/warnings.json b/lode/warnings.json deleted file mode 100644 index e8a6926..0000000 --- a/lode/warnings.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/creator", - "message": "Β§5.8.1: http://purl.org/dc/terms/creator has multiple property types: ['Annotation', 'Relation']" - }, - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/issued", - "message": "Β§5.8.1: http://purl.org/dc/terms/issued has multiple property types: ['Annotation', 'Attribute']" - }, - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/modified", - "message": "Β§5.8.1: http://purl.org/dc/terms/modified has multiple property types: ['Annotation', 'Attribute']" - }, - { - "code": "property_type_conflict", - "subject": "http://purl.org/dc/terms/language", - "message": "Β§5.8.1: http://purl.org/dc/terms/language has multiple property types: ['Annotation', 'Relation']" - } -] \ No newline at end of file diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a985522..bd3efb9 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -15,7 +15,6 @@ def test_package_data_present(): assert (files / "templates" / "viewer.html").is_file() assert (files / "static" / "style.css").is_file() assert (files / "reader" / "config" / "owl.yaml").is_file() - assert (files / "warnings.json").is_file() # --- 2. i template si risolvono a prescindere dalla cwd (prende il path cwd-relative) --- diff --git a/tests/test_static_builder.py b/tests/test_static_builder.py index d14e74f..b87509f 100644 --- a/tests/test_static_builder.py +++ b/tests/test_static_builder.py @@ -157,4 +157,56 @@ def test_internal_links_resolve(built): target = (html.parent / href).resolve() if not target.exists(): broken.append(f"{html.name} -> {href}") - assert not broken, "broken internal links:\n" + "\n".join(broken) \ No newline at end of file + assert not broken, "broken internal links:\n" + "\n".join(broken) + + +# --- warnings report --- + +_WARN_TTL = """ +@prefix owl: . +@prefix rdfs: . +@prefix : . + a owl:Ontology . +:A a owl:Class ; rdfs:subClassOf [ a owl:Restriction ; owl:onProperty :p ; + owl:someValuesFrom :B ; owl:allValuesFrom :B ] . +""" + + +def _build_warn_ttl(tmp_path, warnings): + """Load the malformed-restriction ontology and build it into tmp_path/out.""" + src = tmp_path / "w.ttl" + src.write_text(_WARN_TTL, encoding="utf-8") + reader = Reader() + reader.load_instances(str(src), "owl", warnings=warnings) + out = tmp_path / "out" + build_html(reader.get_viewer(), out, reader=reader) + return out + + +def test_report_written_with_warnings(tmp_path): + """The report lists the warning code.""" + out = _build_warn_ttl(tmp_path, warnings=True) + assert "malformed_restriction" in (out / "report.txt").read_text(encoding="utf-8") + + +def test_format_warnings_uses_display_names(tmp_path): + """Viewer swaps model class names for display names, but leaves IRIs alone.""" + src = tmp_path / "h.ttl" + src.write_text(""" +@prefix owl: . +@prefix rdfs: . +@prefix : . + a owl:Ontology . +:p a owl:ObjectProperty ; rdfs:subPropertyOf :q . +:q a owl:DatatypeProperty . +""", encoding="utf-8") + reader = Reader() + reader.load_instances(str(src), "owl", warnings=True) + msg = reader.get_viewer().format_warnings(reader.get_warnings())[0]["message"] + assert "Object Property http://ex.org/Attribute/p" in msg + assert "Data Property http://ex.org/Attribute/q" in msg + + +def test_no_report_without_warnings_flag(tmp_path): + """No report.txt when warnings were not requested.""" + assert not (_build_warn_ttl(tmp_path, warnings=False) / "report.txt").exists() \ No newline at end of file From 69757f676f5cca11f8f128d3113af6f3e8e5481e Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 18:04:29 +0200 Subject: [PATCH 11/21] fix(ui): responsive index form, wrap long IRIs, add shared footer --- lode/static/style.css | 54 ++++++++++++++++++++++++++++++++++++++ lode/templates/index.html | 21 ++++++++++----- lode/templates/viewer.html | 13 ++++++--- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/lode/static/style.css b/lode/static/style.css index 55996f3..ff2bd10 100644 --- a/lode/static/style.css +++ b/lode/static/style.css @@ -1031,3 +1031,57 @@ body.toc-sidebar-closed { margin-left: 5rem; } scroll-margin-top: 1rem; } + +/* ========================================================================== + 14. RESPONSIVE + Long IRIs must wrap instead of widening the page; the index form must + stay usable on narrow phones. + ========================================================================== */ + +/* "anywhere" breaks only when a word would overflow, unlike break-all */ +.container-fluid a, +.container-fluid code, +.container-fluid dd, +.container-fluid .card-body { + overflow-wrap: anywhere; +} + +/* Header IRI: wrap on as many lines as needed instead of being cut */ +.resource-meta-uri { + min-width: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 767.98px) { + .submission-form-container { padding: 1.5rem 1rem; } + .submission-form-container img { + display: block; + max-width: 10rem; + margin: 0 auto 1rem; + } + .resource-meta-uri { max-width: 100%; } +} + +@media (max-width: 575.98px) { + /* full-width toggle: the 50% slider keeps working */ + .toggle-switch { display: flex; width: 100%; } + .toggle-option { flex: 1 1 0; padding: 8px 12px; text-align: center; } +} + + +/* ========================================================================== + 15. FOOTER + Shared by the index page and the viewer (live and static site). + ========================================================================== */ + +.site-footer { + margin-top: 3rem; + padding: 1rem; + border-top: 1px solid var(--lode-blue-hairline); + text-align: center; + font-size: .85rem; + color: var(--lode-muted); +} + +.site-footer a { color: var(--lode-blue); } + diff --git a/lode/templates/index.html b/lode/templates/index.html index 3140436..2daf65e 100644 --- a/lode/templates/index.html +++ b/lode/templates/index.html @@ -25,9 +25,9 @@
-
+
-
+
@@ -57,7 +57,7 @@
-
+
@@ -155,7 +155,7 @@
-
+
@@ -169,7 +169,7 @@
-
+

LODE 2.0 is an open source service that automatically extracts entities from semantic artefacts (OWL, RDF/RDFS, SKOS) and renders them, together with their textual definitions, as a human-readable HTML page designed for browsing and @@ -203,6 +203,7 @@

Endpoints

Parameters

+
@@ -279,6 +280,7 @@

Parameters

+

Examples

@@ -340,7 +342,14 @@

Reporting bugs

LODE 2.0 is developed in the open. Open an issue at github.com/opencitations/lode/issues.

-
+
+ +
-
Powered by LODE
+ \ No newline at end of file From 6c4a679272e377919b074da841b6a0805d817a1d Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 18:11:14 +0200 Subject: [PATCH 12/21] chore(packaging): add keywords and Documentation/Issues URLs --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a706a01..94a6425 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [{name = "Valentina Pasqual, Silvio Peroni", email = "valentina.pasqua readme = "README.md" license = {text = "ISC"} requires-python = ">=3.10" -keywords = [] +keywords = ["owl", "ontology", "rdf", "skos", "documentation", "semantic-web", "opencitations"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -39,6 +39,8 @@ lode = "lode.cli:main" [project.urls] Homepage = "https://github.com/opencitations/lode" Repository = "https://github.com/opencitations/lode" +Documentation = "https://opencitations.github.io/lode/" +Issues = "https://github.com/opencitations/lode/issues" [dependency-groups] dev = [ From 930b0290e410a4f3fc83364f9ef510b8c627af2e Mon Sep 17 00:00:00 2001 From: Valentina Date: Fri, 16 Oct 2026 18:27:18 +0200 Subject: [PATCH 13/21] docs: explain base install vs [server] extra --- docs/api/endpoints.md | 5 ++++- docs/getting_started.md | 19 +++++++++++++++++++ docs/usage/cli.md | 3 +-- docs/usage/web_form.md | 5 +++-- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 44baec1..567acdc 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -1,5 +1,8 @@ # API endpoints +The API is part of the web service and needs the `[server]` extra +(`pip install "oc-lode[server]"`, see [Getting started](../getting_started.md#which-install-do-i-need)). + Interactive docs available at `/docs` (Swagger) and `/redoc` (ReDoc) when the server is running. ## GET / @@ -16,7 +19,7 @@ Render an artefact as HTML or RDF. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `read_as` | enum | yes | `owl` \| `rdf` \| `skos` | +| `read_as` | enum | yes | `owl` \| `rdf` \| `skos` - Currently only `owl` is available| | `url` | string | one of | Public http(s) URL | | `upload_id` | string | one of | Token from a previous upload | | `resource` | string | no | IRI of a single resource | diff --git a/docs/getting_started.md b/docs/getting_started.md index 8f38258..be76823 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -14,6 +14,25 @@ pip install oc-lode pip install "oc-lode[server]" ``` +### Which install do I need? + +The base install is enough for the CLI and for using LODE as a library: `lode build` +and the `lode.reader` / `lode.viewer` modules. It installs `rdflib`, `jinja2`, +`requests`, `minify-html` and `pyyaml`. + +The web service and the REST API (`lode serve`, `lode.api`) need the `[server]` extra, +which adds `fastapi`, `uvicorn`, `gunicorn` and `python-multipart`. + +| You want to... | Install | +|----------------|---------| +| Build static docs with `lode build` | `pip install oc-lode` | +| Run `lode serve` or import `lode.api` | `pip install "oc-lode[server]"` | + +!!! warning + Without the extra, `lode serve` exits with + `Server dependencies missing: pip install oc-lode[server]`, and + `import lode.api` fails with `ModuleNotFoundError: No module named 'fastapi'`. + ## First run Start the web server (requires the `[server]` extra): diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7efddd6..1d4c251 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -26,14 +26,13 @@ lode build (--url URL | --file FILE) --read-as FORMAT [OPTIONS] |--------|----------|-------------| | `--url` | one of | Public http(s) URL of the artefact | | `--file` | one of | Local file path | -| `--read-as` | yes | Artefact type: `owl`, `rdf`, `skos` | +| `--read-as` | yes | Artefact type: `owl`, `rdf`, `skos` - Currently only `owl` is available | | `--out` | no | Output directory (default `./lode_output`) | | `--lang` | no | Language tag for labels (default `en`) | | `--artefact-name` | no | File name (without extension) of the root page and its serializations (if omitted, falls back to `index`, so the site starts at `index.html`). Only letters, digits, `.`, `_` and `-` are allowed | | `--imported` | no | Load `owl:imports` at depth 1 | | `--closure` | no | Load the full `owl:imports` closure | | `--partial-import` | no | Load only the imported entities that are actually used | - ## Examples ```bash diff --git a/docs/usage/web_form.md b/docs/usage/web_form.md index 902270c..3f2666c 100644 --- a/docs/usage/web_form.md +++ b/docs/usage/web_form.md @@ -1,6 +1,7 @@ # Web form -Start the server and open `http://localhost:8000`. +Start the server and open `http://localhost:8000`. This needs the `[server]` extra: +`pip install "oc-lode[server]"` (see [Getting started](../getting_started.md#which-install-do-i-need)). ## Input @@ -8,7 +9,7 @@ Start the server and open `http://localhost:8000`. |-------|-------------| | **URL** | Public http(s) URL of the artefact | | **File** | Upload a local `.ttl`, `.rdf`, `.owl`, `.n3`, `.jsonld` file | -| **Format** | Artefact type (`owl`, `rdf`, `skos`) | +| **Format** | Artefact type (`owl`, `rdf`, `skos`) - Currently only `owl` is available | | **Language** | Preferred language for labels (e.g. `en`, `it`) | | **Load imports** | Also fetch `owl:imports` at depth 1 | | **Load closure** | Fetch the full `owl:imports` closure | From dce5eadf938a6c4cc75af73b133ecf2cf126da3e Mon Sep 17 00:00:00 2001 From: Valentina Date: Mon, 19 Oct 2026 12:08:03 +0200 Subject: [PATCH 14/21] chore: align package version with release tag --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 94a6425..1c4b169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oc-lode" -version = "0.3.15" +version = "2.1.0" description = "New reengineered version of LODE, maintained by OpenCitations" authors = [{name = "Valentina Pasqual, Silvio Peroni", email = "valentina.pasqual2@unibo.it"}] readme = "README.md" diff --git a/uv.lock b/uv.lock index 9a44003..2fa91d0 100644 --- a/uv.lock +++ b/uv.lock @@ -574,7 +574,7 @@ wheels = [ [[package]] name = "oc-lode" -version = "0.3.15" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "jinja2" }, From 871f97521ca4251ebc6b3453899fa4efcb639680 Mon Sep 17 00:00:00 2001 From: Valentina Date: Mon, 19 Oct 2026 13:34:40 +0200 Subject: [PATCH 15/21] chore: update documentation for pypi publication --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++------ pyproject.toml | 19 +++++++++++++------ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5d47bfb..0b60680 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ LODE 2.0 is an open source service that automatically extracts entities from semantic artefacts (OWL, RDF/RDFS, SKOS) and renders them, together with their textual definitions, as a human-readable HTML page designed for browsing and navigation through embedded links. This new reengineered version of LODE is maintained by OpenCitations. +**Try it online:** a hosted instance of the web service is available at https://lode.opencitations.net/, with no installation needed. + + ## Installation ```bash @@ -16,13 +19,13 @@ pip install "oc-lode[server]" ``` ## Usage - + ```bash # Build static documentation from an ontology URL or a local file lode build --url --read-as [--out ./docs] [--lang en] [--artefact-name index] [--imported] [--closure] lode build --file --read-as [--out ./docs] -# Run the web service (requires the [server] extra) +# Run the web service locally (requires the [server] extra) lode serve --port 8000 ``` @@ -77,14 +80,47 @@ Replace `latest` with the desired version tag. The app will be available at `localhost:8080`. - -