diff --git a/devplacepy/content.py b/devplacepy/content.py index c300911..bc520b6 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -22,6 +22,23 @@ def is_owner(item: dict | None, user: dict | None) -> bool: return bool(item and user and item["user_uid"] == user["uid"]) +def canonical_redirect(area: str, item: dict, requested: str) -> RedirectResponse | None: + canonical = item.get("slug") or item["uid"] + if requested == canonical: + return None + return RedirectResponse(url=f"/{area}/{canonical}", status_code=301) + + +def first_image_url(item: dict, attachments: list | None) -> str | None: + inline = item.get("image") + if inline: + return f"/static/uploads/{inline}" + for attachment in attachments or []: + if attachment.get("is_image"): + return attachment["url"] + return None + + def create_content_item(table_name: str, target_type: str, user: dict, fields: dict, slug_source: str, xp: int, badge: str, mention_text: str, attachment_uids: list | None) -> tuple[str, str]: uid = generate_uid() slug = make_combined_slug(slug_source, uid) @@ -65,6 +82,7 @@ def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dic item = resolve_by_slug(table, slug) if not is_owner(item, user): return RedirectResponse(url=redirect_fail, status_code=302) + update_fields = {**update_fields, "updated_at": datetime.now(timezone.utc).isoformat()} table.update({"uid": item["uid"], **update_fields}, ["uid"]) logger.info(f"{table_name} {item['uid']} edited by {user['username']}") return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302) diff --git a/devplacepy/routers/feed.py b/devplacepy/routers/feed.py index 9ef4e0f..29e9602 100644 --- a/devplacepy/routers/feed.py +++ b/devplacepy/routers/feed.py @@ -6,7 +6,7 @@ from devplacepy.attachments import get_attachments_batch from devplacepy.content import enrich_items from devplacepy.templating import templates from devplacepy.utils import get_current_user -from devplacepy.seo import list_page_seo +from devplacepy.seo import list_page_seo, next_page_url logger = logging.getLogger(__name__) router = APIRouter() @@ -59,6 +59,7 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor title="Feed", description="Discover the latest developer discussions, projects, and community activity on DevPlace.", breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}], + next_url=next_page_url(request, next_cursor), ) return templates.TemplateResponse(request, "feed.html", { **seo_ctx, diff --git a/devplacepy/routers/gists.py b/devplacepy/routers/gists.py index 915749b..265f4da 100644 --- a/devplacepy/routers/gists.py +++ b/devplacepy/routers/gists.py @@ -4,10 +4,10 @@ from fastapi import APIRouter, Request, Form from devplacepy.models import GistForm, GistEditForm from fastapi.responses import HTMLResponse, RedirectResponse from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate -from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context +from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context, canonical_redirect, first_image_url from devplacepy.templating import templates from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST -from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo +from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo, next_page_url logger = logging.getLogger(__name__) router = APIRouter() @@ -53,6 +53,7 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non {"name": "Home", "url": "/feed"}, {"name": "Gists", "url": "/gists"}, ], + next_url=next_page_url(request, next_cursor), ) return templates.TemplateResponse(request, "gists.html", { **seo_ctx, @@ -74,6 +75,9 @@ async def gist_detail(request: Request, gist_slug: str): if not detail: raise not_found("Gist not found") gist = detail["item"] + redirect = canonical_redirect("gists", gist, gist_slug) + if redirect: + return redirect base = site_url(request) seo_ctx = base_seo_context( @@ -81,6 +85,7 @@ async def gist_detail(request: Request, gist_slug: str): title=gist.get("title", "Gist"), description=gist.get("description", "")[:160], og_type="article", + og_image=first_image_url(gist, detail["attachments"]), breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Gists", "url": "/gists"}, diff --git a/devplacepy/routers/news.py b/devplacepy/routers/news.py index 6ad90e5..d9121e4 100644 --- a/devplacepy/routers/news.py +++ b/devplacepy/routers/news.py @@ -5,7 +5,8 @@ from fastapi.responses import HTMLResponse from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate from devplacepy.templating import templates from devplacepy.utils import get_current_user, time_ago, not_found -from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo +from devplacepy.content import canonical_redirect +from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo, next_page_url logger = logging.getLogger(__name__) router = APIRouter() @@ -45,6 +46,7 @@ async def news_page(request: Request, before: str = None): title="Developer News", description="Curated developer news and industry signals. Stay ahead with hand-picked articles.", breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}], + next_url=next_page_url(request, next_cursor), ) return templates.TemplateResponse(request, "news.html", { @@ -63,6 +65,9 @@ async def news_detail_page(request: Request, news_slug: str): article = resolve_by_slug(news_table, news_slug) if not article: raise not_found("News article not found") + redirect = canonical_redirect("news", article, news_slug) + if redirect: + return redirect image_url = "" if "news_images" in db.tables: diff --git a/devplacepy/routers/posts.py b/devplacepy/routers/posts.py index 3cc9d71..9aede7e 100644 --- a/devplacepy/routers/posts.py +++ b/devplacepy/routers/posts.py @@ -6,7 +6,7 @@ from devplacepy.constants import TOPICS from devplacepy.database import db from devplacepy.templating import templates from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST -from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context +from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate from devplacepy.attachments import save_inline_image from devplacepy.models import PostForm, PostEditForm @@ -49,6 +49,9 @@ async def view_post(request: Request, post_slug: str): if not detail: raise not_found("Post not found") post = detail["item"] + redirect = canonical_redirect("posts", post, post_slug) + if redirect: + return redirect author = detail["author"] top_level = detail["comments"] @@ -69,6 +72,7 @@ async def view_post(request: Request, post_slug: str): {"name": post.get("title") or "Post", "url": f"/posts/{post['slug'] or post['uid']}"}, ], og_type="article", + og_image=first_image_url(post, detail["attachments"]), schemas=[ website_schema(base), discussion_forum_posting(post, author, comment_count, detail["star_count"], base), diff --git a/devplacepy/routers/profile.py b/devplacepy/routers/profile.py index 2951c55..c83b1ad 100644 --- a/devplacepy/routers/profile.py +++ b/devplacepy/routers/profile.py @@ -6,7 +6,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids from devplacepy.content import enrich_items from devplacepy.templating import templates -from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache +from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache, not_found +from devplacepy.avatar import avatar_url from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema logger = logging.getLogger(__name__) @@ -35,7 +36,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"): users = get_table("users") profile_user = users.find_one(username=username) if not profile_user: - return RedirectResponse(url="/feed", status_code=302) + raise not_found("Profile not found") profile_user["stars"] = get_user_stars(profile_user["uid"]) rank = get_user_rank(profile_user["uid"]) @@ -82,6 +83,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"): description=desc, robots=robots, og_type="profile", + og_image=avatar_url("multiavatar", profile_user["username"], 256), breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"}, diff --git a/devplacepy/routers/projects.py b/devplacepy/routers/projects.py index 24df8cc..8780d3e 100644 --- a/devplacepy/routers/projects.py +++ b/devplacepy/routers/projects.py @@ -5,10 +5,10 @@ from fastapi import APIRouter, Request, Form from devplacepy.models import ProjectForm from fastapi.responses import HTMLResponse, RedirectResponse from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate -from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context +from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url from devplacepy.templating import templates from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT -from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo +from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo, next_page_url logger = logging.getLogger(__name__) router = APIRouter() @@ -66,6 +66,7 @@ async def projects_page( {"name": "Home", "url": "/feed"}, {"name": "Projects", "url": "/projects"}, ], + next_url=next_page_url(request, next_cursor), ) return templates.TemplateResponse(request, "projects.html", { **seo_ctx, @@ -88,12 +89,16 @@ async def project_detail(request: Request, project_slug: str): if not detail: raise not_found("Project not found") project = detail["item"] + redirect = canonical_redirect("projects", project, project_slug) + if redirect: + return redirect base = site_url(request) seo_ctx = base_seo_context( request, title=project.get("title", "Project"), description=project.get("description", "")[:160], + og_image=first_image_url(project, detail["attachments"]), breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Projects", "url": "/projects"}, diff --git a/devplacepy/seo.py b/devplacepy/seo.py index 1fb24dc..92e8fe3 100644 --- a/devplacepy/seo.py +++ b/devplacepy/seo.py @@ -1,5 +1,7 @@ import json import logging +import time +from urllib.parse import urlencode from xml.etree.ElementTree import Element, tostring from xml.dom import minidom from devplacepy.config import SITE_URL @@ -9,6 +11,9 @@ logger = logging.getLogger(__name__) SITE_NAME = "DevPlace" +SITEMAP_URL_LIMIT = 5000 +SITEMAP_TTL = 3600 +_sitemap_cache = {} def truncate(text, max_len=160): @@ -70,6 +75,7 @@ def discussion_forum_posting(post, author, comment_count, star_count, base_url): "url": f"{base_url}/profile/{author['username']}" if author else "" }, "datePublished": post.get("created_at", ""), + "dateModified": post.get("updated_at") or post.get("created_at", ""), "interactionStatistic": [ {"@type": "InteractionCounter", "interactionType": "https://schema.org/LikeAction", "userInteractionCount": star_count}, {"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count} @@ -106,6 +112,7 @@ def software_application_schema(project, base_url): "name": project.get("author_name", "Unknown") }, "datePublished": project.get("created_at", ""), + "dateModified": project.get("updated_at") or project.get("created_at", ""), "offers": { "@type": "Offer", "price": "0", @@ -131,6 +138,7 @@ def news_article_schema(article, base_url, image_url=""): "description": truncate(strip_html(article.get("description", "") or ""), 200), "url": url, "datePublished": article.get("synced_at", "") or article.get("created_at", ""), + "dateModified": article.get("synced_at", "") or article.get("created_at", ""), "mainEntityOfPage": {"@type": "WebPage", "@id": url}, "author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME}, "publisher": organization_schema(base_url), @@ -148,6 +156,7 @@ def software_source_code_schema(gist, base_url): "url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}", "programmingLanguage": gist.get("language", "") or "text", "dateCreated": gist.get("created_at", ""), + "dateModified": gist.get("updated_at") or gist.get("created_at", ""), } @@ -183,7 +192,13 @@ def combine(schemas): DEFAULT_OG_IMAGE = "/static/og-default.png" -def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None): +def absolute_url(base, path): + if not path: + return "" + return path if path.startswith("http") else f"{base}{path}" + + +def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None, prev_url=None, next_url=None): base = site_url(request) page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME canonical = f"{base}{request.url.path}" @@ -191,7 +206,10 @@ def base_seo_context(request, title="", description="", robots="index,follow", o if page and page not in ("", "1"): canonical = f"{canonical}?page={page}" clean_description = truncate(strip_html(description), 160) - og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}" + og_img = absolute_url(base, og_image) or f"{base}{DEFAULT_OG_IMAGE}" + page_schemas = list(schemas or []) + if breadcrumbs: + page_schemas.append(breadcrumb_schema(breadcrumbs, base)) return { "page_title": page_title, "meta_description": clean_description, @@ -202,11 +220,21 @@ def base_seo_context(request, title="", description="", robots="index,follow", o "og_image": og_img, "og_type": og_type, "breadcrumbs": breadcrumbs or [], - "page_schema": combine(schemas or []), + "page_schema": combine(page_schemas), + "prev_url": absolute_url(base, prev_url), + "next_url": absolute_url(base, next_url), } -def list_page_seo(request, title="", description="", breadcrumbs=None): +def next_page_url(request, next_cursor): + if not next_cursor: + return None + params = dict(request.query_params) + params["before"] = next_cursor + return f"{request.url.path}?{urlencode(params)}" + + +def list_page_seo(request, title="", description="", breadcrumbs=None, prev_url=None, next_url=None): base = site_url(request) return base_seo_context( request, @@ -214,10 +242,28 @@ def list_page_seo(request, title="", description="", breadcrumbs=None): description=description, breadcrumbs=breadcrumbs, schemas=[website_schema(base)], + prev_url=prev_url, + next_url=next_url, ) def make_sitemap(base_url): + cached = _sitemap_cache.get(base_url) + if cached and time.time() - cached[0] < SITEMAP_TTL: + return cached[1] + xml = _build_sitemap(base_url) + _sitemap_cache[base_url] = (time.time(), xml) + return xml + + +def _collect(table, limit, label, **query): + rows = list(table.find(_limit=limit, **query)) + if len(rows) >= limit: + logger.warning("sitemap: %s truncated at %d entries", label, limit) + return rows + + +def _build_sitemap(base_url): from devplacepy.database import get_table, db def url_element(loc, lastmod=None, changefreq=None, priority=None): @@ -250,7 +296,7 @@ def make_sitemap(base_url): urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")) if "posts" in db.tables: - posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500)) + posts = _collect(get_table("posts"), SITEMAP_URL_LIMIT, "posts", order_by=["-created_at"]) for p in posts: urlset.append(url_element( f"{base_url}/posts/{p.get('slug') or p['uid']}", @@ -260,7 +306,7 @@ def make_sitemap(base_url): )) if "projects" in db.tables: - projects = list(get_table("projects").find(order_by=["-created_at"], _limit=1000)) + projects = _collect(get_table("projects"), SITEMAP_URL_LIMIT, "projects", order_by=["-created_at"]) for p in projects: urlset.append(url_element( f"{base_url}/projects/{p.get('slug') or p['uid']}", @@ -270,7 +316,7 @@ def make_sitemap(base_url): )) if "gists" in db.tables: - gists = list(get_table("gists").find(order_by=["-created_at"], _limit=500)) + gists = _collect(get_table("gists"), SITEMAP_URL_LIMIT, "gists", order_by=["-created_at"]) for g in gists: urlset.append(url_element( f"{base_url}/gists/{g.get('slug') or g['uid']}", @@ -280,7 +326,7 @@ def make_sitemap(base_url): )) if "news" in db.tables: - articles = list(get_table("news").find(status="published", order_by=["-synced_at"], _limit=500)) + articles = _collect(get_table("news"), SITEMAP_URL_LIMIT, "news", status="published", order_by=["-synced_at"]) for a in articles: urlset.append(url_element( f"{base_url}/news/{a.get('slug') or a['uid']}", @@ -294,7 +340,7 @@ def make_sitemap(base_url): if "posts" in db.tables: for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"): post_counts[row["user_uid"]] = row["c"] - users = list(get_table("users").find(order_by=["-created_at"], _limit=200)) + users = _collect(get_table("users"), SITEMAP_URL_LIMIT, "users", order_by=["-created_at"]) for u in users: if post_counts.get(u["uid"], 0) < 2: continue diff --git a/devplacepy/services/news.py b/devplacepy/services/news.py index 4d22b1a..2152b21 100644 --- a/devplacepy/services/news.py +++ b/devplacepy/services/news.py @@ -207,8 +207,42 @@ class NewsService(BaseService): content = strip_html(article.get("content", "") or "")[:1500] prompt = ( - "Rate this article's relevance to software developers on a scale of 1-10. " - "Return only a single integer between 1 and 10, nothing else.\n\n" + "You are an expert content strategist and editor for DevPlace, a " + "server-rendered social network for software developers. Decide how " + "strongly a given article should be published, expressed as a single " + "grade.\n\n" + "Site context:\n" + "- Audience: software developers, hobbyists and professionals, " + "interested in software development, AI agents, open-source tools, " + "systems programming, and self-hosting.\n" + "- Mission: deliver deep technical insight, critical analysis, and " + "practical value; build authority and reader trust; avoid low-effort, " + "rehashed, or clickbait content.\n" + "- Tone: technical, practical, nuanced, skeptical of hype; no " + "sensationalism or unverified claims.\n\n" + "Evaluate the article against these weighted criteria and weigh them " + "internally:\n" + "1. Relevance & alignment to the developer niche and audience needs " + "(20%).\n" + "2. Quality & accuracy: depth, originality, factual correctness, " + "sourcing, technical correctness, clear writing (25%).\n" + "3. Engagement & readability: strong title/intro/flow, useful " + "examples, appropriate length (15%).\n" + "4. Originality & uniqueness: new perspective, data, or analysis; not " + "duplicated generic web content (15%).\n" + "5. SEO & discoverability: keyword opportunity without stuffing, " + "shareable (10%).\n" + "6. Ethics, legality & risk: no misinformation, plagiarism, or " + "brand-safety issues; controversial topics handled with nuance and " + "evidence (10%).\n" + "7. Strategic fit for the site (5%).\n\n" + "Convert your overall judgment to a single integer grade from 1 to " + "10:\n" + "- 9-10: excellent, publish as-is.\n" + "- 7-8: good, worth publishing.\n" + "- 5-6: marginal, weak fit or quality.\n" + "- 1-4: reject, low quality or off-topic.\n\n" + "Return ONLY a single integer from 1 to 10, nothing else.\n\n" f"Title: {title}\n" f"Description: {description}\n" f"Content: {content}" diff --git a/devplacepy/templates/base.html b/devplacepy/templates/base.html index 7d05a6f..64dc71b 100644 --- a/devplacepy/templates/base.html +++ b/devplacepy/templates/base.html @@ -7,6 +7,8 @@ {% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %} + {% if prev_url %}{% endif %} + {% if next_url %}{% endif %} diff --git a/devplacepy/templates/landing.html b/devplacepy/templates/landing.html index 8f69ee0..124e2b2 100644 --- a/devplacepy/templates/landing.html +++ b/devplacepy/templates/landing.html @@ -83,7 +83,7 @@
{% if item.image_url %}
- + {{ item.title }}
{% endif %}
diff --git a/devplacepy/templates/news.html b/devplacepy/templates/news.html index 98c54e4..debd7eb 100644 --- a/devplacepy/templates/news.html +++ b/devplacepy/templates/news.html @@ -15,7 +15,7 @@
{% if item.image_url %}
- + {{ item.article['title'] }}
{% endif %}
diff --git a/devplacepy/templates/news_detail.html b/devplacepy/templates/news_detail.html index c817bf1..adeaed7 100644 --- a/devplacepy/templates/news_detail.html +++ b/devplacepy/templates/news_detail.html @@ -8,7 +8,7 @@
{% if image_url %}
- + {{ article['title'] }}
{% endif %} diff --git a/tests/test_seo.py b/tests/test_seo.py index 76bb1f7..ae6399e 100644 --- a/tests/test_seo.py +++ b/tests/test_seo.py @@ -213,3 +213,184 @@ def test_news_detail_has_newsarticle_schema(page, app_server): text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count())) assert "NewsArticle" in text assert page.locator('meta[property="og:type"]').get_attribute("content") == "article" + + +def _seed_owner(): + from datetime import datetime, timezone + from uuid import uuid4 + from devplacepy.database import get_table + uid = str(uuid4()) + get_table("users").insert({ + "uid": uid, + "username": f"seo_{uid[:8]}", + "email": f"{uid[:8]}@seo.test", + "password_hash": "x", + "role": "Member", + "is_active": True, + "created_at": datetime.now(timezone.utc).isoformat(), + }) + return uid + + +def _seed_post(image=None): + from datetime import datetime, timezone + from uuid import uuid4 + from devplacepy.database import get_table + from devplacepy.utils import make_combined_slug + owner = _seed_owner() + uid = str(uuid4()) + slug = make_combined_slug("SEO Detail Post", uid) + get_table("posts").insert({ + "uid": uid, "user_uid": owner, "slug": slug, + "title": "SEO Detail Post", "content": "Body text for the SEO detail post.", + "topic": "general", "project_uid": None, "image": image, + "stars": 0, "created_at": datetime.now(timezone.utc).isoformat(), + }) + return slug, uid + + +def _seed_gist(): + from datetime import datetime, timezone + from uuid import uuid4 + from devplacepy.database import get_table + from devplacepy.utils import make_combined_slug + owner = _seed_owner() + uid = str(uuid4()) + slug = make_combined_slug("SEO Detail Gist", uid) + get_table("gists").insert({ + "uid": uid, "user_uid": owner, "slug": slug, + "title": "SEO Detail Gist", "description": "Gist description for SEO tests.", + "source_code": "print('seo')", "language": "python", + "stars": 0, "created_at": datetime.now(timezone.utc).isoformat(), + }) + return slug, uid + + +def _seed_project(): + from datetime import datetime, timezone + from uuid import uuid4 + from devplacepy.database import get_table + from devplacepy.utils import make_combined_slug + owner = _seed_owner() + uid = str(uuid4()) + slug = make_combined_slug("SEO Detail Project", uid) + get_table("projects").insert({ + "uid": uid, "user_uid": owner, "slug": slug, + "title": "SEO Detail Project", "description": "Project description for SEO tests.", + "project_type": "software", "platforms": "Linux", "status": "Released", + "stars": 0, "created_at": datetime.now(timezone.utc).isoformat(), + }) + return slug, uid + + +def _seed_news_image(news_uid): + from devplacepy.database import get_table + get_table("news_images").insert({ + "news_uid": news_uid, + "url": "https://example.com/seo-news-image.jpg", + }) + + +def _seed_feed_posts(count): + from datetime import datetime, timezone, timedelta + from uuid import uuid4 + from devplacepy.database import get_table + from devplacepy.utils import make_combined_slug + owner = _seed_owner() + topic = f"seopag{owner[:8]}" + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + posts = get_table("posts") + for i in range(count): + uid = str(uuid4()) + posts.insert({ + "uid": uid, "user_uid": owner, "slug": make_combined_slug(f"seo pag {i}", uid), + "title": None, "content": f"seo pag post {i}", "topic": topic, "project_uid": None, + "image": None, "stars": 0, + "created_at": (base - timedelta(seconds=i)).isoformat(), + }) + return topic + + +def test_post_uid_redirects_to_canonical_slug(app_server): + slug, uid = _seed_post() + r = requests.get(f"{BASE_URL}/posts/{uid}", allow_redirects=False) + assert r.status_code == 301 + assert r.headers["location"].endswith(f"/posts/{slug}"), r.headers.get("location") + + +def test_post_slug_served_without_redirect(app_server): + slug, uid = _seed_post() + r = requests.get(f"{BASE_URL}/posts/{slug}", allow_redirects=False) + assert r.status_code == 200 + + +def test_gist_uid_redirects_to_canonical_slug(app_server): + slug, uid = _seed_gist() + r = requests.get(f"{BASE_URL}/gists/{uid}", allow_redirects=False) + assert r.status_code == 301 + assert r.headers["location"].endswith(f"/gists/{slug}"), r.headers.get("location") + + +def test_project_uid_redirects_to_canonical_slug(app_server): + slug, uid = _seed_project() + r = requests.get(f"{BASE_URL}/projects/{uid}", allow_redirects=False) + assert r.status_code == 301 + assert r.headers["location"].endswith(f"/projects/{slug}"), r.headers.get("location") + + +def test_news_uid_redirects_to_canonical_slug(app_server): + slug, uid = _seed_news() + r = requests.get(f"{BASE_URL}/news/{uid}", allow_redirects=False) + assert r.status_code == 301 + assert r.headers["location"].endswith(f"/news/{slug}"), r.headers.get("location") + + +def test_missing_profile_returns_404(app_server): + r = requests.get(f"{BASE_URL}/profile/no-such-user-xyz", allow_redirects=False) + assert r.status_code == 404 + + +def test_post_og_image_uses_content_image(page, app_server): + slug, _ = _seed_post(image="seo-content.png") + page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded") + og = page.locator('meta[property="og:image"]').get_attribute("content") + assert og.endswith("/static/uploads/seo-content.png"), og + + +def test_post_page_has_breadcrumb_schema(page, app_server): + slug, _ = _seed_post() + page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded") + scripts = page.locator('script[type="application/ld+json"]') + text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count())) + assert "BreadcrumbList" in text + + +def test_post_schema_has_date_modified(page, app_server): + slug, _ = _seed_post() + page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded") + scripts = page.locator('script[type="application/ld+json"]') + text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count())) + assert "dateModified" in text + + +def test_news_detail_image_has_alt_text(page, app_server): + slug, uid = _seed_news() + _seed_news_image(uid) + page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded") + alt = page.locator(".news-detail-image img").get_attribute("alt") + assert alt and alt.strip() + + +def test_feed_pagination_emits_rel_next(page, app_server): + topic = _seed_feed_posts(26) + page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded") + nxt = page.locator('link[rel="next"]') + assert nxt.count() == 1 + href = nxt.get_attribute("href") + assert "before=" in href and f"topic={topic}" in href, href + + +def test_post_detail_has_no_rel_next(page, app_server): + slug, _ = _seed_post() + page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded") + assert page.locator('link[rel="next"]').count() == 0