sardine-cms-validation 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sardine_cms_validation-0.1.0/.gitignore +37 -0
- sardine_cms_validation-0.1.0/PKG-INFO +14 -0
- sardine_cms_validation-0.1.0/README.md +5 -0
- sardine_cms_validation-0.1.0/pyproject.toml +15 -0
- sardine_cms_validation-0.1.0/src/cms_validation/__init__.py +37 -0
- sardine_cms_validation-0.1.0/src/cms_validation/engine.py +89 -0
- sardine_cms_validation-0.1.0/src/cms_validation/py.typed +0 -0
- sardine_cms_validation-0.1.0/src/cms_validation/rules.py +162 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
.mypy_cache/
|
|
8
|
+
.ruff_cache/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
|
|
15
|
+
# Node (admin panel UI, if applicable)
|
|
16
|
+
node_modules/
|
|
17
|
+
*.tsbuildinfo
|
|
18
|
+
|
|
19
|
+
# Environment and secrets
|
|
20
|
+
.env
|
|
21
|
+
.env.*
|
|
22
|
+
!.env.example
|
|
23
|
+
|
|
24
|
+
# Local databases
|
|
25
|
+
*.sqlite3
|
|
26
|
+
*.db
|
|
27
|
+
|
|
28
|
+
# Static build output
|
|
29
|
+
_site/
|
|
30
|
+
out/
|
|
31
|
+
export/
|
|
32
|
+
|
|
33
|
+
# OS / editors
|
|
34
|
+
.DS_Store
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
!.vscode/settings.json
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sardine-cms-validation
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Configurable content validation rules for Sardine CMS
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: sardine-cms-core
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# cms-validation
|
|
11
|
+
|
|
12
|
+
Configurable validation rules for Sardine CMS: language parity,
|
|
13
|
+
structure, editorial rules. Runs before every publish. See the repository
|
|
14
|
+
root README and docs for the full architecture.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sardine-cms-validation"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Configurable content validation rules for Sardine CMS"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = ["sardine-cms-core"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/cms_validation"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Configurable content validation rules."""
|
|
2
|
+
|
|
3
|
+
from cms_validation.engine import (
|
|
4
|
+
Issue,
|
|
5
|
+
Report,
|
|
6
|
+
Rule,
|
|
7
|
+
RuleSet,
|
|
8
|
+
Severity,
|
|
9
|
+
SiteContent,
|
|
10
|
+
ValidationContext,
|
|
11
|
+
)
|
|
12
|
+
from cms_validation.rules import (
|
|
13
|
+
KnownCategoriesRule,
|
|
14
|
+
MediaAltCoverageRule,
|
|
15
|
+
MediaReferencesRule,
|
|
16
|
+
RequiredTranslationsRule,
|
|
17
|
+
UniqueSlugsRule,
|
|
18
|
+
default_ruleset,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Issue",
|
|
25
|
+
"KnownCategoriesRule",
|
|
26
|
+
"MediaAltCoverageRule",
|
|
27
|
+
"MediaReferencesRule",
|
|
28
|
+
"Report",
|
|
29
|
+
"RequiredTranslationsRule",
|
|
30
|
+
"Rule",
|
|
31
|
+
"RuleSet",
|
|
32
|
+
"Severity",
|
|
33
|
+
"SiteContent",
|
|
34
|
+
"UniqueSlugsRule",
|
|
35
|
+
"ValidationContext",
|
|
36
|
+
"default_ruleset",
|
|
37
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Validation engine: composable, configurable rules over site content.
|
|
2
|
+
|
|
3
|
+
A rule is any object satisfying the :class:`Rule` protocol; a
|
|
4
|
+
:class:`RuleSet` runs the enabled rules and aggregates issues. Publishing is
|
|
5
|
+
gated on the absence of ``ERROR`` issues.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from cms_core import Article, Language, MediaAsset, Page
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Severity(StrEnum):
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
WARNING = "warning"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Issue:
|
|
23
|
+
code: str
|
|
24
|
+
severity: Severity
|
|
25
|
+
message: str
|
|
26
|
+
subject: str
|
|
27
|
+
language: Language | None = None
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
scope = f" [{self.language.value}]" if self.language else ""
|
|
31
|
+
return f"{self.severity.value}: {self.code}: {self.subject}{scope}: {self.message}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SiteContent:
|
|
36
|
+
"""The full content set a validation or build run operates on."""
|
|
37
|
+
|
|
38
|
+
articles: Sequence[Article] = ()
|
|
39
|
+
pages: Sequence[Page] = ()
|
|
40
|
+
media: Sequence[MediaAsset] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ValidationContext:
|
|
45
|
+
"""Configuration a rule may consult; never read from globals."""
|
|
46
|
+
|
|
47
|
+
required_languages: tuple[Language, ...]
|
|
48
|
+
known_categories: tuple[str, ...] | None = None
|
|
49
|
+
"""None disables the check; a tuple restricts article categories to it."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class Rule(Protocol):
|
|
54
|
+
name: str
|
|
55
|
+
|
|
56
|
+
def check(self, content: SiteContent, context: ValidationContext) -> Iterable[Issue]: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class Report:
|
|
61
|
+
issues: tuple[Issue, ...]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def errors(self) -> tuple[Issue, ...]:
|
|
65
|
+
return tuple(issue for issue in self.issues if issue.severity is Severity.ERROR)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def warnings(self) -> tuple[Issue, ...]:
|
|
69
|
+
return tuple(issue for issue in self.issues if issue.severity is Severity.WARNING)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ok(self) -> bool:
|
|
73
|
+
return not self.errors
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class RuleSet:
|
|
78
|
+
rules: list[Rule] = field(default_factory=list)
|
|
79
|
+
disabled: set[str] = field(default_factory=set)
|
|
80
|
+
|
|
81
|
+
def enabled_rules(self) -> Iterator[Rule]:
|
|
82
|
+
return (rule for rule in self.rules if rule.name not in self.disabled)
|
|
83
|
+
|
|
84
|
+
def run(self, content: SiteContent, context: ValidationContext) -> Report:
|
|
85
|
+
issues: list[Issue] = []
|
|
86
|
+
for rule in self.enabled_rules():
|
|
87
|
+
issues.extend(rule.check(content, context))
|
|
88
|
+
issues.sort(key=lambda issue: (issue.subject, issue.code, issue.language or ""))
|
|
89
|
+
return Report(issues=tuple(issues))
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Core validation rules.
|
|
2
|
+
|
|
3
|
+
Each rule is small, pure and independently testable. Rules never read global
|
|
4
|
+
state — everything comes from the content set and the context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
|
|
9
|
+
from cms_core import Article, ContentStatus, Language, Page, TranslationState
|
|
10
|
+
|
|
11
|
+
from cms_validation.engine import Issue, Rule, Severity, SiteContent, ValidationContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _publishable(status: ContentStatus) -> bool:
|
|
15
|
+
return status in (ContentStatus.REVIEW, ContentStatus.PUBLISHED)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RequiredTranslationsRule:
|
|
19
|
+
"""Published/review content must be complete in every required language."""
|
|
20
|
+
|
|
21
|
+
name = "required-translations"
|
|
22
|
+
|
|
23
|
+
def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
|
|
24
|
+
entries: Iterable[tuple[str, Article | Page]] = [
|
|
25
|
+
*((f"article:{article.id}", article) for article in content.articles),
|
|
26
|
+
*((f"page:{page.id}", page) for page in content.pages),
|
|
27
|
+
]
|
|
28
|
+
for subject, entry in entries:
|
|
29
|
+
if not _publishable(entry.status):
|
|
30
|
+
continue
|
|
31
|
+
severity = (
|
|
32
|
+
Severity.ERROR if entry.status is ContentStatus.PUBLISHED else Severity.WARNING
|
|
33
|
+
)
|
|
34
|
+
for language in context.required_languages:
|
|
35
|
+
state = entry.translation_state(language)
|
|
36
|
+
if state is not TranslationState.COMPLETE:
|
|
37
|
+
yield Issue(
|
|
38
|
+
code=self.name,
|
|
39
|
+
severity=severity,
|
|
40
|
+
message=f"translation is {state.value}",
|
|
41
|
+
subject=subject,
|
|
42
|
+
language=language,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UniqueSlugsRule:
|
|
47
|
+
"""Within each language, generated URLs must not collide."""
|
|
48
|
+
|
|
49
|
+
name = "unique-slugs"
|
|
50
|
+
|
|
51
|
+
def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
|
|
52
|
+
for language in (Language.EN, *context.required_languages):
|
|
53
|
+
seen: dict[str, str] = {}
|
|
54
|
+
for article in content.articles:
|
|
55
|
+
slug = _article_slug(article, language)
|
|
56
|
+
subject = f"article:{article.id}"
|
|
57
|
+
if slug in seen:
|
|
58
|
+
yield self._collision(subject, seen[slug], slug, language)
|
|
59
|
+
else:
|
|
60
|
+
seen[slug] = subject
|
|
61
|
+
for page in content.pages:
|
|
62
|
+
slug = _page_slug(page, language)
|
|
63
|
+
subject = f"page:{page.id}"
|
|
64
|
+
if slug in seen:
|
|
65
|
+
yield self._collision(subject, seen[slug], slug, language)
|
|
66
|
+
else:
|
|
67
|
+
seen[slug] = subject
|
|
68
|
+
|
|
69
|
+
def _collision(self, subject: str, other: str, slug: str, language: Language) -> Issue:
|
|
70
|
+
return Issue(
|
|
71
|
+
code=self.name,
|
|
72
|
+
severity=Severity.ERROR,
|
|
73
|
+
message=f"slug {slug!r} collides with {other}",
|
|
74
|
+
subject=subject,
|
|
75
|
+
language=language,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MediaReferencesRule:
|
|
80
|
+
"""Sections may only reference media assets that exist."""
|
|
81
|
+
|
|
82
|
+
name = "media-references"
|
|
83
|
+
|
|
84
|
+
def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
|
|
85
|
+
known = {asset.id for asset in content.media}
|
|
86
|
+
for page in content.pages:
|
|
87
|
+
for section in page.sections:
|
|
88
|
+
for media_id in section.source.media:
|
|
89
|
+
if media_id not in known:
|
|
90
|
+
yield Issue(
|
|
91
|
+
code=self.name,
|
|
92
|
+
severity=Severity.ERROR,
|
|
93
|
+
message=f"unknown media asset {media_id!r}",
|
|
94
|
+
subject=f"page:{page.id}/section:{section.key}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class MediaAltCoverageRule:
|
|
99
|
+
"""Media used by publishable content must have alt text in required languages."""
|
|
100
|
+
|
|
101
|
+
name = "media-alt-coverage"
|
|
102
|
+
|
|
103
|
+
def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
|
|
104
|
+
for asset in content.media:
|
|
105
|
+
missing = [
|
|
106
|
+
language
|
|
107
|
+
for language in context.required_languages
|
|
108
|
+
if not asset.alt.get(language, "").strip()
|
|
109
|
+
]
|
|
110
|
+
for language in missing:
|
|
111
|
+
yield Issue(
|
|
112
|
+
code=self.name,
|
|
113
|
+
severity=Severity.WARNING,
|
|
114
|
+
message="missing alt text",
|
|
115
|
+
subject=f"media:{asset.id}",
|
|
116
|
+
language=language,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _article_slug(article: Article, language: Language) -> str:
|
|
121
|
+
if language is Language.EN:
|
|
122
|
+
return article.source.slug or article.id
|
|
123
|
+
translation = article.translations.get(language)
|
|
124
|
+
if translation is None or translation.content.slug is None:
|
|
125
|
+
return article.source.slug or article.id
|
|
126
|
+
return translation.content.slug
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _page_slug(page: Page, language: Language) -> str:
|
|
130
|
+
if language is Language.EN:
|
|
131
|
+
return page.source.slug
|
|
132
|
+
translation = page.translations.get(language)
|
|
133
|
+
return translation.content.slug if translation else page.source.slug
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class KnownCategoriesRule:
|
|
137
|
+
"""When the project declares categories, articles may only use those."""
|
|
138
|
+
|
|
139
|
+
name = "known-categories"
|
|
140
|
+
|
|
141
|
+
def check(self, content: SiteContent, context: ValidationContext) -> Iterator[Issue]:
|
|
142
|
+
if context.known_categories is None:
|
|
143
|
+
return
|
|
144
|
+
known = set(context.known_categories)
|
|
145
|
+
for article in content.articles:
|
|
146
|
+
if article.category is not None and article.category not in known:
|
|
147
|
+
yield Issue(
|
|
148
|
+
code=self.name,
|
|
149
|
+
severity=Severity.ERROR,
|
|
150
|
+
message=f"unknown category {article.category!r}",
|
|
151
|
+
subject=f"article:{article.id}",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def default_ruleset() -> list[Rule]:
|
|
156
|
+
return [
|
|
157
|
+
RequiredTranslationsRule(),
|
|
158
|
+
UniqueSlugsRule(),
|
|
159
|
+
MediaReferencesRule(),
|
|
160
|
+
MediaAltCoverageRule(),
|
|
161
|
+
KnownCategoriesRule(),
|
|
162
|
+
]
|