"""Structural validation for a generated deck. Default mode (``grammar=None``) only enforces SKILL-AGNOSTIC invariants: file exists, parses as HTML, ≥ 5 ``
`` blocks, self-contained (no external link/script/img references), reasonable size. A skill that wants stricter checks can declare its slide grammar in the SKILL.md frontmatter under ``od.deck_grammar`` and pass it as the ``grammar`` argument. The Editorial Monocle skill does this — its grammar names the 7 ``data-type`` values plus the cover/closing requirement; guizang / swiss / any community skill simply omit it and get the generic validation. Mirrors ``openkb/skill/validator.py``'s ``ValidationResult`` shape so callers can format issues identically regardless of artifact type. """ from __future__ import annotations from dataclasses import dataclass, field from html.parser import HTMLParser from pathlib import Path from typing import Optional, TypedDict __all__ = [ "ALLOWED_DATA_TYPES", "DeckGrammar", "EDITORIAL_MONOCLE_GRAMMAR", "ValidationResult", "validate_deck", ] class DeckGrammar(TypedDict, total=False): """Skill-declared rules for slide classification. All keys are optional. If a key is missing, the corresponding check is skipped. This is what a skill writer puts under ``frontmatter.od.deck_grammar`` to opt into structural validation. Example (Editorial Monocle skill):: od: mode: deck deck_grammar: kind_attr: data-type required: [cover, closing] allowed: [cover, chapter, thesis, quote, compare, data, closing] min_distinct: 4 max_consecutive_same: 2 """ kind_attr: str # attribute name carrying the slide kind (e.g. "data-type") required: list[str] # kinds that MUST appear at least once allowed: list[str] # whitelist; anything else is rejected min_distinct: int # warn if fewer distinct kinds present max_consecutive_same: int # warn if run-length exceeds this # Editorial Monocle's published grammar. Kept here for the openkb-deck-editorial # skill to import (and for tests / docs to reference); third-party skills may # define their own or omit grammar entirely. EDITORIAL_MONOCLE_GRAMMAR: DeckGrammar = { "kind_attr": "data-type", "required": ["cover", "closing"], "allowed": ["cover", "chapter", "thesis", "quote", "compare", "data", "closing"], "min_distinct": 4, "max_consecutive_same": 2, } # Legacy alias used by tests that pinned the old name. New code should # read this from EDITORIAL_MONOCLE_GRAMMAR["allowed"]. ALLOWED_DATA_TYPES: frozenset[str] = frozenset(EDITORIAL_MONOCLE_GRAMMAR["allowed"]) MAX_FILE_BYTES = 2 * 1024 * 1024 # 2 MB MIN_SLIDES_HARD = 5 # error threshold (skill-agnostic) MIN_SLIDES_SOFT = 8 # warning threshold (count outside [8,15]) MAX_SLIDES_SOFT = 15 @dataclass class ValidationResult: errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) @property def ok(self) -> bool: return not self.errors class _DeckParser(HTMLParser): """Collects ``
`` blocks and any external refs. The slide kind (e.g. ``data-type="cover"`` for Editorial Monocle) is extracted lazily — ``slide_kinds`` is keyed by the configured ``kind_attr``; an empty string means the slide didn't declare a kind under that attr. """ def __init__(self, kind_attr: Optional[str] = None) -> None: super().__init__() self.kind_attr = kind_attr self.slide_kinds: list[str] = [] self.external_links: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: a = dict(attrs) if tag == "section" and "slide" in (a.get("class") or "").split(): if self.kind_attr is None: # Skill-agnostic: just count slides; kind is irrelevant. self.slide_kinds.append("") else: self.slide_kinds.append((a.get(self.kind_attr) or "").strip()) elif tag == "link": href = (a.get("href") or "").strip() if href.startswith(("http://", "https://", "//")): self.external_links.append(f"") elif tag == "script": src = (a.get("src") or "").strip() if src.startswith(("http://", "https://", "//")): self.external_links.append(f"