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.
@@ -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
+ )