feat: add SEO foundation — meta tags, sitemap, robots.txt, JSON-LD (#813)
- Add `site_url` setting to config (default: https://alexanderwhitestone.com, override with SITE_URL env var) - Inject `site_url` as a Jinja2 global in templating.py so all templates can reference it without per-route boilerplate - Update base.html <title> to "Timmy AI Workshop | Lightning-Powered AI Jobs — Pay Per Task with Bitcoin" with per-page override block - Add SEO meta blocks to base.html: - `{% block meta_description %}` with Lightning/sats copy - `{% block meta_robots %}` defaulting to "index, follow" - `{% block canonical_url %}` link tag pointing to site_url - Open Graph tags (og:title, og:description, og:url, og:image) - Twitter/X Card summary_large_image tags - JSON-LD structured data: SoftwareApplication, Service (paymentAccepted: Bitcoin Lightning), Organization, FAQPage - Add src/dashboard/routes/seo.py with GET /robots.txt and GET /sitemap.xml (lists 14 crawlable pages with changefreq + priority) - Register seo_router before all other routers in app.py Fixes #813 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -571,6 +571,11 @@ class Settings(BaseSettings):
|
||||
content_meilisearch_url: str = "http://localhost:7700"
|
||||
content_meilisearch_api_key: str = ""
|
||||
|
||||
# ── SEO / Public Site ──────────────────────────────────────────────────
|
||||
# Canonical base URL used in sitemap.xml, canonical link tags, and OG tags.
|
||||
# Override with SITE_URL env var, e.g. "https://alexanderwhitestone.com".
|
||||
site_url: str = "https://alexanderwhitestone.com"
|
||||
|
||||
# ── Scripture / Biblical Integration ──────────────────────────────
|
||||
# Enable the biblical text module.
|
||||
scripture_enabled: bool = True
|
||||
|
||||
@@ -62,6 +62,7 @@ from dashboard.routes.tools import router as tools_router
|
||||
from dashboard.routes.tower import router as tower_router
|
||||
from dashboard.routes.voice import router as voice_router
|
||||
from dashboard.routes.work_orders import router as work_orders_router
|
||||
from dashboard.routes.seo import router as seo_router
|
||||
from dashboard.routes.world import matrix_router
|
||||
from dashboard.routes.world import router as world_router
|
||||
from timmy.workshop_state import PRESENCE_FILE
|
||||
@@ -663,6 +664,7 @@ if static_dir.exists():
|
||||
from dashboard.templating import templates # noqa: E402
|
||||
|
||||
# Include routers
|
||||
app.include_router(seo_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(agents_router)
|
||||
app.include_router(voice_router)
|
||||
|
||||
73
src/dashboard/routes/seo.py
Normal file
73
src/dashboard/routes/seo.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""SEO endpoints: robots.txt, sitemap.xml, and structured-data helpers.
|
||||
|
||||
These endpoints make alexanderwhitestone.com crawlable by search engines.
|
||||
All pages listed in the sitemap are server-rendered HTML (not SPA-only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
|
||||
from config import settings
|
||||
|
||||
router = APIRouter(tags=["seo"])
|
||||
|
||||
# Public-facing pages included in the sitemap.
|
||||
# Format: (path, change_freq, priority)
|
||||
_SITEMAP_PAGES: list[tuple[str, str, str]] = [
|
||||
("/", "daily", "1.0"),
|
||||
("/briefing", "daily", "0.9"),
|
||||
("/tasks", "daily", "0.8"),
|
||||
("/calm", "weekly", "0.7"),
|
||||
("/thinking", "weekly", "0.7"),
|
||||
("/swarm/mission-control", "weekly", "0.7"),
|
||||
("/monitoring", "weekly", "0.6"),
|
||||
("/nexus", "weekly", "0.6"),
|
||||
("/spark/ui", "weekly", "0.6"),
|
||||
("/memory", "weekly", "0.6"),
|
||||
("/marketplace/ui", "weekly", "0.8"),
|
||||
("/models", "weekly", "0.5"),
|
||||
("/tools", "weekly", "0.5"),
|
||||
("/scorecards", "weekly", "0.6"),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=PlainTextResponse)
|
||||
async def robots_txt() -> str:
|
||||
"""Allow all search engines; point to sitemap."""
|
||||
base = settings.site_url.rstrip("/")
|
||||
return (
|
||||
"User-agent: *\n"
|
||||
"Allow: /\n"
|
||||
"\n"
|
||||
f"Sitemap: {base}/sitemap.xml\n"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sitemap.xml")
|
||||
async def sitemap_xml() -> Response:
|
||||
"""Generate XML sitemap for all crawlable pages."""
|
||||
base = settings.site_url.rstrip("/")
|
||||
today = date.today().isoformat()
|
||||
|
||||
url_entries: list[str] = []
|
||||
for path, changefreq, priority in _SITEMAP_PAGES:
|
||||
url_entries.append(
|
||||
f" <url>\n"
|
||||
f" <loc>{base}{path}</loc>\n"
|
||||
f" <lastmod>{today}</lastmod>\n"
|
||||
f" <changefreq>{changefreq}</changefreq>\n"
|
||||
f" <priority>{priority}</priority>\n"
|
||||
f" </url>"
|
||||
)
|
||||
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
+ "\n".join(url_entries)
|
||||
+ "\n</urlset>\n"
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
@@ -6,7 +6,103 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#080412" />
|
||||
<title>{% block title %}Timmy Time — Mission Control{% endblock %}</title>
|
||||
<title>{% block title %}Timmy AI Workshop | Lightning-Powered AI Jobs — Pay Per Task with Bitcoin{% endblock %}</title>
|
||||
|
||||
{# SEO: description #}
|
||||
<meta name="description" content="{% block meta_description %}Run AI jobs in seconds — pay per task in sats over Bitcoin Lightning. No subscription, no waiting, instant results. Timmy AI Workshop powers your workflow.{% endblock %}" />
|
||||
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}" />
|
||||
|
||||
{# Canonical URL — override per-page via {% block canonical_url %} #}
|
||||
{% block canonical_url %}
|
||||
<link rel="canonical" href="{{ site_url }}" />
|
||||
{% endblock %}
|
||||
|
||||
{# Open Graph #}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Timmy AI Workshop" />
|
||||
<meta property="og:title" content="{% block og_title %}Timmy AI Workshop | Lightning-Powered AI Jobs — Pay Per Task with Bitcoin{% endblock %}" />
|
||||
<meta property="og:description" content="{% block og_description %}Pay-per-task AI jobs over Bitcoin Lightning. No subscriptions — just instant, sovereign AI results priced in sats.{% endblock %}" />
|
||||
<meta property="og:url" content="{% block og_url %}{{ site_url }}{% endblock %}" />
|
||||
<meta property="og:image" content="{% block og_image %}{{ site_url }}/static/og-workshop.png{% endblock %}" />
|
||||
<meta property="og:image:alt" content="Timmy AI Workshop — 3D lightning-powered AI task engine" />
|
||||
|
||||
{# Twitter / X Card #}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{% block twitter_title %}Timmy AI Workshop | Lightning-Powered AI Jobs{% endblock %}" />
|
||||
<meta name="twitter:description" content="Pay-per-task AI over Bitcoin Lightning. No subscription — just sats and instant results." />
|
||||
<meta name="twitter:image" content="{% block twitter_image %}{{ site_url }}/static/og-workshop.png{% endblock %}" />
|
||||
|
||||
{# JSON-LD Structured Data #}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Timmy AI Workshop",
|
||||
"applicationCategory": "BusinessApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "{{ site_url }}",
|
||||
"description": "Lightning-powered AI task engine. Pay per task in sats — no subscription required.",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "SAT",
|
||||
"description": "Pay-per-task pricing over Bitcoin Lightning Network"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "Timmy AI Workshop",
|
||||
"serviceType": "AI Task Automation",
|
||||
"description": "On-demand AI jobs priced in satoshis. Instant results, no subscription.",
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": "Alexander Whitestone",
|
||||
"url": "{{ site_url }}"
|
||||
},
|
||||
"paymentAccepted": "Bitcoin Lightning",
|
||||
"url": "{{ site_url }}"
|
||||
},
|
||||
{
|
||||
"@type": "Organization",
|
||||
"name": "Alexander Whitestone",
|
||||
"url": "{{ site_url }}",
|
||||
"description": "Sovereign AI infrastructure powered by Bitcoin Lightning."
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How do I pay for AI tasks?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Tasks are priced in satoshis (sats) and settled instantly over the Bitcoin Lightning Network. No credit card or subscription required."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is there a subscription fee?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "No. Timmy AI Workshop is strictly pay-per-task — you only pay for what you use, in sats."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How fast are results?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "AI jobs run on local sovereign infrastructure and return results in seconds, with no cloud round-trips."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
@@ -4,4 +4,9 @@ from pathlib import Path
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from config import settings
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||
|
||||
# Inject site_url into every template so SEO tags and canonical URLs work.
|
||||
templates.env.globals["site_url"] = settings.site_url
|
||||
|
||||
Reference in New Issue
Block a user