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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user