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>
This commit is contained in:
2026-08-09 21:36:51 +02:00
co-authored by Claude Fable 5
parent 3fca4be72e
commit 2af5110399
16 changed files with 504 additions and 102 deletions
+67
View File
@@ -271,3 +271,70 @@ def test_devlog_works_for_guest_visitor(app_server):
assert "devlog_posts" in body
assert body["devlog_posts"] == []
def test_devlog_count_in_json(app_server):
"""devlog_count reflects every linked post, beyond the rendered page."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
for i in range(3):
_create_post_direct(project["uid"], user["uid"], i)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_count"] == 3
assert body["comment_count"] == 0
def test_project_page_emits_typed_json_ld(app_server):
"""A game project renders VideoGame JSON-LD; the devlog renders a Blog graph."""
session, name = _member()
title = _unique("dlseo")
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": title,
"description": "SEO schema test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC,Web",
},
)
assert r.status_code == 200, r.text[:300]
project = r.json()["data"]
slug = project["slug"] or project["uid"]
user = _db_user(name)
marker = _unique("dlseopost")
_create_post_direct(project["uid"], user["uid"], 0, marker=marker)
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"VideoGame"' in html, "Expected VideoGame JSON-LD for a game project"
assert '"Blog"' in html, "Expected a Blog node for the devlog"
assert '"BlogPosting"' in html, "Expected BlogPosting entries for devlog posts"
assert '"gamePlatform"' in html, "Expected gamePlatform from the platforms field"
def test_project_page_json_ld_rating_from_stars(app_server):
"""Stars surface as an aggregateRating; zero stars emit none."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"aggregateRating"' not in html, "No rating expected without stars"
r = session.post(
f"{BASE_URL}/votes/project/{project['uid']}",
data={"value": "1"},
headers={"X-Requested-With": "fetch"},
)
assert r.status_code == 200, r.text[:300]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"aggregateRating"' in html, "Expected aggregateRating once starred"
+41 -1
View File
@@ -85,7 +85,7 @@ def test_devlog_empty_state_on_project_page(alice):
devlog_section = page.locator(".project-devlog")
expect(devlog_section).to_be_visible()
expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible()
expect(devlog_section.locator("h2:has-text('Devlog')")).to_be_visible()
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
@@ -187,3 +187,43 @@ def test_devlog_multiple_posts_order(alice):
f"Expected newest post first: {markers[-1]}, got: {first_text}"
)
def test_project_stats_strip_visible(alice):
"""The overview stats strip shows stars, updates, comments, files, forks."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
_seed_project_post(project_uid, owner_uid, 0)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
stats = page.locator(".project-stats")
expect(stats).to_be_visible()
expect(stats.locator(".project-stat")).to_have_count(5)
expect(stats.locator(".project-stat:has-text('update')")).to_contain_text("1")
def test_owner_post_update_button_opens_preset_composer(alice):
"""The owner's Post update button opens the composer preselected to this project."""
page, _ = alice
_create_project_ui(page, f"Composer Project {uuid4().hex[:6]}")
button = page.locator(".project-devlog-post-btn")
expect(button).to_be_visible()
button.click()
modal = page.locator("#create-post-modal")
expect(modal).to_be_visible()
expect(modal.locator("input[name='topic'][value='devlog']")).to_be_checked()
selected = modal.locator("#project_uid").input_value()
assert selected != "", "Expected the project preselected in the composer"
def test_guest_sees_no_post_update_button(app_server):
"""Guests and non-owners get no Post update control."""
import requests
slug, _, _ = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{slug}")
assert r.status_code == 200
assert "project-devlog-post-btn" not in r.text
+71
View File
@@ -49,6 +49,77 @@ def test_news_article_publisher_is_organization():
assert schema["publisher"]["@type"] == "Organization"
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
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
def test_site_url_precedence(monkeypatch):
import devplacepy.database as database