iUpdate
This commit is contained in:
parent
9e836ea152
commit
8d4a82965d
@ -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"])
|
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]:
|
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()
|
uid = generate_uid()
|
||||||
slug = make_combined_slug(slug_source, 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)
|
item = resolve_by_slug(table, slug)
|
||||||
if not is_owner(item, user):
|
if not is_owner(item, user):
|
||||||
return RedirectResponse(url=redirect_fail, status_code=302)
|
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"])
|
table.update({"uid": item["uid"], **update_fields}, ["uid"])
|
||||||
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
|
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)
|
return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from devplacepy.attachments import get_attachments_batch
|
|||||||
from devplacepy.content import enrich_items
|
from devplacepy.content import enrich_items
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -59,6 +59,7 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
|
|||||||
title="Feed",
|
title="Feed",
|
||||||
description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
|
description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
|
||||||
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
|
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
|
||||||
|
next_url=next_page_url(request, next_cursor),
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(request, "feed.html", {
|
return templates.TemplateResponse(request, "feed.html", {
|
||||||
**seo_ctx,
|
**seo_ctx,
|
||||||
|
|||||||
@ -4,10 +4,10 @@ from fastapi import APIRouter, Request, Form
|
|||||||
from devplacepy.models import GistForm, GistEditForm
|
from devplacepy.models import GistForm, GistEditForm
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate
|
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.templating import templates
|
||||||
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -53,6 +53,7 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
|
|||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Gists", "url": "/gists"},
|
{"name": "Gists", "url": "/gists"},
|
||||||
],
|
],
|
||||||
|
next_url=next_page_url(request, next_cursor),
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(request, "gists.html", {
|
return templates.TemplateResponse(request, "gists.html", {
|
||||||
**seo_ctx,
|
**seo_ctx,
|
||||||
@ -74,6 +75,9 @@ async def gist_detail(request: Request, gist_slug: str):
|
|||||||
if not detail:
|
if not detail:
|
||||||
raise not_found("Gist not found")
|
raise not_found("Gist not found")
|
||||||
gist = detail["item"]
|
gist = detail["item"]
|
||||||
|
redirect = canonical_redirect("gists", gist, gist_slug)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
|
||||||
base = site_url(request)
|
base = site_url(request)
|
||||||
seo_ctx = base_seo_context(
|
seo_ctx = base_seo_context(
|
||||||
@ -81,6 +85,7 @@ async def gist_detail(request: Request, gist_slug: str):
|
|||||||
title=gist.get("title", "Gist"),
|
title=gist.get("title", "Gist"),
|
||||||
description=gist.get("description", "")[:160],
|
description=gist.get("description", "")[:160],
|
||||||
og_type="article",
|
og_type="article",
|
||||||
|
og_image=first_image_url(gist, detail["attachments"]),
|
||||||
breadcrumbs=[
|
breadcrumbs=[
|
||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Gists", "url": "/gists"},
|
{"name": "Gists", "url": "/gists"},
|
||||||
|
|||||||
@ -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.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user, time_ago, not_found
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -45,6 +46,7 @@ async def news_page(request: Request, before: str = None):
|
|||||||
title="Developer News",
|
title="Developer News",
|
||||||
description="Curated developer news and industry signals. Stay ahead with hand-picked articles.",
|
description="Curated developer news and industry signals. Stay ahead with hand-picked articles.",
|
||||||
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}],
|
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}],
|
||||||
|
next_url=next_page_url(request, next_cursor),
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(request, "news.html", {
|
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)
|
article = resolve_by_slug(news_table, news_slug)
|
||||||
if not article:
|
if not article:
|
||||||
raise not_found("News article not found")
|
raise not_found("News article not found")
|
||||||
|
redirect = canonical_redirect("news", article, news_slug)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
|
||||||
image_url = ""
|
image_url = ""
|
||||||
if "news_images" in db.tables:
|
if "news_images" in db.tables:
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from devplacepy.constants import TOPICS
|
|||||||
from devplacepy.database import db
|
from devplacepy.database import db
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST
|
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.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
|
||||||
from devplacepy.attachments import save_inline_image
|
from devplacepy.attachments import save_inline_image
|
||||||
from devplacepy.models import PostForm, PostEditForm
|
from devplacepy.models import PostForm, PostEditForm
|
||||||
@ -49,6 +49,9 @@ async def view_post(request: Request, post_slug: str):
|
|||||||
if not detail:
|
if not detail:
|
||||||
raise not_found("Post not found")
|
raise not_found("Post not found")
|
||||||
post = detail["item"]
|
post = detail["item"]
|
||||||
|
redirect = canonical_redirect("posts", post, post_slug)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
author = detail["author"]
|
author = detail["author"]
|
||||||
top_level = detail["comments"]
|
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']}"},
|
{"name": post.get("title") or "Post", "url": f"/posts/{post['slug'] or post['uid']}"},
|
||||||
],
|
],
|
||||||
og_type="article",
|
og_type="article",
|
||||||
|
og_image=first_image_url(post, detail["attachments"]),
|
||||||
schemas=[
|
schemas=[
|
||||||
website_schema(base),
|
website_schema(base),
|
||||||
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
|
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
|
||||||
|
|||||||
@ -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.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.content import enrich_items
|
||||||
from devplacepy.templating import templates
|
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
|
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -35,7 +36,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
|||||||
users = get_table("users")
|
users = get_table("users")
|
||||||
profile_user = users.find_one(username=username)
|
profile_user = users.find_one(username=username)
|
||||||
if not profile_user:
|
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"])
|
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||||
rank = get_user_rank(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,
|
description=desc,
|
||||||
robots=robots,
|
robots=robots,
|
||||||
og_type="profile",
|
og_type="profile",
|
||||||
|
og_image=avatar_url("multiavatar", profile_user["username"], 256),
|
||||||
breadcrumbs=[
|
breadcrumbs=[
|
||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"},
|
{"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"},
|
||||||
|
|||||||
@ -5,10 +5,10 @@ from fastapi import APIRouter, Request, Form
|
|||||||
from devplacepy.models import ProjectForm
|
from devplacepy.models import ProjectForm
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
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.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.templating import templates
|
||||||
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -66,6 +66,7 @@ async def projects_page(
|
|||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Projects", "url": "/projects"},
|
{"name": "Projects", "url": "/projects"},
|
||||||
],
|
],
|
||||||
|
next_url=next_page_url(request, next_cursor),
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(request, "projects.html", {
|
return templates.TemplateResponse(request, "projects.html", {
|
||||||
**seo_ctx,
|
**seo_ctx,
|
||||||
@ -88,12 +89,16 @@ async def project_detail(request: Request, project_slug: str):
|
|||||||
if not detail:
|
if not detail:
|
||||||
raise not_found("Project not found")
|
raise not_found("Project not found")
|
||||||
project = detail["item"]
|
project = detail["item"]
|
||||||
|
redirect = canonical_redirect("projects", project, project_slug)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
|
||||||
base = site_url(request)
|
base = site_url(request)
|
||||||
seo_ctx = base_seo_context(
|
seo_ctx = base_seo_context(
|
||||||
request,
|
request,
|
||||||
title=project.get("title", "Project"),
|
title=project.get("title", "Project"),
|
||||||
description=project.get("description", "")[:160],
|
description=project.get("description", "")[:160],
|
||||||
|
og_image=first_image_url(project, detail["attachments"]),
|
||||||
breadcrumbs=[
|
breadcrumbs=[
|
||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Projects", "url": "/projects"},
|
{"name": "Projects", "url": "/projects"},
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
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
|
||||||
@ -9,6 +11,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
SITE_NAME = "DevPlace"
|
SITE_NAME = "DevPlace"
|
||||||
|
SITEMAP_URL_LIMIT = 5000
|
||||||
|
SITEMAP_TTL = 3600
|
||||||
|
_sitemap_cache = {}
|
||||||
|
|
||||||
|
|
||||||
def truncate(text, max_len=160):
|
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 ""
|
"url": f"{base_url}/profile/{author['username']}" if author else ""
|
||||||
},
|
},
|
||||||
"datePublished": post.get("created_at", ""),
|
"datePublished": post.get("created_at", ""),
|
||||||
|
"dateModified": post.get("updated_at") or post.get("created_at", ""),
|
||||||
"interactionStatistic": [
|
"interactionStatistic": [
|
||||||
{"@type": "InteractionCounter", "interactionType": "https://schema.org/LikeAction", "userInteractionCount": star_count},
|
{"@type": "InteractionCounter", "interactionType": "https://schema.org/LikeAction", "userInteractionCount": star_count},
|
||||||
{"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_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")
|
"name": project.get("author_name", "Unknown")
|
||||||
},
|
},
|
||||||
"datePublished": project.get("created_at", ""),
|
"datePublished": project.get("created_at", ""),
|
||||||
|
"dateModified": project.get("updated_at") or project.get("created_at", ""),
|
||||||
"offers": {
|
"offers": {
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
"price": "0",
|
"price": "0",
|
||||||
@ -131,6 +138,7 @@ def news_article_schema(article, base_url, image_url=""):
|
|||||||
"description": truncate(strip_html(article.get("description", "") or ""), 200),
|
"description": truncate(strip_html(article.get("description", "") or ""), 200),
|
||||||
"url": url,
|
"url": url,
|
||||||
"datePublished": article.get("synced_at", "") or article.get("created_at", ""),
|
"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},
|
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
|
||||||
"author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME},
|
"author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME},
|
||||||
"publisher": organization_schema(base_url),
|
"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']}",
|
"url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}",
|
||||||
"programmingLanguage": gist.get("language", "") or "text",
|
"programmingLanguage": gist.get("language", "") or "text",
|
||||||
"dateCreated": gist.get("created_at", ""),
|
"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"
|
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)
|
base = site_url(request)
|
||||||
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
|
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
|
||||||
canonical = f"{base}{request.url.path}"
|
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"):
|
if page and page not in ("", "1"):
|
||||||
canonical = f"{canonical}?page={page}"
|
canonical = f"{canonical}?page={page}"
|
||||||
clean_description = truncate(strip_html(description), 160)
|
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 {
|
return {
|
||||||
"page_title": page_title,
|
"page_title": page_title,
|
||||||
"meta_description": clean_description,
|
"meta_description": clean_description,
|
||||||
@ -202,11 +220,21 @@ def base_seo_context(request, title="", description="", robots="index,follow", o
|
|||||||
"og_image": og_img,
|
"og_image": og_img,
|
||||||
"og_type": og_type,
|
"og_type": og_type,
|
||||||
"breadcrumbs": breadcrumbs or [],
|
"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)
|
base = site_url(request)
|
||||||
return base_seo_context(
|
return base_seo_context(
|
||||||
request,
|
request,
|
||||||
@ -214,10 +242,28 @@ def list_page_seo(request, title="", description="", breadcrumbs=None):
|
|||||||
description=description,
|
description=description,
|
||||||
breadcrumbs=breadcrumbs,
|
breadcrumbs=breadcrumbs,
|
||||||
schemas=[website_schema(base)],
|
schemas=[website_schema(base)],
|
||||||
|
prev_url=prev_url,
|
||||||
|
next_url=next_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_sitemap(base_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
|
from devplacepy.database import get_table, db
|
||||||
|
|
||||||
def url_element(loc, lastmod=None, changefreq=None, priority=None):
|
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"))
|
urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7"))
|
||||||
|
|
||||||
if "posts" in db.tables:
|
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:
|
for p in posts:
|
||||||
urlset.append(url_element(
|
urlset.append(url_element(
|
||||||
f"{base_url}/posts/{p.get('slug') or p['uid']}",
|
f"{base_url}/posts/{p.get('slug') or p['uid']}",
|
||||||
@ -260,7 +306,7 @@ def make_sitemap(base_url):
|
|||||||
))
|
))
|
||||||
|
|
||||||
if "projects" in db.tables:
|
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:
|
for p in projects:
|
||||||
urlset.append(url_element(
|
urlset.append(url_element(
|
||||||
f"{base_url}/projects/{p.get('slug') or p['uid']}",
|
f"{base_url}/projects/{p.get('slug') or p['uid']}",
|
||||||
@ -270,7 +316,7 @@ def make_sitemap(base_url):
|
|||||||
))
|
))
|
||||||
|
|
||||||
if "gists" in db.tables:
|
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:
|
for g in gists:
|
||||||
urlset.append(url_element(
|
urlset.append(url_element(
|
||||||
f"{base_url}/gists/{g.get('slug') or g['uid']}",
|
f"{base_url}/gists/{g.get('slug') or g['uid']}",
|
||||||
@ -280,7 +326,7 @@ def make_sitemap(base_url):
|
|||||||
))
|
))
|
||||||
|
|
||||||
if "news" in db.tables:
|
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:
|
for a in articles:
|
||||||
urlset.append(url_element(
|
urlset.append(url_element(
|
||||||
f"{base_url}/news/{a.get('slug') or a['uid']}",
|
f"{base_url}/news/{a.get('slug') or a['uid']}",
|
||||||
@ -294,7 +340,7 @@ def make_sitemap(base_url):
|
|||||||
if "posts" in db.tables:
|
if "posts" in db.tables:
|
||||||
for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"):
|
for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"):
|
||||||
post_counts[row["user_uid"]] = row["c"]
|
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:
|
for u in users:
|
||||||
if post_counts.get(u["uid"], 0) < 2:
|
if post_counts.get(u["uid"], 0) < 2:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -207,8 +207,42 @@ class NewsService(BaseService):
|
|||||||
content = strip_html(article.get("content", "") or "")[:1500]
|
content = strip_html(article.get("content", "") or "")[:1500]
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
"Rate this article's relevance to software developers on a scale of 1-10. "
|
"You are an expert content strategist and editor for DevPlace, a "
|
||||||
"Return only a single integer between 1 and 10, nothing else.\n\n"
|
"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"Title: {title}\n"
|
||||||
f"Description: {description}\n"
|
f"Description: {description}\n"
|
||||||
f"Content: {content}"
|
f"Content: {content}"
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
|
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
|
||||||
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
|
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
|
||||||
<link rel="canonical" href="{{ canonical_url or request.url }}">
|
<link rel="canonical" href="{{ canonical_url or request.url }}">
|
||||||
|
{% if prev_url %}<link rel="prev" href="{{ prev_url }}">{% endif %}
|
||||||
|
{% if next_url %}<link rel="next" href="{{ next_url }}">{% endif %}
|
||||||
<meta name="robots" content="{{ meta_robots or 'index,follow' }}">
|
<meta name="robots" content="{{ meta_robots or 'index,follow' }}">
|
||||||
|
|
||||||
<meta property="og:title" content="{{ og_title or page_title or 'DevPlace' }}">
|
<meta property="og:title" content="{{ og_title or page_title or 'DevPlace' }}">
|
||||||
|
|||||||
@ -83,7 +83,7 @@
|
|||||||
<article class="news-card">
|
<article class="news-card">
|
||||||
{% if item.image_url %}
|
{% if item.image_url %}
|
||||||
<div class="news-card-image">
|
<div class="news-card-image">
|
||||||
<img src="{{ item.image_url }}" alt="" loading="lazy" class="image-fallback">
|
<img src="{{ item.image_url }}" alt="{{ item.title }}" loading="lazy" class="image-fallback">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="news-card-body">
|
<div class="news-card-body">
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
<article class="news-card">
|
<article class="news-card">
|
||||||
{% if item.image_url %}
|
{% if item.image_url %}
|
||||||
<div class="news-card-image">
|
<div class="news-card-image">
|
||||||
<img src="{{ item.image_url }}" alt="" loading="lazy" class="image-fallback">
|
<img src="{{ item.image_url }}" alt="{{ item.article['title'] }}" loading="lazy" class="image-fallback">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="news-card-body">
|
<div class="news-card-body">
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<article class="news-detail-card">
|
<article class="news-detail-card">
|
||||||
{% if image_url %}
|
{% if image_url %}
|
||||||
<div class="news-detail-image">
|
<div class="news-detail-image">
|
||||||
<img src="{{ image_url }}" alt="" loading="lazy" class="image-fallback">
|
<img src="{{ image_url }}" alt="{{ article['title'] }}" loading="lazy" class="image-fallback">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@ -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()))
|
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
|
||||||
assert "NewsArticle" in text
|
assert "NewsArticle" in text
|
||||||
assert page.locator('meta[property="og:type"]').get_attribute("content") == "article"
|
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user