2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
import types
|
|
|
|
|
import devplacepy.seo as seo
|
|
|
|
|
import devplacepy.attachments as attach
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 22:15:22 +02:00
|
|
|
def _seo_request():
|
|
|
|
|
return types.SimpleNamespace(
|
|
|
|
|
base_url="https://x.test/",
|
|
|
|
|
url=types.SimpleNamespace(path="/posts/abc"),
|
|
|
|
|
query_params={},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
def test_truncate_no_overflow():
|
|
|
|
|
assert len(seo.truncate("x" * 200)) <= 160
|
|
|
|
|
assert seo.truncate("short text") == "short text"
|
|
|
|
|
assert seo.truncate("") == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_software_application_url_is_per_project():
|
2026-06-09 18:48:08 +02:00
|
|
|
schema = seo.software_application_schema(
|
|
|
|
|
{"uid": "p1", "slug": "p1-foo", "title": "P"}, "https://x.test"
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
assert schema["url"] == "https://x.test/projects/p1-foo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_schema_types():
|
|
|
|
|
assert seo.organization_schema("https://x.test")["@type"] == "Organization"
|
2026-06-09 18:48:08 +02:00
|
|
|
assert (
|
|
|
|
|
seo.news_article_schema(
|
|
|
|
|
{"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test"
|
|
|
|
|
)["@type"]
|
|
|
|
|
== "NewsArticle"
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
seo.software_source_code_schema(
|
|
|
|
|
{"uid": "g1", "title": "G", "language": "python"}, "https://x.test"
|
|
|
|
|
)["@type"]
|
|
|
|
|
== "SoftwareSourceCode"
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_news_article_publisher_is_organization():
|
2026-06-09 18:48:08 +02:00
|
|
|
schema = seo.news_article_schema(
|
|
|
|
|
{"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test"
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
assert schema["publisher"]["@type"] == "Organization"
|
|
|
|
|
|
|
|
|
|
|
Make the project page an SEO-optimized overview with a devlog environment
The project detail page becomes a professional project overview: hero
with status/type/dates/forked-from, a stats strip (stars, updates,
comments, files, forks with #devlog/#comments anchors), an About
section, platforms, and the Devlog timeline under a proper h2 - now
rendered with feed.css loaded so the post cards are actually styled.
The owner posts updates from the page itself: a Post update button
opens the shared create-post composer preset to the devlog topic and
this project. The composer form is extracted into
_post_composer_form.html and reused by feed.html - one form, two
surfaces.
SEO: software_application_schema is type-aware via project_schema_type
(game -> VideoGame with gamePlatform, website -> WebApplication,
software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork)
and now carries keywords, image, an aggregateRating from stars, and a
comment InteractionCounter. project_devlog_schema emits a Blog node
with one BlogPosting per devlog entry. The detail route feeds both,
adds meta keywords, and exposes the devlog cursor as rel=next; the
sitemap's project lastmod follows the newest devlog post via one
grouped query. devlog_count/comment_count ride ProjectDetailOut, the
docs projects-detail endpoint documents the before cursor, and the
routers/projects CLAUDE.md, templates CLAUDE.md and README document the
new surface. Unit, api and e2e tests cover the schema mapping, the
JSON-LD in the rendered page, the stats strip, and the preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:05:04 +02:00
|
|
|
def test_project_schema_type_mapping():
|
|
|
|
|
assert seo.project_schema_type("game") == "VideoGame"
|
|
|
|
|
assert seo.project_schema_type("game_asset") == "CreativeWork"
|
|
|
|
|
assert seo.project_schema_type("website") == "WebApplication"
|
|
|
|
|
assert seo.project_schema_type("software") == "SoftwareApplication"
|
|
|
|
|
assert seo.project_schema_type("mobile_app") == "SoftwareApplication"
|
|
|
|
|
assert seo.project_schema_type(None) == "SoftwareApplication"
|
|
|
|
|
assert seo.project_schema_type("unknown") == "SoftwareApplication"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_software_application_schema_is_type_aware():
|
|
|
|
|
game = seo.software_application_schema(
|
|
|
|
|
{"uid": "p1", "slug": "p1-g", "title": "G", "project_type": "game", "platforms": "PC, Web"},
|
|
|
|
|
"https://x.test",
|
|
|
|
|
)
|
|
|
|
|
assert game["@type"] == "VideoGame"
|
|
|
|
|
assert game["gamePlatform"] == ["PC", "Web"]
|
|
|
|
|
assert "applicationCategory" not in game
|
|
|
|
|
web = seo.software_application_schema(
|
|
|
|
|
{"uid": "p2", "slug": "p2-w", "title": "W", "project_type": "website"},
|
|
|
|
|
"https://x.test",
|
|
|
|
|
)
|
|
|
|
|
assert web["@type"] == "WebApplication"
|
|
|
|
|
assert web["browserRequirements"] == "Requires JavaScript"
|
|
|
|
|
assert web["applicationCategory"] == "DeveloperApplication"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_software_application_schema_rating_and_image():
|
|
|
|
|
project = {"uid": "p1", "slug": "p1-s", "title": "S", "project_type": "software"}
|
|
|
|
|
plain = seo.software_application_schema(project, "https://x.test")
|
|
|
|
|
assert "aggregateRating" not in plain
|
|
|
|
|
assert "image" not in plain
|
|
|
|
|
rich = seo.software_application_schema(
|
|
|
|
|
project,
|
|
|
|
|
"https://x.test",
|
|
|
|
|
image_url="https://x.test/img.png",
|
|
|
|
|
star_count=7,
|
|
|
|
|
comment_count=3,
|
|
|
|
|
)
|
|
|
|
|
assert rich["aggregateRating"]["ratingCount"] == "7"
|
|
|
|
|
assert rich["image"] == "https://x.test/img.png"
|
|
|
|
|
assert rich["interactionStatistic"]["userInteractionCount"] == 3
|
|
|
|
|
|
|
|
|
|
|
Dedicate the project page to the project: hero, tabs, screenshots, sidebar
The project detail page becomes a full project showcase on the site's
content measure. The hero card opens with a cover banner from the
project's first image attachment (brand-gradient band as fallback),
then title + status chip, type/platform chips, dates and forked-from
meta, the author row with an owner-set Visit Website CTA, and the
unchanged action row. A sticky anchor tab bar (Overview, Devlog,
Screenshots when images exist, Comments, Files) navigates the page
with plain server-rendered anchors so crawlers index one complete
document. The two-column body keeps About (description + non-image
attachments), the Devlog timeline and the comment thread in the main
column, adds a Screenshots gallery built from image attachments
(lightbox-wired thumbnails), and a sidebar with Links (website, files,
fork source), the Stats card with a last-update line, and the Author
card.
New optional projects.website_url rides the whole stack: normalized
and validated in models (scheme-less input gets https://, non-http(s)
rejected), settable in the create and edit modals, on ProjectOut, in
the Devii create/edit actions and the API docs, rendered as the hero
CTA and Links entry with rel noopener nofollow, and emitted as
schema.org sameAs. The app schema also gains screenshot urls from the
image attachments. The e2e project comment/files tests scope their
locators (.comment-form textarea, .project-detail-actions a) per the
documented dual-control idiom - the composer modal made the bare
selectors ambiguous - and new unit/api tests cover URL normalization,
sameAs/screenshot schema output, the cover, and the gallery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:56:50 +02:00
|
|
|
def test_software_application_schema_website_and_screenshots():
|
|
|
|
|
project = {
|
|
|
|
|
"uid": "p1",
|
|
|
|
|
"slug": "p1-s",
|
|
|
|
|
"title": "S",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"website_url": "https://myproject.dev",
|
|
|
|
|
}
|
|
|
|
|
schema = seo.software_application_schema(
|
|
|
|
|
project,
|
|
|
|
|
"https://x.test",
|
|
|
|
|
screenshot_urls=["https://x.test/a.png", "https://x.test/b.png"],
|
|
|
|
|
)
|
|
|
|
|
assert schema["sameAs"] == ["https://myproject.dev"]
|
|
|
|
|
assert schema["screenshot"] == ["https://x.test/a.png", "https://x.test/b.png"]
|
|
|
|
|
bare = seo.software_application_schema(
|
|
|
|
|
{"uid": "p2", "slug": "p2-s", "title": "B"}, "https://x.test"
|
|
|
|
|
)
|
|
|
|
|
assert "sameAs" not in bare
|
|
|
|
|
assert "screenshot" not in bare
|
|
|
|
|
|
|
|
|
|
|
Make the project page an SEO-optimized overview with a devlog environment
The project detail page becomes a professional project overview: hero
with status/type/dates/forked-from, a stats strip (stars, updates,
comments, files, forks with #devlog/#comments anchors), an About
section, platforms, and the Devlog timeline under a proper h2 - now
rendered with feed.css loaded so the post cards are actually styled.
The owner posts updates from the page itself: a Post update button
opens the shared create-post composer preset to the devlog topic and
this project. The composer form is extracted into
_post_composer_form.html and reused by feed.html - one form, two
surfaces.
SEO: software_application_schema is type-aware via project_schema_type
(game -> VideoGame with gamePlatform, website -> WebApplication,
software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork)
and now carries keywords, image, an aggregateRating from stars, and a
comment InteractionCounter. project_devlog_schema emits a Blog node
with one BlogPosting per devlog entry. The detail route feeds both,
adds meta keywords, and exposes the devlog cursor as rel=next; the
sitemap's project lastmod follows the newest devlog post via one
grouped query. devlog_count/comment_count ride ProjectDetailOut, the
docs projects-detail endpoint documents the before cursor, and the
routers/projects CLAUDE.md, templates CLAUDE.md and README document the
new surface. Unit, api and e2e tests cover the schema mapping, the
JSON-LD in the rendered page, the stats strip, and the preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:05:04 +02:00
|
|
|
def test_project_devlog_schema_builds_blog_graph():
|
|
|
|
|
project = {"uid": "p1", "slug": "p1-s", "title": "Nebula"}
|
|
|
|
|
assert seo.project_devlog_schema(project, [], "https://x.test") is None
|
|
|
|
|
posts = [
|
|
|
|
|
{
|
|
|
|
|
"post": {
|
|
|
|
|
"uid": "a1",
|
|
|
|
|
"slug": "a1-first",
|
|
|
|
|
"title": "First update",
|
|
|
|
|
"content": "Progress!",
|
|
|
|
|
"created_at": "2026-01-01T00:00:00+00:00",
|
|
|
|
|
},
|
|
|
|
|
"author": {"username": "dev"},
|
|
|
|
|
"comment_count": 2,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
schema = seo.project_devlog_schema(project, posts, "https://x.test")
|
|
|
|
|
assert schema["@type"] == "Blog"
|
|
|
|
|
assert schema["url"] == "https://x.test/projects/p1-s#devlog"
|
|
|
|
|
entry = schema["blogPost"][0]
|
|
|
|
|
assert entry["@type"] == "BlogPosting"
|
|
|
|
|
assert entry["headline"] == "First update"
|
|
|
|
|
assert entry["url"] == "https://x.test/posts/a1-first"
|
|
|
|
|
assert entry["author"]["name"] == "dev"
|
|
|
|
|
assert entry["commentCount"] == 2
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 05:22:44 +02:00
|
|
|
def test_site_url_precedence(monkeypatch):
|
|
|
|
|
import devplacepy.database as database
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(seo, "SITE_URL", "https://constant.test")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
database, "get_setting", lambda *a, **k: "https://setting.test"
|
|
|
|
|
)
|
|
|
|
|
assert seo.site_url(None) == "https://setting.test"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(database, "get_setting", lambda *a, **k: "")
|
|
|
|
|
assert seo.site_url(None) == "https://constant.test"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(seo, "SITE_URL", "")
|
|
|
|
|
request = types.SimpleNamespace(base_url="https://req.test/")
|
|
|
|
|
assert seo.site_url(request) == "https://req.test"
|
2026-05-23 03:21:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_site_url_falls_back_to_request(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(seo, "SITE_URL", "")
|
|
|
|
|
request = types.SimpleNamespace(base_url="http://fallback.test/")
|
|
|
|
|
assert seo.site_url(request) == "http://fallback.test"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upload_allowlist_excludes_dangerous_types():
|
|
|
|
|
assert ".svg" not in attach.ALLOWED_UPLOAD_TYPES
|
|
|
|
|
assert ".html" not in attach.ALLOWED_UPLOAD_TYPES
|
|
|
|
|
assert ".png" in attach.ALLOWED_UPLOAD_TYPES
|
|
|
|
|
assert ".svg" not in attach.POST_IMAGE_EXTENSIONS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detect_mime_neutralizes_dangerous_types():
|
|
|
|
|
assert attach._detect_mime(b"", "x.svg") == "application/octet-stream"
|
|
|
|
|
assert attach._detect_mime(b"", "x.html") == "application/octet-stream"
|
|
|
|
|
assert attach._detect_mime(b"", "x.png") == "image/png"
|
2026-06-19 22:15:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_base_seo_context_emits_keywords_key():
|
|
|
|
|
ctx = seo.base_seo_context(
|
|
|
|
|
_seo_request(),
|
|
|
|
|
title="A Post",
|
|
|
|
|
description="Body text",
|
|
|
|
|
keywords="python, sqlite",
|
|
|
|
|
)
|
|
|
|
|
assert "meta_keywords" in ctx
|
|
|
|
|
assert ctx["meta_keywords"] == "python, sqlite"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_base_seo_context_default_keywords_empty_without_target():
|
|
|
|
|
ctx = seo.base_seo_context(
|
|
|
|
|
_seo_request(), title="A Post", description="Body text"
|
|
|
|
|
)
|
|
|
|
|
assert ctx["meta_keywords"] == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_base_seo_context_description_strips_markdown(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(seo, "_ready_seo_metadata", lambda target: None)
|
|
|
|
|
ctx = seo.base_seo_context(
|
|
|
|
|
_seo_request(),
|
|
|
|
|
title="A Post",
|
|
|
|
|
description="## Heading\n\nThis is **bold** body with `code`.",
|
|
|
|
|
)
|
|
|
|
|
assert "##" not in ctx["meta_description"]
|
|
|
|
|
assert "**" not in ctx["meta_description"]
|
|
|
|
|
assert "`" not in ctx["meta_description"]
|
|
|
|
|
assert "bold" in ctx["meta_description"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_base_seo_context_plain_default_fills_fields_for_target(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(seo, "_ready_seo_metadata", lambda target: None)
|
|
|
|
|
ctx = seo.base_seo_context(
|
|
|
|
|
_seo_request(),
|
|
|
|
|
title="Async Python Database Tooling",
|
|
|
|
|
description="A **guide** to async python database tooling with sqlite.",
|
|
|
|
|
seo_target=("post", "abc"),
|
|
|
|
|
)
|
|
|
|
|
assert ctx["meta_keywords"]
|
|
|
|
|
assert "python" in ctx["meta_keywords"]
|
|
|
|
|
assert ctx["meta_description"]
|
|
|
|
|
assert "**" not in ctx["meta_description"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_base_seo_context_consumes_ready_metadata(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
seo,
|
|
|
|
|
"_ready_seo_metadata",
|
|
|
|
|
lambda target: {
|
|
|
|
|
"seo_title": "Generated Title",
|
|
|
|
|
"seo_description": "Generated clean description",
|
|
|
|
|
"seo_keywords": "generated, metadata",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
ctx = seo.base_seo_context(
|
|
|
|
|
_seo_request(),
|
|
|
|
|
title="Raw **markdown** title",
|
|
|
|
|
description="raw markdown body",
|
|
|
|
|
seo_target=("post", "abc"),
|
|
|
|
|
)
|
|
|
|
|
assert "Generated Title" in ctx["page_title"]
|
|
|
|
|
assert ctx["meta_description"] == "Generated clean description"
|
|
|
|
|
assert ctx["meta_keywords"] == "generated, metadata"
|