goatseo-core 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.
- goatseo_core-0.1.0/LICENSE +21 -0
- goatseo_core-0.1.0/PKG-INFO +38 -0
- goatseo_core-0.1.0/README.md +15 -0
- goatseo_core-0.1.0/pyproject.toml +42 -0
- goatseo_core-0.1.0/pyproject.toml.orig +34 -0
- goatseo_core-0.1.0/src/goatseo/core/__init__.py +52 -0
- goatseo_core-0.1.0/src/goatseo/core/context.py +14 -0
- goatseo_core-0.1.0/src/goatseo/core/html.py +85 -0
- goatseo_core-0.1.0/src/goatseo/core/metadata.py +184 -0
- goatseo_core-0.1.0/src/goatseo/core/precedence.py +54 -0
- goatseo_core-0.1.0/src/goatseo/core/py.typed +0 -0
- goatseo_core-0.1.0/src/goatseo/core/renderer.py +42 -0
- goatseo_core-0.1.0/src/goatseo/core/serialization.py +21 -0
- goatseo_core-0.1.0/src/goatseo/core/urls.py +51 -0
- goatseo_core-0.1.0/src/goatseo/core/validation.py +97 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Louis Midson LAJEANTY <midsonlajeanty@proton.me>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: goatseo-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-independent SEO metadata, validation and HTML rendering for GoatSEO.
|
|
5
|
+
Keywords: seo,schema.org,json-ld,opengraph,twitter-cards,metadata
|
|
6
|
+
Author: Louis Midson LAJEANTY
|
|
7
|
+
Author-email: Louis Midson LAJEANTY <midsonlajeanty@proton.me>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: pydantic>=2.12
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Project-URL: Homepage, https://github.com/goatlabshq/goatseo
|
|
20
|
+
Project-URL: Documentation, https://goatlabshq.github.io/goatseo/
|
|
21
|
+
Project-URL: Issues, https://github.com/goatlabshq/goatseo/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# goatseo-core
|
|
25
|
+
|
|
26
|
+
Framework-independent primitives of GoatSEO: typed page metadata, deterministic precedence between metadata layers, URL safety, escaped HTML rendering of head elements and an SEO audit.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install goatseo-core
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from goatseo.core import Metadata, metadata_elements, render_elements
|
|
34
|
+
|
|
35
|
+
html = render_elements(metadata_elements(Metadata(title="Hello")))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Part of [GoatSEO](https://github.com/goatlabshq/goatseo). See the [documentation](https://goatlabshq.github.io/goatseo/).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# goatseo-core
|
|
2
|
+
|
|
3
|
+
Framework-independent primitives of GoatSEO: typed page metadata, deterministic precedence between metadata layers, URL safety, escaped HTML rendering of head elements and an SEO audit.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install goatseo-core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from goatseo.core import Metadata, metadata_elements, render_elements
|
|
11
|
+
|
|
12
|
+
html = render_elements(metadata_elements(Metadata(title="Hello")))
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Part of [GoatSEO](https://github.com/goatlabshq/goatseo). See the [documentation](https://goatlabshq.github.io/goatseo/).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "goatseo-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Framework-independent SEO metadata, validation and HTML rendering for GoatSEO."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
keywords = [
|
|
10
|
+
"seo",
|
|
11
|
+
"schema.org",
|
|
12
|
+
"json-ld",
|
|
13
|
+
"opengraph",
|
|
14
|
+
"twitter-cards",
|
|
15
|
+
"metadata",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["pydantic>=2.12"]
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Louis Midson LAJEANTY"
|
|
30
|
+
email = "midsonlajeanty@proton.me"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/goatlabshq/goatseo"
|
|
34
|
+
Documentation = "https://goatlabshq.github.io/goatseo/"
|
|
35
|
+
Issues = "https://github.com/goatlabshq/goatseo/issues"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[tool.uv.build-backend]
|
|
42
|
+
module-name = "goatseo.core"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "goatseo-core"
|
|
3
|
+
version = "0.1.0" # x-release-please-version
|
|
4
|
+
description = "Framework-independent SEO metadata, validation and HTML rendering for GoatSEO."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Louis Midson LAJEANTY", email = "midsonlajeanty@proton.me" }]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
keywords = ["seo", "schema.org", "json-ld", "opengraph", "twitter-cards", "metadata"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"pydantic>=2.12",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/goatlabshq/goatseo"
|
|
26
|
+
Documentation = "https://goatlabshq.github.io/goatseo/"
|
|
27
|
+
Issues = "https://github.com/goatlabshq/goatseo/issues"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-name = "goatseo.core"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Framework-independent SEO primitives."""
|
|
2
|
+
|
|
3
|
+
from goatseo.core.context import RequestContext, StaticRequestContext
|
|
4
|
+
from goatseo.core.html import HeadElement, JsonLd, Link, Meta, SafeHtml, Title, render_elements
|
|
5
|
+
from goatseo.core.metadata import (
|
|
6
|
+
AlternateLink,
|
|
7
|
+
HrefLang,
|
|
8
|
+
Metadata,
|
|
9
|
+
MetadataFields,
|
|
10
|
+
Robots,
|
|
11
|
+
RobotsDirectives,
|
|
12
|
+
merge_metadata,
|
|
13
|
+
)
|
|
14
|
+
from goatseo.core.precedence import MetadataStack, Precedence, SupportsMetadata
|
|
15
|
+
from goatseo.core.renderer import ElementRenderer, metadata_elements
|
|
16
|
+
from goatseo.core.serialization import JsonObject, JsonValue, dumps_for_script
|
|
17
|
+
from goatseo.core.urls import AbsoluteUrl, UnsafeUrlError, Url, resolve_url
|
|
18
|
+
from goatseo.core.validation import Issue, Severity, audit
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AbsoluteUrl",
|
|
22
|
+
"AlternateLink",
|
|
23
|
+
"ElementRenderer",
|
|
24
|
+
"HeadElement",
|
|
25
|
+
"HrefLang",
|
|
26
|
+
"Issue",
|
|
27
|
+
"JsonLd",
|
|
28
|
+
"JsonObject",
|
|
29
|
+
"JsonValue",
|
|
30
|
+
"Link",
|
|
31
|
+
"Meta",
|
|
32
|
+
"Metadata",
|
|
33
|
+
"MetadataFields",
|
|
34
|
+
"MetadataStack",
|
|
35
|
+
"Precedence",
|
|
36
|
+
"RequestContext",
|
|
37
|
+
"Robots",
|
|
38
|
+
"RobotsDirectives",
|
|
39
|
+
"SafeHtml",
|
|
40
|
+
"Severity",
|
|
41
|
+
"StaticRequestContext",
|
|
42
|
+
"SupportsMetadata",
|
|
43
|
+
"Title",
|
|
44
|
+
"UnsafeUrlError",
|
|
45
|
+
"Url",
|
|
46
|
+
"audit",
|
|
47
|
+
"dumps_for_script",
|
|
48
|
+
"merge_metadata",
|
|
49
|
+
"metadata_elements",
|
|
50
|
+
"render_elements",
|
|
51
|
+
"resolve_url",
|
|
52
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The only thing GoatSEO needs from a web framework: the URL of the current request."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RequestContext(Protocol):
|
|
8
|
+
@property
|
|
9
|
+
def url(self) -> str: ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class StaticRequestContext:
|
|
14
|
+
url: str
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Head elements and their escaped HTML serialization."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from html import escape
|
|
6
|
+
from typing import Literal, Self, final
|
|
7
|
+
|
|
8
|
+
from goatseo.core.serialization import JsonValue, dumps_for_script
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@final
|
|
12
|
+
class SafeHtml(str):
|
|
13
|
+
"""Trusted HTML: ``__html__`` tells Django, Jinja and MarkupSafe not to escape it again."""
|
|
14
|
+
|
|
15
|
+
__slots__ = ()
|
|
16
|
+
|
|
17
|
+
# nosemgrep: python.django.security.audit.xss.html-magic-method.html-magic-method
|
|
18
|
+
def __html__(self) -> Self:
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
type MetaAttribute = Literal["name", "property", "http-equiv", "itemprop"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Title:
|
|
27
|
+
text: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Meta:
|
|
32
|
+
attribute: MetaAttribute
|
|
33
|
+
key: str
|
|
34
|
+
content: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Link:
|
|
39
|
+
rel: str
|
|
40
|
+
href: str
|
|
41
|
+
hreflang: str | None = None
|
|
42
|
+
type: str | None = None
|
|
43
|
+
title: str | None = None
|
|
44
|
+
media: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class JsonLd:
|
|
49
|
+
data: JsonValue
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type HeadElement = Title | Meta | Link | JsonLd
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _attributes(pairs: Iterable[tuple[str, str | None]]) -> str:
|
|
56
|
+
return "".join(
|
|
57
|
+
f' {name}="{escape(value, quote=True)}"' for name, value in pairs if value is not None
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_element(element: HeadElement, *, json_indent: int | None = None) -> str:
|
|
62
|
+
match element:
|
|
63
|
+
case Title(text):
|
|
64
|
+
return f"<title>{escape(text, quote=True)}</title>"
|
|
65
|
+
case Meta(attribute, key, content):
|
|
66
|
+
return f"<meta{_attributes([(attribute, key), ('content', content)])}>"
|
|
67
|
+
case Link(rel, href, hreflang, type_, title, media):
|
|
68
|
+
pairs = [
|
|
69
|
+
("rel", rel),
|
|
70
|
+
("hreflang", hreflang),
|
|
71
|
+
("type", type_),
|
|
72
|
+
("title", title),
|
|
73
|
+
("media", media),
|
|
74
|
+
("href", href),
|
|
75
|
+
]
|
|
76
|
+
return f"<link{_attributes(pairs)}>"
|
|
77
|
+
case JsonLd(data):
|
|
78
|
+
payload = dumps_for_script(data, indent=json_indent)
|
|
79
|
+
return f'<script type="application/ld+json">{payload}</script>'
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_elements(
|
|
83
|
+
elements: Iterable[HeadElement], *, separator: str = "\n", json_indent: int | None = None
|
|
84
|
+
) -> SafeHtml:
|
|
85
|
+
return SafeHtml(separator.join(render_element(e, json_indent=json_indent) for e in elements))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Strongly typed page metadata and its deterministic merge rules."""
|
|
2
|
+
|
|
3
|
+
import datetime as _dt
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Annotated, Final, Literal, Self, TypedDict
|
|
7
|
+
|
|
8
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from goatseo.core.urls import Url
|
|
11
|
+
|
|
12
|
+
TITLE_PLACEHOLDER: Final = "{title}"
|
|
13
|
+
X_DEFAULT: Final = "x-default"
|
|
14
|
+
|
|
15
|
+
_LANGUAGE_TAG: Final = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$")
|
|
16
|
+
_LOCALE: Final = re.compile(r"^[a-z]{2,3}(?:_[A-Z]{2})?$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_language_tag(value: str) -> str:
|
|
20
|
+
if not _LANGUAGE_TAG.match(value):
|
|
21
|
+
raise ValueError(f"{value!r} is not a BCP 47 language tag such as 'en' or 'fr-CA'")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_hreflang(value: str) -> str:
|
|
26
|
+
return value if value == X_DEFAULT else validate_language_tag(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_locale(value: str) -> str:
|
|
30
|
+
if not _LOCALE.match(value):
|
|
31
|
+
raise ValueError(f"{value!r} is not a locale such as 'en_US'")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_title_template(value: str) -> str:
|
|
36
|
+
if TITLE_PLACEHOLDER not in value:
|
|
37
|
+
raise ValueError(f"title template {value!r} must contain {TITLE_PLACEHOLDER}")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
type LanguageTag = Annotated[str, AfterValidator(validate_language_tag)]
|
|
42
|
+
type Locale = Annotated[str, AfterValidator(validate_locale)]
|
|
43
|
+
type NonEmptyText = Annotated[str, Field(min_length=1)]
|
|
44
|
+
type ImagePreview = Literal["none", "standard", "large"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Model(BaseModel):
|
|
48
|
+
model_config = ConfigDict(frozen=True, extra="forbid", str_strip_whitespace=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RobotsDirectives(TypedDict, total=False):
|
|
52
|
+
index: bool | None
|
|
53
|
+
follow: bool | None
|
|
54
|
+
archive: bool | None
|
|
55
|
+
snippet: bool | None
|
|
56
|
+
image_index: bool | None
|
|
57
|
+
translate: bool | None
|
|
58
|
+
max_snippet: int | None
|
|
59
|
+
max_image_preview: ImagePreview | None
|
|
60
|
+
max_video_preview: int | None
|
|
61
|
+
unavailable_after: _dt.datetime | None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Robots(_Model):
|
|
65
|
+
"""Robots directives; ``None`` means "not specified" and lets a lower layer decide."""
|
|
66
|
+
|
|
67
|
+
index: bool | None = None
|
|
68
|
+
follow: bool | None = None
|
|
69
|
+
archive: bool | None = None
|
|
70
|
+
snippet: bool | None = None
|
|
71
|
+
image_index: bool | None = None
|
|
72
|
+
translate: bool | None = None
|
|
73
|
+
max_snippet: Annotated[int, Field(ge=-1)] | None = None
|
|
74
|
+
max_image_preview: ImagePreview | None = None
|
|
75
|
+
max_video_preview: Annotated[int, Field(ge=-1)] | None = None
|
|
76
|
+
unavailable_after: _dt.datetime | None = None
|
|
77
|
+
|
|
78
|
+
def merged(self, override: Robots) -> Robots:
|
|
79
|
+
return self.model_copy(update=override.model_dump(exclude_none=True))
|
|
80
|
+
|
|
81
|
+
def directives(self) -> tuple[str, ...]:
|
|
82
|
+
flags = (
|
|
83
|
+
(self.index, "index", "noindex"),
|
|
84
|
+
(self.follow, "follow", "nofollow"),
|
|
85
|
+
(self.archive, None, "noarchive"),
|
|
86
|
+
(self.snippet, None, "nosnippet"),
|
|
87
|
+
(self.image_index, None, "noimageindex"),
|
|
88
|
+
(self.translate, None, "notranslate"),
|
|
89
|
+
)
|
|
90
|
+
directives = [
|
|
91
|
+
positive if value else negative
|
|
92
|
+
for value, positive, negative in flags
|
|
93
|
+
if value is not None and (positive if value else negative)
|
|
94
|
+
]
|
|
95
|
+
if self.max_snippet is not None:
|
|
96
|
+
directives.append(f"max-snippet:{self.max_snippet}")
|
|
97
|
+
if self.max_image_preview is not None:
|
|
98
|
+
directives.append(f"max-image-preview:{self.max_image_preview}")
|
|
99
|
+
if self.max_video_preview is not None:
|
|
100
|
+
directives.append(f"max-video-preview:{self.max_video_preview}")
|
|
101
|
+
if self.unavailable_after is not None:
|
|
102
|
+
directives.append(f"unavailable_after:{self.unavailable_after.isoformat()}")
|
|
103
|
+
return tuple(d for d in directives if d)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def indexable(self) -> bool:
|
|
107
|
+
return self.index is not False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HrefLang(_Model):
|
|
111
|
+
hreflang: Annotated[str, AfterValidator(validate_hreflang)]
|
|
112
|
+
href: Url
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AlternateLink(_Model):
|
|
116
|
+
"""A ``<link rel="alternate">`` such as an RSS feed or a print version."""
|
|
117
|
+
|
|
118
|
+
href: Url
|
|
119
|
+
type: NonEmptyText | None = None
|
|
120
|
+
title: NonEmptyText | None = None
|
|
121
|
+
media: NonEmptyText | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Metadata(_Model):
|
|
125
|
+
"""Page metadata. Every field defaults to ``None``, meaning "inherit from a lower layer"."""
|
|
126
|
+
|
|
127
|
+
title: NonEmptyText | None = None
|
|
128
|
+
title_template: Annotated[str, AfterValidator(validate_title_template)] | None = None
|
|
129
|
+
description: NonEmptyText | None = None
|
|
130
|
+
canonical: Url | None = None
|
|
131
|
+
robots: Robots | None = None
|
|
132
|
+
language: LanguageTag | None = None
|
|
133
|
+
locale: Locale | None = None
|
|
134
|
+
site_name: NonEmptyText | None = None
|
|
135
|
+
author: NonEmptyText | None = None
|
|
136
|
+
publisher: NonEmptyText | None = None
|
|
137
|
+
keywords: Sequence[NonEmptyText] | None = None
|
|
138
|
+
theme_color: Annotated[str, Field(pattern=r"^[#(),.%\w\s-]{1,64}$")] | None = None
|
|
139
|
+
alternates: Sequence[AlternateLink] | None = None
|
|
140
|
+
hreflang: Sequence[HrefLang] | None = None
|
|
141
|
+
|
|
142
|
+
def merged(self, override: Metadata) -> Self:
|
|
143
|
+
"""Returns a copy where every field set on ``override`` wins; robots merge per directive."""
|
|
144
|
+
update = {
|
|
145
|
+
name: getattr(override, name)
|
|
146
|
+
for name in type(override).model_fields
|
|
147
|
+
if getattr(override, name) is not None
|
|
148
|
+
}
|
|
149
|
+
if self.robots is not None and override.robots is not None:
|
|
150
|
+
update["robots"] = self.robots.merged(override.robots)
|
|
151
|
+
return self.model_copy(update=update)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def full_title(self) -> str | None:
|
|
155
|
+
if self.title is None or self.title_template is None:
|
|
156
|
+
return self.title
|
|
157
|
+
return self.title_template.replace(TITLE_PLACEHOLDER, self.title)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MetadataFields(TypedDict, total=False):
|
|
161
|
+
"""Keyword arguments accepted wherever a :class:`Metadata` can be built inline."""
|
|
162
|
+
|
|
163
|
+
title: str | None
|
|
164
|
+
title_template: str | None
|
|
165
|
+
description: str | None
|
|
166
|
+
canonical: str | None
|
|
167
|
+
robots: Robots | None
|
|
168
|
+
language: str | None
|
|
169
|
+
locale: str | None
|
|
170
|
+
site_name: str | None
|
|
171
|
+
author: str | None
|
|
172
|
+
publisher: str | None
|
|
173
|
+
keywords: Sequence[str] | None
|
|
174
|
+
theme_color: str | None
|
|
175
|
+
alternates: Sequence[AlternateLink] | None
|
|
176
|
+
hreflang: Sequence[HrefLang] | None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def merge_metadata(*layers: Metadata) -> Metadata:
|
|
180
|
+
"""Merges layers from least to most specific: the last layer that sets a field wins."""
|
|
181
|
+
result = Metadata()
|
|
182
|
+
for layer in layers:
|
|
183
|
+
result = result.merged(layer)
|
|
184
|
+
return result
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Metadata layers resolved from the least to the most specific."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from typing import Protocol, Self, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from goatseo.core.metadata import Metadata, merge_metadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Precedence(IntEnum):
|
|
11
|
+
GLOBAL = 0
|
|
12
|
+
SITE = 10
|
|
13
|
+
APPLICATION = 20
|
|
14
|
+
MODEL = 30
|
|
15
|
+
VIEW = 40
|
|
16
|
+
REQUEST = 50
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class SupportsMetadata(Protocol):
|
|
21
|
+
"""Implemented by domain objects (a Django model, a dataclass...) that describe their page."""
|
|
22
|
+
|
|
23
|
+
def seo_metadata(self) -> Metadata: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MetadataStack:
|
|
27
|
+
"""Holds one metadata layer per precedence level; higher levels override lower ones."""
|
|
28
|
+
|
|
29
|
+
__slots__ = ("_layers", "_resolved")
|
|
30
|
+
|
|
31
|
+
def __init__(self, layers: Mapping[Precedence, Metadata] | None = None) -> None:
|
|
32
|
+
self._layers: dict[Precedence, Metadata] = dict(layers or {})
|
|
33
|
+
self._resolved: Metadata | None = None
|
|
34
|
+
|
|
35
|
+
def set(self, precedence: Precedence, metadata: Metadata) -> Self:
|
|
36
|
+
self._layers[precedence] = metadata
|
|
37
|
+
self._resolved = None
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def update(self, precedence: Precedence, metadata: Metadata) -> Self:
|
|
41
|
+
"""Merges ``metadata`` into the layer at ``precedence`` instead of replacing it."""
|
|
42
|
+
current = self._layers.get(precedence)
|
|
43
|
+
return self.set(precedence, metadata if current is None else current.merged(metadata))
|
|
44
|
+
|
|
45
|
+
def layer(self, precedence: Precedence) -> Metadata | None:
|
|
46
|
+
return self._layers.get(precedence)
|
|
47
|
+
|
|
48
|
+
def resolve(self) -> Metadata:
|
|
49
|
+
if self._resolved is None:
|
|
50
|
+
self._resolved = merge_metadata(*(self._layers[p] for p in sorted(self._layers)))
|
|
51
|
+
return self._resolved
|
|
52
|
+
|
|
53
|
+
def copy(self) -> MetadataStack:
|
|
54
|
+
return MetadataStack(self._layers)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Turns metadata into head elements; framework adapters only choose where to print them."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Iterator
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from goatseo.core.html import HeadElement, Link, Meta, Title
|
|
7
|
+
from goatseo.core.metadata import Metadata
|
|
8
|
+
from goatseo.core.urls import resolve_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ElementRenderer[T](Protocol):
|
|
12
|
+
def __call__(self, value: T, /) -> Iterable[HeadElement]: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def metadata_elements(metadata: Metadata, *, base_url: str | None = None) -> Iterator[HeadElement]:
|
|
16
|
+
title = metadata.full_title
|
|
17
|
+
if title is not None:
|
|
18
|
+
yield Title(title)
|
|
19
|
+
if metadata.description is not None:
|
|
20
|
+
yield Meta("name", "description", metadata.description)
|
|
21
|
+
if metadata.robots is not None and (directives := metadata.robots.directives()):
|
|
22
|
+
yield Meta("name", "robots", ", ".join(directives))
|
|
23
|
+
if metadata.author is not None:
|
|
24
|
+
yield Meta("name", "author", metadata.author)
|
|
25
|
+
if metadata.publisher is not None:
|
|
26
|
+
yield Meta("name", "publisher", metadata.publisher)
|
|
27
|
+
if metadata.keywords:
|
|
28
|
+
yield Meta("name", "keywords", ", ".join(metadata.keywords))
|
|
29
|
+
if metadata.theme_color is not None:
|
|
30
|
+
yield Meta("name", "theme-color", metadata.theme_color)
|
|
31
|
+
if metadata.canonical is not None:
|
|
32
|
+
yield Link("canonical", resolve_url(metadata.canonical, base_url))
|
|
33
|
+
for alternate in metadata.hreflang or ():
|
|
34
|
+
yield Link("alternate", resolve_url(alternate.href, base_url), hreflang=alternate.hreflang)
|
|
35
|
+
for link in metadata.alternates or ():
|
|
36
|
+
yield Link(
|
|
37
|
+
"alternate",
|
|
38
|
+
resolve_url(link.href, base_url),
|
|
39
|
+
type=link.type,
|
|
40
|
+
title=link.title,
|
|
41
|
+
media=link.media,
|
|
42
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""JSON values and serialization that is safe to embed inside an HTML ``<script>`` element."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
|
|
7
|
+
type JsonObject = dict[str, JsonValue]
|
|
8
|
+
|
|
9
|
+
# Escaping these keeps "</script>", "<!--" and line separators from ending or corrupting
|
|
10
|
+
# the script element, while the JSON document itself stays byte-for-byte equivalent.
|
|
11
|
+
_SCRIPT_ESCAPES: Final = str.maketrans(
|
|
12
|
+
{"<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "\u2028": "\\u2028", "\u2029": "\\u2029"}
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dumps_for_script(value: JsonValue, *, indent: int | None = None) -> str:
|
|
17
|
+
separators = (",", ": ") if indent is not None else (",", ":")
|
|
18
|
+
text = json.dumps(
|
|
19
|
+
value, ensure_ascii=False, allow_nan=False, indent=indent, separators=separators
|
|
20
|
+
)
|
|
21
|
+
return text.translate(_SCRIPT_ESCAPES)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""URL validation and resolution shared by every GoatSEO package."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
from typing import Annotated, Final
|
|
5
|
+
from urllib.parse import urljoin, urlsplit
|
|
6
|
+
|
|
7
|
+
from pydantic import AfterValidator
|
|
8
|
+
|
|
9
|
+
ALLOWED_SCHEMES: Final = frozenset({"http", "https"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnsafeUrlError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _reject_invisible_characters(value: str) -> None:
|
|
17
|
+
if any(char.isspace() or unicodedata.category(char).startswith("C") for char in value):
|
|
18
|
+
raise UnsafeUrlError(f"URL {value!r} contains whitespace or control characters")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_absolute(url: str) -> bool:
|
|
22
|
+
parts = urlsplit(url)
|
|
23
|
+
return bool(parts.scheme and parts.netloc)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_absolute_url(value: str) -> str:
|
|
27
|
+
value = value.strip()
|
|
28
|
+
_reject_invisible_characters(value)
|
|
29
|
+
parts = urlsplit(value)
|
|
30
|
+
if parts.scheme.lower() not in ALLOWED_SCHEMES or not parts.netloc:
|
|
31
|
+
raise UnsafeUrlError(f"URL {value!r} must be an absolute http(s) URL")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_url(value: str) -> str:
|
|
36
|
+
"""Accepts absolute http(s) URLs and root-relative paths such as ``/articles/1``."""
|
|
37
|
+
value = value.strip()
|
|
38
|
+
_reject_invisible_characters(value)
|
|
39
|
+
if value.startswith("/") and not value.startswith("//"):
|
|
40
|
+
return value
|
|
41
|
+
return validate_absolute_url(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_url(url: str, base_url: str | None) -> str:
|
|
45
|
+
if base_url is None or is_absolute(url):
|
|
46
|
+
return url
|
|
47
|
+
return validate_absolute_url(urljoin(base_url, url))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
type AbsoluteUrl = Annotated[str, AfterValidator(validate_absolute_url)]
|
|
51
|
+
type Url = Annotated[str, AfterValidator(validate_url)]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""SEO audit: checks that are valid HTML-wise but hurt search visibility."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from goatseo.core.metadata import X_DEFAULT, Metadata
|
|
10
|
+
from goatseo.core.urls import is_absolute
|
|
11
|
+
|
|
12
|
+
TITLE_MAX_LENGTH: Final = 60
|
|
13
|
+
DESCRIPTION_MIN_LENGTH: Final = 50
|
|
14
|
+
DESCRIPTION_MAX_LENGTH: Final = 160
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Severity(StrEnum):
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
WARNING = "warning"
|
|
20
|
+
INFO = "info"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Issue:
|
|
25
|
+
code: str
|
|
26
|
+
severity: Severity
|
|
27
|
+
field: str
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _title_issues(metadata: Metadata) -> Iterator[Issue]:
|
|
32
|
+
title = metadata.full_title
|
|
33
|
+
if title is None:
|
|
34
|
+
yield Issue("missing-title", Severity.ERROR, "title", "The page has no title.")
|
|
35
|
+
elif len(title) > TITLE_MAX_LENGTH:
|
|
36
|
+
yield Issue(
|
|
37
|
+
"title-too-long",
|
|
38
|
+
Severity.WARNING,
|
|
39
|
+
"title",
|
|
40
|
+
f"The title has {len(title)} characters; search engines show about {TITLE_MAX_LENGTH}.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _description_issues(metadata: Metadata) -> Iterator[Issue]:
|
|
45
|
+
description = metadata.description
|
|
46
|
+
if description is None:
|
|
47
|
+
yield Issue(
|
|
48
|
+
"missing-description", Severity.WARNING, "description", "The page has no description."
|
|
49
|
+
)
|
|
50
|
+
elif not DESCRIPTION_MIN_LENGTH <= len(description) <= DESCRIPTION_MAX_LENGTH:
|
|
51
|
+
yield Issue(
|
|
52
|
+
"description-length",
|
|
53
|
+
Severity.INFO,
|
|
54
|
+
"description",
|
|
55
|
+
f"The description has {len(description)} characters; aim for "
|
|
56
|
+
f"{DESCRIPTION_MIN_LENGTH} to {DESCRIPTION_MAX_LENGTH}.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _canonical_issues(metadata: Metadata) -> Iterator[Issue]:
|
|
61
|
+
if metadata.canonical is None:
|
|
62
|
+
yield Issue("missing-canonical", Severity.INFO, "canonical", "No canonical URL is set.")
|
|
63
|
+
elif not is_absolute(metadata.canonical):
|
|
64
|
+
yield Issue(
|
|
65
|
+
"relative-canonical",
|
|
66
|
+
Severity.INFO,
|
|
67
|
+
"canonical",
|
|
68
|
+
"The canonical URL is relative; it is resolved against the request URL.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hreflang_issues(metadata: Metadata) -> Iterator[Issue]:
|
|
73
|
+
languages = Counter(link.hreflang.lower() for link in metadata.hreflang or ())
|
|
74
|
+
for language, count in sorted(languages.items()):
|
|
75
|
+
if count > 1:
|
|
76
|
+
yield Issue(
|
|
77
|
+
"duplicate-hreflang",
|
|
78
|
+
Severity.ERROR,
|
|
79
|
+
"hreflang",
|
|
80
|
+
f"hreflang {language!r} is declared {count} times.",
|
|
81
|
+
)
|
|
82
|
+
if languages and X_DEFAULT not in languages:
|
|
83
|
+
yield Issue(
|
|
84
|
+
"missing-x-default",
|
|
85
|
+
Severity.INFO,
|
|
86
|
+
"hreflang",
|
|
87
|
+
"hreflang alternates have no x-default entry.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def audit(metadata: Metadata) -> tuple[Issue, ...]:
|
|
92
|
+
return (
|
|
93
|
+
*_title_issues(metadata),
|
|
94
|
+
*_description_issues(metadata),
|
|
95
|
+
*_canonical_issues(metadata),
|
|
96
|
+
*_hreflang_issues(metadata),
|
|
97
|
+
)
|