Move frontmatter validation into ContentMeta

This commit is contained in:
omicron committed 2026-10-02 00:28:42 +02:00
1 parent ab14def478
commit 346e3d7e0c
3 files changed
+106 -44

No files matched your search

+11 -16
View File
@@ -1,7 +1,7 @@
import logging import logging
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, TypeAlias
from omicron.ssg.output.content import Content from omicron.ssg.output.content import Content, ContentMeta
from omicron.ssg.output.file import File from omicron.ssg.output.file import File
from pathlib import Path from pathlib import Path
import omicron.ssg.markdown as md import omicron.ssg.markdown as md
@@ -14,22 +14,17 @@ else:
Site = "omicron.ssg.site.Site" # for runtime checking with beartype Site = "omicron.ssg.site.Site" # for runtime checking with beartype
class ArticleMeta(ContentMeta):
pass
class Article(Content): class Article(Content):
def __init__(self, site: Site, source: Path, meta: dict[str, Any]): Meta: TypeAlias = ArticleMeta
if "tags" not in meta:
def __init__(self, site: Site, source: Path, meta: ArticleMeta):
if not meta.tags:
log.warning("no tags for article in '%s'", source) log.warning("no tags for article in '%s'", source)
super().__init__( super().__init__(site, source, meta)
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),
)
def handle_reference(self, path: Path) -> Content | File: def handle_reference(self, path: Path) -> Content | File:
site = self.site site = self.site
+91 -25
View File
@@ -1,9 +1,11 @@
import re 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.output import Output, OutputError, is_normalized
from omicron.ssg.output.file import File from omicron.ssg.output.file import File
from pathlib import Path from pathlib import Path
from datetime import date import datetime
if TYPE_CHECKING: if TYPE_CHECKING:
from omicron.ssg.site import Site from omicron.ssg.site import Site
@@ -15,37 +17,101 @@ class ContentError(OutputError):
pass 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): class Content(Output):
Meta: TypeAlias = ContentMeta
_SECTION_RE = re.compile(r"^[a-z0-9_-]+(/[a-z0-9_-]+)*$") _SECTION_RE = re.compile(r"^[a-z0-9_-]+(/[a-z0-9_-]+)*$")
def __init__( def __init__(self, site: Site, source: Path, meta: ContentMeta):
self, super().__init__(site, Content.build_path(meta.section, meta.slug))
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()
if not source.is_absolute(): if not source.is_absolute():
raise ContentError("content source path must be absolute") raise ContentError("content source path must be absolute")
if not is_normalized(source): if not is_normalized(source):
raise ContentError("content source path must be normalized") raise ContentError("content source path must be normalized")
self.source = source self.source = source
self.section: str = section self.section = meta.section
self.title: str = title self.title = meta.title
self.created = created self.created = meta.date
self.updated = updated self.updated = meta.updated
self.tags: set[str] = tags self.tags = meta.tags
self.summary: str | None = summary self.summary = meta.summary
self.draft: bool = draft self.draft = meta.draft
self.in_sitemap = True self.in_sitemap = True
@staticmethod @staticmethod
+4 -3
View File
@@ -44,7 +44,8 @@ def create_content(site: Site, path: Path) -> Content:
else: else:
raise ContentError(f"Unhandled file type for '{path}'") raise ContentError(f"Unhandled file type for '{path}'")
if meta["type"] == "article": content_type = meta.pop("type", None)
return Article(site, path, meta) if content_type == "article":
return Article(site, path, Article.Meta.from_dict(meta))
else: else:
raise ContentError(f"Invalid type '{meta['type']}' for '{path}'") raise ContentError(f"Invalid type '{content_type}' for '{path}'")