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:
blindxfish 2026-08-09 21:05:04 +02:00
parent 3fca4be72e
commit 2af5110399
16 changed files with 504 additions and 102 deletions

View File

@ -68,6 +68,7 @@ devplacepy/
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read | | `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion | | `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility | | `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
| `/projects/{slug}` | SEO-optimized project overview and devlog: hero with status/type/dates, stats strip (stars, updates, comments, files, forks), About and Platforms sections, and a **Devlog** timeline of every post linked to the project. The owner posts updates straight from the page (composer preset to the project with the `devlog` topic). Emits type-aware JSON-LD (VideoGame / WebApplication / SoftwareApplication / CreativeWork with rating, keywords and image) plus a `Blog`/`BlogPosting` graph for the devlog; the sitemap's `lastmod` follows the newest devlog post |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) | | `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` | | `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL | | `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |

View File

@ -854,6 +854,10 @@ def enrich_items(
return enriched return enriched
def count_project_devlog(project_uid: str) -> int:
return get_table("posts").count(project_uid=project_uid, deleted_at=None)
def get_project_devlog( def get_project_devlog(
project_uid: str, before: str | None = None, viewer: dict | None = None project_uid: str, before: str | None = None, viewer: dict | None = None
) -> tuple[list, str | None]: ) -> tuple[list, str | None]:

View File

@ -362,7 +362,7 @@ four ways to sign requests.
method="GET", method="GET",
path="/projects/{project_slug}", path="/projects/{project_slug}",
title="View a project", title="View a project",
summary="Render a project with comments. Returns an HTML page.", summary="Render a project overview with its devlog and comments. Returns an HTML page.",
auth="public", auth="public",
interactive=True, interactive=True,
params=[ params=[
@ -373,7 +373,15 @@ four ways to sign requests.
True, True,
"PROJECT_SLUG", "PROJECT_SLUG",
"Slug or UID of the project.", "Slug or UID of the project.",
) ),
field(
"before",
"query",
"string",
False,
"",
"Devlog pagination cursor (devlog_next_cursor from the previous page).",
),
], ],
), ),
endpoint( endpoint(

View File

@ -246,7 +246,7 @@ The feed page (`GET /feed`) is accessible without authentication:
All SEO features are implemented across the following locations: All SEO features are implemented across the following locations:
### Core SEO utilities ### Core SEO utilities
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator - `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, the type-aware project schema via `project_schema_type` - VideoGame/WebApplication/SoftwareApplication/CreativeWork - plus the devlog `Blog`/`BlogPosting` graph `project_devlog_schema`), meta description truncation, schema combiner, sitemap XML generator (project `lastmod` = latest of created/updated/newest devlog post)
- `routers/seo.py` - robots.txt and sitemap.xml routes - `routers/seo.py` - robots.txt and sitemap.xml routes
### SEO template context ### SEO template context

View File

@ -9,6 +9,10 @@ This file documents the project detail page, the per-project virtual filesystem,
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`. Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
**Project overview + devlog (SEO surface).** The detail page is a professional project overview: hero (h1 title, status, type badge, dates, forked-from), author row, a `.project-stats` strip (stars / updates / comments / files / forks, the anchors jump to `#devlog` and `#comments`), an **About** section, platforms, the action row, then the **Devlog** section (`#devlog`, `h2`) listing every post whose `project_uid` points at the project via `_post_card.html` (the template loads `feed.css` for the card styles alongside `post.css` - same rule as `news.html`). `devlog_count` (`content.count_project_devlog`) and `comment_count` ride the context and `ProjectDetailOut`. The owner gets a **Post update** button (`.project-devlog-post-btn`) opening the shared create-post composer preset to `topic=devlog` + this project - the composer form lives ONCE in `templates/_post_composer_form.html` (locals `_composer_topic`, `_composer_project`) and is included by both `feed.html` and `project_detail.html`; never fork a second copy of that form.
**JSON-LD** (`seo.py`): `software_application_schema(project, base, image_url=, star_count=, comment_count=)` is type-aware via `project_schema_type` (`game` -> `VideoGame` with `gamePlatform`, `website` -> `WebApplication`, `software`/`mobile_app` -> `SoftwareApplication`, `game_asset` -> `CreativeWork`), adds `keywords` (type + platforms), `image`, an `aggregateRating` when the project has stars, and a comment `InteractionCounter`. `project_devlog_schema(project, devlog_posts, base)` emits a `Blog` node (`@id` = `{project_url}#devlog`) with one `BlogPosting` per rendered devlog entry; it returns `None` for an empty devlog (dropped by `combine`). The devlog cursor rides `base_seo_context(next_url=next_page_url(request, devlog_next_cursor))` for a crawlable `rel=next`, `keywords` carries title/type/platforms, and the sitemap's project `lastmod` is `max(created_at, updated_at, newest devlog post)` via one grouped posts query in `_build_sitemap`. The `before` devlog cursor is documented on `projects-detail` in `docs_api/groups/content.py`.
**Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks. **Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.
**Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`). **Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`).

View File

@ -41,6 +41,7 @@ from devplacepy.content import (
can_view_project_containers, can_view_project_containers,
can_open_workspace, can_open_workspace,
get_project_devlog, get_project_devlog,
count_project_devlog,
) )
from devplacepy.utils import ( from devplacepy.utils import (
get_current_user, get_current_user,
@ -52,10 +53,12 @@ from devplacepy.utils import (
XP_PROJECT, XP_PROJECT,
) )
from devplacepy.seo import ( from devplacepy.seo import (
absolute_url,
base_seo_context, base_seo_context,
site_url, site_url,
website_schema, website_schema,
software_application_schema, software_application_schema,
project_devlog_schema,
list_page_seo, list_page_seo,
next_page_url, next_page_url,
) )
@ -206,40 +209,6 @@ async def project_detail(request: Request, project_slug: str, before: str = None
user["uid"], resolve_object_url("project", project["uid"]) user["uid"], resolve_object_url("project", project["uid"])
) )
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=first_image_url(project, detail["attachments"]),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
{
"name": project.get("title", "Project"),
"url": f"/projects/{project['slug'] or project['uid']}",
},
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
parent = get_fork_parent(project["uid"])
forked_from = (
{
"uid": parent["uid"],
"slug": parent.get("slug") or parent["uid"],
"title": parent.get("title") or "project",
}
if parent
else None
)
devlog_posts, devlog_next_cursor = get_project_devlog( devlog_posts, devlog_next_cursor = get_project_devlog(
project["uid"], before=before, viewer=user project["uid"], before=before, viewer=user
) )
@ -257,6 +226,67 @@ async def project_detail(request: Request, project_slug: str, before: str = None
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []}) item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid) item["poll"] = polls_map.get(uid)
devlog_count = count_project_devlog(project["uid"])
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
og_image = first_image_url(project, detail["attachments"])
comment_total = get_table("comments").count(
target_type="project", target_uid=project["uid"], deleted_at=None
)
platforms = [
p.strip() for p in (project.get("platforms") or "").split(",") if p.strip()
]
keyword_parts = [
project.get("title", ""),
project.get("project_type", "").replace("_", " "),
*platforms,
"devlog",
"developer project",
]
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=og_image,
keywords=", ".join(part for part in keyword_parts if part),
next_url=next_page_url(request, devlog_next_cursor),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
{
"name": project.get("title", "Project"),
"url": f"/projects/{project['slug'] or project['uid']}",
},
],
schemas=[
website_schema(base),
software_application_schema(
{**project, "author_name": (detail.get("author") or {}).get("username", "Unknown")},
base,
image_url=absolute_url(base, og_image),
star_count=detail.get("star_count", 0),
comment_count=comment_total,
),
project_devlog_schema(project, devlog_posts, base),
],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
parent = get_fork_parent(project["uid"])
forked_from = (
{
"uid": parent["uid"],
"slug": parent.get("slug") or parent["uid"],
"title": parent.get("title") or "project",
}
if parent
else None
)
return respond( return respond(
request, request,
@ -279,8 +309,10 @@ async def project_detail(request: Request, project_slug: str, before: str = None
"forked_from": forked_from, "forked_from": forked_from,
"fork_count": count_forks(project["uid"]), "fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]), "file_count": count_files(project["uid"]),
"comment_count": comment_total,
"devlog_posts": devlog_posts, "devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor, "devlog_next_cursor": devlog_next_cursor,
"devlog_count": devlog_count,
}, },
), ),
model=ProjectDetailOut, model=ProjectDetailOut,

View File

@ -172,8 +172,10 @@ class ProjectDetailOut(_Out):
forked_from: Optional[dict] = None forked_from: Optional[dict] = None
fork_count: int = 0 fork_count: int = 0
file_count: int = 0 file_count: int = 0
comment_count: int = 0
devlog_posts: list[FeedItemOut] = [] devlog_posts: list[FeedItemOut] = []
devlog_next_cursor: Optional[str] = None devlog_next_cursor: Optional[str] = None
devlog_count: int = 0
class GistsOut(_Out): class GistsOut(_Out):

View File

@ -8,7 +8,6 @@ from urllib.parse import urlencode
from xml.etree.ElementTree import Element, tostring from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom from xml.dom import minidom
from devplacepy.config import SITE_URL from devplacepy.config import SITE_URL
from devplacepy.utils import strip_html
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -139,19 +138,99 @@ def profile_page_schema(profile_user, post_count, base_url):
} }
def software_application_schema(project, base_url): PROJECT_SCHEMA_TYPES = {
return { "game": "VideoGame",
"@type": "SoftwareApplication", "game_asset": "CreativeWork",
"software": "SoftwareApplication",
"mobile_app": "SoftwareApplication",
"website": "WebApplication",
}
def project_schema_type(project_type):
return PROJECT_SCHEMA_TYPES.get((project_type or "").lower(), "SoftwareApplication")
def software_application_schema(
project, base_url, image_url="", star_count=0, comment_count=0
):
schema_type = project_schema_type(project.get("project_type"))
platforms = [p.strip() for p in (project.get("platforms") or "").split(",") if p.strip()]
author_name = project.get("author_name", "Unknown")
schema = {
"@type": schema_type,
"name": project.get("title", "Untitled"), "name": project.get("title", "Untitled"),
"description": truncate(plain_markdown(project.get("description", "")), 300), "description": truncate(plain_markdown(project.get("description", "")), 300),
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}", "url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
"applicationCategory": "DeveloperApplication", "operatingSystem": project.get("platforms") or "Cross-platform",
"operatingSystem": project.get("platforms", "Cross-platform"), "author": {"@type": "Person", "name": author_name},
"author": {"@type": "Person", "name": project.get("author_name", "Unknown")}, "creator": {"@type": "Person", "name": author_name},
"datePublished": project.get("created_at", ""), "datePublished": project.get("created_at", ""),
"dateModified": project.get("updated_at") or project.get("created_at", ""), "dateModified": project.get("updated_at") or project.get("created_at", ""),
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
} }
if schema_type in ("SoftwareApplication", "WebApplication"):
schema["applicationCategory"] = "DeveloperApplication"
if schema_type == "WebApplication":
schema["browserRequirements"] = "Requires JavaScript"
if schema_type == "VideoGame" and platforms:
schema["gamePlatform"] = platforms
if project.get("release_date"):
schema["releasedEvent"] = {
"@type": "PublicationEvent",
"startDate": project["release_date"],
}
keywords = [project.get("project_type", "").replace("_", " ").strip(), *platforms]
keywords = [k for k in keywords if k]
if keywords:
schema["keywords"] = ", ".join(keywords)
if image_url:
schema["image"] = image_url
if star_count > 0:
schema["aggregateRating"] = {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": str(star_count),
"bestRating": "5",
"worstRating": "1",
}
if comment_count > 0:
schema["interactionStatistic"] = {
"@type": "InteractionCounter",
"interactionType": "https://schema.org/CommentAction",
"userInteractionCount": comment_count,
}
return schema
def project_devlog_schema(project, devlog_posts, base_url):
if not devlog_posts:
return None
project_url = f"{base_url}/projects/{project.get('slug') or project['uid']}"
entries = []
for item in devlog_posts:
post = item.get("post") or {}
author = item.get("author") or {}
entry = {
"@type": "BlogPosting",
"headline": post.get("title") or truncate(plain_markdown(post.get("content", "")), 80) or "Untitled update",
"url": f"{base_url}/posts/{post.get('slug') or post.get('uid', '')}",
"articleBody": truncate(plain_markdown(post.get("content", "")), 300),
"datePublished": post.get("created_at", ""),
"dateModified": post.get("updated_at") or post.get("created_at", ""),
"author": {"@type": "Person", "name": author.get("username") or "Unknown"},
}
if item.get("comment_count"):
entry["commentCount"] = item["comment_count"]
entries.append(entry)
return {
"@type": "Blog",
"@id": f"{project_url}#devlog",
"name": f"{project.get('title', 'Project')} devlog",
"url": f"{project_url}#devlog",
"about": project.get("title", "Project"),
"blogPost": entries,
}
def web_application_schema(name, description, path, base_url, category="DeveloperApplication"): def web_application_schema(name, description, path, base_url, category="DeveloperApplication"):
@ -461,6 +540,12 @@ def _build_sitemap(base_url):
) )
if "projects" in db.tables: if "projects" in db.tables:
latest_devlog = {}
if "posts" in db.tables:
for row in db.query(
"SELECT project_uid, MAX(created_at) AS latest FROM posts WHERE deleted_at IS NULL AND project_uid IS NOT NULL GROUP BY project_uid"
):
latest_devlog[row["project_uid"]] = row["latest"]
projects = _collect( projects = _collect(
get_table("projects"), get_table("projects"),
SITEMAP_URL_LIMIT, SITEMAP_URL_LIMIT,
@ -470,10 +555,21 @@ def _build_sitemap(base_url):
for p in projects: for p in projects:
if p.get("is_private"): if p.get("is_private"):
continue continue
lastmod = max(
filter(
None,
(
p.get("created_at", ""),
p.get("updated_at") or "",
latest_devlog.get(p["uid"], ""),
),
),
default="",
)
urlset.append( urlset.append(
url_element( url_element(
f"{base_url}/projects/{p.get('slug') or p['uid']}", f"{base_url}/projects/{p.get('slug') or p['uid']}",
lastmod=p.get("created_at", ""), lastmod=lastmod,
changefreq="weekly", changefreq="weekly",
priority="0.6", priority="0.6",
) )

View File

@ -308,10 +308,60 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.project-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
margin-bottom: 1rem;
padding: var(--space-md) var(--space-lg);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.8125rem;
color: var(--text-muted);
}
.project-stat a {
color: var(--text-muted);
}
.project-stat a:hover {
color: var(--accent);
}
.project-stat-value {
font-weight: 700;
color: var(--text-primary);
}
.project-about {
margin-bottom: 1.5rem;
}
.project-devlog { .project-devlog {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.project-devlog-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: 0.75rem;
}
.project-devlog-header .project-section-label {
margin-bottom: 0;
}
.project-devlog-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.project-devlog-post-btn {
margin-left: auto;
}
.project-section-label { .project-section-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;

View File

@ -65,6 +65,7 @@ Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup: Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
- `_post_composer_form.html` - the create-post form (topic selector, content/title, project select, attachments, poll builder, footer). Locals: `_composer_topic` (preselected topic, default `random`), `_composer_project` (preselected project uid or `""`). Wrapped in the `modal()` macro by `feed.html` (Create New Post) and `project_detail.html` (owner-only Post an update, preset to `devlog` + the project). Never fork a second copy of this form.
- `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`. - `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`.
- `_star_vote.html` - project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) - do not put a literal star in markup. - `_star_vote.html` - project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) - do not put a literal star in markup.
- `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`. - `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.

View File

@ -0,0 +1,49 @@
{# Shared create-post form. Locals: _composer_topic (preselected topic), _composer_project (preselected project uid or ""). #}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
{% set _topics = TOPICS %}{% set _selected = _composer_topic or 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="post-title">Title (optional)</label>
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
<small class="hint-text"><span id="post-title-count">0/500</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="project_uid">Link to Project (Optional)</label>
<select id="project_uid" name="project_uid">
<option value="">No project</option>
{% for p in get_user_projects(user['uid']) %}
<option value="{{ p['uid'] }}" {% if _composer_project and p['uid'] == _composer_project %}selected{% endif %}>{{ p['title'] }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field auth-field-gap">
<label>Attach files (images, video, audio, documents)</label>
{% include "_attachment_form.html" %}
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>

View File

@ -160,54 +160,7 @@
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post" aria-label="Create New Post">+</a> <a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post" aria-label="Create New Post">+</a>
{% call modal('create-post-modal', 'Create New Post') %} {% call modal('create-post-modal', 'Create New Post') %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data"> {% set _composer_topic = 'random' %}{% set _composer_project = '' %}{% include "_post_composer_form.html" %}
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="post-title">Title (optional)</label>
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
<small class="hint-text"><span id="post-title-count">0/500</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="project_uid">Link to Project (Optional)</label>
<select id="project_uid" name="project_uid">
<option value="">No project</option>
{% for p in get_user_projects(user['uid']) %}
<option value="{{ p['uid'] }}">{{ p['title'] }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field auth-field-gap">
<label>Attach files (images, video, audio, documents)</label>
{% include "_attachment_form.html" %}
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>
{% endcall %} {% endcall %}
{% else %} {% else %}
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a> <a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>

View File

@ -3,6 +3,7 @@
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}"> <link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}"> <link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="project-detail-page"> <div class="project-detail-page">
@ -31,6 +32,9 @@
{% if project.get('demo_date') %} {% if project.get('demo_date') %}
<span>&#x1F3AD; Demo: {{ format_date(project['demo_date']) }}</span> <span>&#x1F3AD; Demo: {{ format_date(project['demo_date']) }}</span>
{% endif %} {% endif %}
{% if project.get('created_at') %}
<span>&#x1F331; Started {{ dt_ago(project['created_at']) }}</span>
{% endif %}
</div> </div>
{% if forked_from %} {% if forked_from %}
@ -47,10 +51,21 @@
</div> </div>
</div> </div>
<div class="project-stats">
<span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span>
<span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="/projects/{{ project['slug'] or project['uid'] }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span>
<span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
</div>
{% if maturity_hidden(maturity, user) %} {% if maturity_hidden(maturity, user) %}
{% set _level = maturity %}{% include "_maturity_gate.html" %} {% set _level = maturity %}{% include "_maturity_gate.html" %}
{% else %} {% else %}
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div> <section class="project-about">
<h2 class="project-section-label">About</h2>
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div>
</section>
{% endif %} {% endif %}
{% if attachments %} {% if attachments %}
@ -59,7 +74,7 @@
{% if platforms %} {% if platforms %}
<div class="project-platforms"> <div class="project-platforms">
<h4 class="project-section-label">Platforms</h4> <h2 class="project-section-label">Platforms</h2>
<div class="project-card-platforms"> <div class="project-card-platforms">
{% for plat in platforms %} {% for plat in platforms %}
<span class="platform-tag">{{ plat.strip() }}</span> <span class="platform-tag">{{ plat.strip() }}</span>
@ -113,19 +128,29 @@
</div> </div>
</article> </article>
<section class="project-devlog"> <section class="project-devlog" id="devlog">
<h3 class="project-section-label">Devlog</h3> <div class="project-devlog-header">
<h2 class="project-section-label">Devlog</h2>
<span class="project-devlog-count">{{ devlog_count }} update{{ '' if devlog_count == 1 else 's' }}</span>
{% if is_owner %}
<button type="button" class="btn btn-primary btn-sm project-devlog-post-btn" data-modal="create-post-modal"><span class="icon">&#x270D;&#xFE0F;</span> Post update</button>
{% endif %}
</div>
{% if devlog_posts %} {% if devlog_posts %}
{% for item in devlog_posts %} {% for item in devlog_posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %} {% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% endfor %} {% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %} {% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %} {% else %}
<p class="empty-state">No devlog posts yet.</p> <p class="empty-state">No devlog posts yet.{% if is_owner %} Share your first update to give this project a public build log.{% endif %}</p>
{% endif %} {% endif %}
</section> </section>
{% if is_owner %} {% if is_owner %}
{% call modal('create-post-modal', 'Post an update') %}
{% set _composer_topic = 'devlog' %}{% set _composer_project = project['uid'] %}{% include "_post_composer_form.html" %}
{% endcall %}
{% call modal('edit-project-modal', 'Edit Project') %} {% call modal('edit-project-modal', 'Edit Project') %}
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}"> <form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
<div class="auth-field auth-field-gap"> <div class="auth-field auth-field-gap">
@ -193,9 +218,11 @@
{% endcall %} {% endcall %}
{% endif %} {% endif %}
<div id="comments">
{% with target_uid=project['uid'], target_type="project" %} {% with target_uid=project['uid'], target_type="project" %}
{% include "_comment_section.html" %} {% include "_comment_section.html" %}
{% endwith %} {% endwith %}
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -207,6 +234,3 @@ if (actions) {
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -271,3 +271,70 @@ def test_devlog_works_for_guest_visitor(app_server):
assert "devlog_posts" in body assert "devlog_posts" in body
assert body["devlog_posts"] == [] 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"

View File

@ -85,7 +85,7 @@ def test_devlog_empty_state_on_project_page(alice):
devlog_section = page.locator(".project-devlog") devlog_section = page.locator(".project-devlog")
expect(devlog_section).to_be_visible() 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() 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}" 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

View File

@ -49,6 +49,77 @@ def test_news_article_publisher_is_organization():
assert schema["publisher"]["@type"] == "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): def test_site_url_precedence(monkeypatch):
import devplacepy.database as database import devplacepy.database as database