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
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
+91 -25
View File
@@ -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
+4 -3
View File
@@ -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}'")