Move frontmatter validation into ContentMeta
This commit is contained in:
1 parent
ab14def478
commit
346e3d7e0c
3 files changed
+106
-44
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'")
|
||||
Reference in new issue
Block a user