From 346e3d7e0c51e5905050c0dea87528d6146b9a21 Mon Sep 17 00:00:00 2001 From: omicron Date: Fri, 2 Oct 2026 00:28:42 +0200 Subject: [PATCH] Move frontmatter validation into ContentMeta --- omicron/ssg/output/article.py | 27 ++++---- omicron/ssg/output/content.py | 116 ++++++++++++++++++++++++++-------- omicron/ssg/output/factory.py | 7 +- 3 files changed, 106 insertions(+), 44 deletions(-) diff --git a/omicron/ssg/output/article.py b/omicron/ssg/output/article.py index d716c87..c4c8025 100644 --- a/omicron/ssg/output/article.py +++ b/omicron/ssg/output/article.py @@ -1,7 +1,7 @@ import logging from urllib.parse import urlparse -from typing import TYPE_CHECKING, Any -from omicron.ssg.output.content import Content +from typing import TYPE_CHECKING, TypeAlias +from omicron.ssg.output.content import Content, ContentMeta from omicron.ssg.output.file import File from pathlib import Path import omicron.ssg.markdown as md @@ -14,22 +14,17 @@ else: Site = "omicron.ssg.site.Site" # for runtime checking with beartype +class ArticleMeta(ContentMeta): + pass + + class Article(Content): - def __init__(self, site: Site, source: Path, meta: dict[str, Any]): - if "tags" not in meta: + Meta: TypeAlias = ArticleMeta + + def __init__(self, site: Site, source: Path, meta: ArticleMeta): + if not meta.tags: log.warning("no tags for article in '%s'", source) - super().__init__( - site, - source, - meta["section"], - meta["slug"], - meta["title"], - meta["date"], - meta.get("updated", None), - set(meta["tags"]) if "tags" in meta else None, - meta.get("summary", None), - meta.get("draft", False), - ) + super().__init__(site, source, meta) def handle_reference(self, path: Path) -> Content | File: site = self.site diff --git a/omicron/ssg/output/content.py b/omicron/ssg/output/content.py index 367001f..61451c2 100644 --- a/omicron/ssg/output/content.py +++ b/omicron/ssg/output/content.py @@ -1,9 +1,11 @@ import re -from typing import TYPE_CHECKING +from abc import ABC +from dataclasses import dataclass, field, fields +from typing import TYPE_CHECKING, Any, Self, TypeAlias from omicron.ssg.output.output import Output, OutputError, is_normalized from omicron.ssg.output.file import File from pathlib import Path -from datetime import date +import datetime if TYPE_CHECKING: from omicron.ssg.site import Site @@ -15,37 +17,101 @@ class ContentError(OutputError): pass +@dataclass(kw_only=True) +class ContentMeta(ABC): + section: str + slug: str + title: str + date: datetime.date + updated: datetime.date | None = None + tags: set[str] = field(default_factory=set) + summary: str | None = None + draft: bool = False + + @staticmethod + def validate_dict(data: dict[str, Any]) -> None: + section = data.get("section") + slug = data.get("slug") + title = data.get("title") + content_date = data.get("date") + + if section is None: + raise ContentError("frontmatter must contain a section property") + if slug is None: + raise ContentError("frontmatter must contain a slug property") + if title is None: + raise ContentError("frontmatter must contain a title property") + if content_date is None: + raise ContentError("frontmatter must contain a date property") + + if not isinstance(section, str): + raise ContentError("section frontmatter property must be a string") + if not isinstance(slug, str): + raise ContentError("slug frontmatter property must be a string") + if not isinstance(title, str): + raise ContentError("title frontmatter property must be a string") + if not isinstance(content_date, datetime.date): + raise ContentError("date frontmatter property must be a date") + + updated = data.get("updated") + if updated is not None and not isinstance(updated, datetime.date): + raise ContentError("updated frontmatter property must be a date") + + if "tags" in data and ( + not isinstance(data["tags"], (list, set)) + or not all(isinstance(tag, str) for tag in data["tags"]) + ): + raise ContentError("tags frontmatter property must be a list of strings") + + summary = data.get("summary") + if summary is not None and not isinstance(summary, str): + raise ContentError("summary frontmatter property must be a string") + + draft = data.get("draft") + if draft is not None and not isinstance(draft, bool): + raise ContentError("draft frontmatter property must be a boolean") + + @staticmethod + def coerce_dict(data: dict[str, Any]) -> None: + if "tags" in data and isinstance(data["tags"], list): + data["tags"] = set(data["tags"]) + + @classmethod + def reject_extra_fields(cls, data: dict[str, Any]) -> None: + field_names = {f.name for f in fields(cls)} + + unexpected = data.keys() - field_names + if unexpected: + raise ContentError( + f"unexpected frontmatter field(s): {', '.join(sorted(unexpected))}" + ) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + cls.validate_dict(data) + cls.coerce_dict(data) + cls.reject_extra_fields(data) + return cls(**data) + + class Content(Output): + Meta: TypeAlias = ContentMeta _SECTION_RE = re.compile(r"^[a-z0-9_-]+(/[a-z0-9_-]+)*$") - def __init__( - self, - site: Site, - source: Path, - section: str, - slug: str, - title: str, - created: date, - updated: date | None = None, - tags: set[str] | None = None, - summary: str | None = None, - draft: bool = False, - ): - super().__init__(site, Content.build_path(section, slug)) - if tags is None: - tags = set() + def __init__(self, site: Site, source: Path, meta: ContentMeta): + super().__init__(site, Content.build_path(meta.section, meta.slug)) if not source.is_absolute(): raise ContentError("content source path must be absolute") if not is_normalized(source): raise ContentError("content source path must be normalized") self.source = source - self.section: str = section - self.title: str = title - self.created = created - self.updated = updated - self.tags: set[str] = tags - self.summary: str | None = summary - self.draft: bool = draft + self.section = meta.section + self.title = meta.title + self.created = meta.date + self.updated = meta.updated + self.tags = meta.tags + self.summary = meta.summary + self.draft = meta.draft self.in_sitemap = True @staticmethod diff --git a/omicron/ssg/output/factory.py b/omicron/ssg/output/factory.py index 5bae048..0ad81dd 100644 --- a/omicron/ssg/output/factory.py +++ b/omicron/ssg/output/factory.py @@ -44,7 +44,8 @@ def create_content(site: Site, path: Path) -> Content: else: raise ContentError(f"Unhandled file type for '{path}'") - if meta["type"] == "article": - return Article(site, path, meta) + content_type = meta.pop("type", None) + if content_type == "article": + return Article(site, path, Article.Meta.from_dict(meta)) else: - raise ContentError(f"Invalid type '{meta['type']}' for '{path}'") + raise ContentError(f"Invalid type '{content_type}' for '{path}'")