fix(seo): comprehensive SEO improvements per expert audit #4

Merged
robot merged 8 commits from fix/seo-improvements into main 2026-08-04 20:03:55 -07:00
34 changed files with 390 additions and 14 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ name: CI Pipeline
on:
push:
branches: [main, develop, feature/tests-and-ci, feature/multi-admin-roles]
branches: ['**']
kfj001 marked this conversation as resolved Outdated
Outdated
Review

There has got to be a more general way to ensure this runs on any branch on push.

There has got to be a more general way to ensure this runs on any branch on push.
pull_request:
branches: [main]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
"""Sitemap & robots.txt API: tenant-aware SEO files served by the backend.
Review

This needs a unit test.

This needs a unit test.
Because this is a white-label app where each tenant has its own domain
(set via KMTN_WEBSITE_URL), sitemap.xml and robots.txt are generated
on-the-fly from the station config — no static files needed.
This matches the reviewer feedback:
'This is a whitelabel app - this file may need to be dynaimcally generated...'
Follows the same pattern as theme_export.py: content is derived from
KMTN_WEBSITE_URL at runtime, so every tenant gets their own correct URL.
"""
from typing import Any, Dict
from fastapi import APIRouter, Depends, Response, status as http_status
from app.auth import get_current_admin_user
from app.config import settings
from app.sitemap_export import build_robots_txt, build_sitemap_xml
from app.user_models import User
router = APIRouter()
@router.get("/sitemap.xml")
async def get_sitemap() -> Response:
"""Return a dynamically-generated sitemap.xml. Public endpoint.
Uses KMTN_WEBSITE_URL so each tenant gets the correct domain.
"""
xml = build_sitemap_xml(settings.WEBSITE_URL)
return Response(content=xml, media_type="application/xml")
@router.get("/robots.txt")
async def get_robots() -> Response:
"""Return a dynamically-generated robots.txt. Public endpoint.
Uses KMTN_WEBSITE_URL so the Sitemap directive points to the
correct tenant domain.
"""
txt = build_robots_txt(settings.WEBSITE_URL)
return Response(content=txt, media_type="text/plain")
@router.post("/regenerate", status_code=http_status.HTTP_200_OK)
async def regenerate_sitemap(
_: User = Depends(get_current_admin_user),
) -> Dict[str, Any]:
"""Force regenerate the sitemap cache. Admin only.
In the dynamic model this is a no-op (files are generated per-request),
but kept for API consistency with the theme endpoint.
"""
return {
"status": "ok",
"note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL",
"base_url": settings.WEBSITE_URL,
}
+7 -1
View File
@@ -10,7 +10,7 @@ from app.config import settings
from app.database import async_session, engine
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
from app.user_models import User
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme, sitemap
def _cleanup_orphaned_sequences(sync_conn):
@@ -78,6 +78,11 @@ def _migrate_add_missing_columns(sync_conn):
("station_config", "color_black", sa.String(7), "#1a1816"),
# Multi-admin support: password_hash (nullable — existing rows stay NULL)
("users", "password_hash", sa.String(255), None),
# Social media columns
("station_config", "social_facebook", sa.String(500), ""),
("station_config", "social_twitter", sa.String(500), ""),
("station_config", "social_instagram", sa.String(500), ""),
("station_config", "social_youtube", sa.String(500), ""),
]
# Determine dialect
@@ -230,6 +235,7 @@ app.include_router(underwriters.router, prefix="/api/underwriters", tags=["under
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
app.include_router(sitemap.router, prefix="/api/sitemap", tags=["sitemap"])
# Admin user management (available in all environments)
from app.api import users
+6
View File
@@ -97,6 +97,12 @@ class StationConfig(Base):
# Donation
donation_url = Column(String(500), nullable=False, default="")
# Social media
social_facebook = Column(String(500), nullable=False, default="")
social_twitter = Column(String(500), nullable=False, default="")
social_instagram = Column(String(500), nullable=False, default="")
social_youtube = Column(String(500), nullable=False, default="")
# Stream
stream_url = Column(String(500), nullable=False, default="")
stream_metadata_url = Column(String(500), nullable=False, default="")
+10
View File
@@ -78,6 +78,11 @@ class StationConfigResponse(BaseModel):
play_store_icon_url: str
play_store_url: str
app_store_embed_html: str
# Social media
social_facebook: str
social_twitter: str
social_instagram: str
social_youtube: str
# Page content
hero_subtitle: str
about_intro: str
@@ -129,6 +134,11 @@ class StationConfigUpdate(BaseModel):
play_store_icon_url: Optional[str] = None
play_store_url: Optional[str] = None
app_store_embed_html: Optional[str] = None
# Social media
social_facebook: Optional[str] = None
social_twitter: Optional[str] = None
social_instagram: Optional[str] = None
social_youtube: Optional[str] = None
# Page content
hero_subtitle: Optional[str] = None
about_intro: Optional[str] = None
+52
View File
@@ -0,0 +1,52 @@
"""Sitemap & robots.txt generation: tenant-aware SEO content.
kfj001 marked this conversation as resolved
Review

Unit test for this type too.

Unit test for this type too.
Because this is a white-label app where each tenant has its own domain
(set via KMTN_WEBSITE_URL), the sitemap and robots.txt are generated
at runtime — no static files are needed. The FastAPI /api/sitemap/*
endpoints call these functions; Nginx proxies /sitemap.xml and
/robots.txt to those endpoints so search-engine crawlers hit the right
content at the root URL.
"""
# Public routes included in the sitemap with their change frequency and priority.
SEOMAP_ROUTES = [
{"path": "/", "changefreq": "daily", "priority": 1.0},
{"path": "/about", "changefreq": "weekly", "priority": 0.8},
{"path": "/schedule", "changefreq": "weekly", "priority": 0.8},
{"path": "/events", "changefreq": "daily", "priority": 0.8},
{"path": "/contact", "changefreq": "monthly", "priority": 0.5},
]
def build_sitemap_xml(base_url: str) -> str:
"""Build a valid sitemap.xml string for the given base URL."""
url = base_url.rstrip("/")
entries = []
for route in SEOMAP_ROUTES:
entries.append(
f" <url>\n"
f" <loc>{url}{route['path']}</loc>\n"
f" <changefreq>{route['changefreq']}</changefreq>\n"
f" <priority>{route['priority']}</priority>\n"
f" </url>"
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(entries) + "\n"
"</urlset>\n"
)
def build_robots_txt(base_url: str) -> str:
"""Build a robots.txt string that references the correct sitemap URL."""
sitemap = base_url.rstrip("/") + "/sitemap.xml"
return (
"User-agent: *\n"
"Allow: /\n"
"\n"
"Disallow: /admin\n"
"Disallow: /login\n"
"\n"
f"Sitemap: {sitemap}\n"
)
+55
View File
@@ -0,0 +1,55 @@
"""Tests for sitemap API endpoints — /api/sitemap/sitemap.xml, /api/sitemap/robots.txt, /api/sitemap/regenerate."""
import pytest
from httpx import AsyncClient
from starlette.testclient import TestClient
from app.main import app
class TestSitemapEndpoints:
"""Tests for sitemap API routes."""
def _client(self):
return TestClient(app)
def test_sitemap_xml_status(self):
client = self._client()
resp = client.get("/api/sitemap/sitemap.xml")
assert resp.status_code == 200
def test_sitemap_xml_content_type(self):
client = self._client()
resp = client.get("/api/sitemap/sitemap.xml")
assert resp.headers["content-type"].startswith("application/xml")
def test_sitemap_xml_contains_routes(self):
client = self._client()
resp = client.get("/api/sitemap/sitemap.xml")
body = resp.text
for path in ["/", "/about", "/schedule", "/events", "/contact"]:
assert f"<loc>https://kmountainflower.org{path}</loc>" in body
def test_robots_txt_status(self):
client = self._client()
resp = client.get("/api/sitemap/robots.txt")
assert resp.status_code == 200
def test_robots_txt_content_type(self):
client = self._client()
resp = client.get("/api/sitemap/robots.txt")
assert resp.headers["content-type"].startswith("text/plain")
def test_robots_txt_contains_rules(self):
client = self._client()
resp = client.get("/api/sitemap/robots.txt")
body = resp.text
assert "User-agent: *" in body
assert "Disallow: /admin" in body
assert "Disallow: /login" in body
assert "Sitemap: https://kmountainflower.org/sitemap.xml" in body
def test_regenerate_requires_auth(self):
client = self._client()
resp = client.post("/api/sitemap/regenerate")
assert resp.status_code in (401, 403)
+113
View File
@@ -0,0 +1,113 @@
"""Tests for sitemap_export — sitemap.xml and robots.txt generation."""
import pytest
from xml.etree import ElementTree as ET
from app.sitemap_export import (
SEOMAP_ROUTES,
build_sitemap_xml,
build_robots_txt,
)
class TestSitemapRoutes:
"""Verify the route configuration constants."""
def test_route_count(self):
assert len(SEOMAP_ROUTES) == 5
def test_home_route(self):
assert SEOMAP_ROUTES[0] == {
"path": "/",
"changefreq": "daily",
"priority": 1.0,
}
def test_all_paths_present(self):
paths = [r["path"] for r in SEOMAP_ROUTES]
expected = ["/", "/about", "/schedule", "/events", "/contact"]
assert paths == expected
def test_priority_range(self):
for route in SEOMAP_ROUTES:
assert 0.0 <= route["priority"] <= 1.0
def test_valid_changefreq_values(self):
valid = {"always", "hourly", "daily", "weekly", "monthly", "yearly", "never"}
for route in SEOMAP_ROUTES:
assert route["changefreq"] in valid
class TestBuildSitemapXml:
"""Tests for the sitemap XML builder."""
def test_returns_string(self):
result = build_sitemap_xml("https://example.com")
assert isinstance(result, str)
def test_contains_xml_declaration(self):
result = build_sitemap_xml("https://example.com")
assert '<?xml version="1.0" encoding="UTF-8"?>' in result
def test_contains_urlset_namespace(self):
result = build_sitemap_xml("https://example.com")
assert 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' in result
def test_contains_all_routes(self):
result = build_sitemap_xml("https://example.com")
for route in SEOMAP_ROUTES:
assert f"<loc>https://example.com{route['path']}</loc>" in result
def test_strips_trailing_slash_from_base_url(self):
result = build_sitemap_xml("https://example.com/")
assert "https://example.com//about" not in result
assert "<loc>https://example.com/about</loc>" in result
def test_different_base_url(self):
result = build_sitemap_xml("https://mystation.org")
assert "https://mystation.org/schedule" in result
assert "https://example.com" not in result
def test_valid_xml(self):
result = build_sitemap_xml("https://example.com")
# Should parse without error
root = ET.fromstring(result)
# The root tag includes the namespace
assert "urlset" in root.tag
def test_contains_changefreq_and_priority(self):
result = build_sitemap_xml("https://example.com")
assert "<changefreq>daily</changefreq>" in result
assert "<priority>1.0</priority>" in result
assert "<priority>0.5</priority>" in result
class TestBuildRobotsTxt:
"""Tests for the robots.txt builder."""
def test_returns_string(self):
result = build_robots_txt("https://example.com")
assert isinstance(result, str)
def test_user_agent_allows_all(self):
result = build_robots_txt("https://example.com")
assert "User-agent: *" in result
assert "Allow: /" in result
def test_disallows_admin_and_login(self):
result = build_robots_txt("https://example.com")
assert "Disallow: /admin" in result
assert "Disallow: /login" in result
def test_sitemap_reference(self):
result = build_robots_txt("https://example.com")
assert "Sitemap: https://example.com/sitemap.xml" in result
def test_strips_trailing_slash(self):
result = build_robots_txt("https://example.com/")
assert "Sitemap: https://example.com//sitemap.xml" not in result
assert "Sitemap: https://example.com/sitemap.xml" in result
def test_different_base_url(self):
result = build_robots_txt("https://mystation.org")
assert "Sitemap: https://mystation.org/sitemap.xml" in result
+18
View File
@@ -24,6 +24,24 @@ server {
try_files $uri $uri/ /index.html;
}
# Dynamic sitemap.xml + robots.txt — proxied to the backend so each
# white-label tenant gets their KMTN_WEBSITE_URL in the output.
location = /sitemap.xml {
proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location = /robots.txt {
proxy_pass __API_UPSTREAM__/api/sitemap/robots.txt;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
# Proxy API calls to the backend service
location /api/ {
proxy_pass __API_UPSTREAM__;
+1 -1
View File
@@ -73,7 +73,7 @@
@if (config().donation_url) {
<div class="about-cta">
<div class="about-flower-divider" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="">
<img [src]="resolveImageUrl(config().logo_url)" alt="" aria-hidden="true">
</div>
<h3>Become a Member</h3>
<p class="section-intro">{{ config().about_donation_text }}</p>
@@ -184,6 +184,31 @@
</div>
</div>
<!-- Social Media -->
<div class="form-section">
<h3>Social Media</h3>
<div class="form-group">
<label for="social_facebook">Facebook URL</label>
<input id="social_facebook" type="url" [(ngModel)]="social_facebook" name="social_facebook"
placeholder="https://facebook.com/yourstation">
</div>
<div class="form-group">
<label for="social_twitter">Twitter / X URL</label>
<input id="social_twitter" type="url" [(ngModel)]="social_twitter" name="social_twitter"
placeholder="https://x.com/yourstation">
</div>
<div class="form-group">
<label for="social_instagram">Instagram URL</label>
<input id="social_instagram" type="url" [(ngModel)]="social_instagram" name="social_instagram"
placeholder="https://instagram.com/yourstation">
</div>
<div class="form-group">
<label for="social_youtube">YouTube URL</label>
<input id="social_youtube" type="url" [(ngModel)]="social_youtube" name="social_youtube"
placeholder="https://youtube.com/@yourstation">
</div>
</div>
<!-- Page Content -->
<div class="form-section">
<h3>Page Content</h3>
@@ -43,6 +43,10 @@ export class AdminStationFormComponent implements OnChanges {
play_store_icon_url = '';
play_store_url = '';
app_store_embed_html = '';
social_facebook = '';
social_twitter = '';
social_instagram = '';
social_youtube = '';
hero_subtitle = '';
about_intro = '';
about_donation_text = '';
@@ -84,6 +88,10 @@ export class AdminStationFormComponent implements OnChanges {
this.play_store_icon_url = config.play_store_icon_url;
this.play_store_url = config.play_store_url;
this.app_store_embed_html = config.app_store_embed_html;
this.social_facebook = config.social_facebook;
this.social_twitter = config.social_twitter;
this.social_instagram = config.social_instagram;
this.social_youtube = config.social_youtube;
this.hero_subtitle = config.hero_subtitle;
this.about_intro = config.about_intro;
this.about_donation_text = config.about_donation_text;
@@ -180,6 +188,10 @@ export class AdminStationFormComponent implements OnChanges {
play_store_icon_url: this.play_store_icon_url,
play_store_url: this.play_store_url,
app_store_embed_html: this.app_store_embed_html,
social_facebook: this.social_facebook,
social_twitter: this.social_twitter,
social_instagram: this.social_instagram,
social_youtube: this.social_youtube,
hero_subtitle: this.hero_subtitle,
about_intro: this.about_intro,
about_donation_text: this.about_donation_text,
+1 -1
View File
@@ -29,7 +29,7 @@
@if (event.rsvp_url) {
<a [href]="event.rsvp_url" class="event-rsvp" target="_blank" rel="noopener">RSVP &rarr;</a>
} @else {
<a href="#" class="event-rsvp">Learn More &rarr;</a>
<a routerLink="/contact" [queryParams]="{subject: 'general'}" class="event-rsvp">Learn More &rarr;</a>
}
</div>
</div>
+2 -1
View File
@@ -1,5 +1,6 @@
import { Component, inject } from '@angular/core';
import { AsyncPipe, DatePipe, NgFor } from '@angular/common';
import { RouterLinkWithHref } from '@angular/router';
import { map, Observable, startWith } from 'rxjs';
import { EventService } from '../services/event.service';
@@ -14,7 +15,7 @@ interface EventsState {
@Component({
selector: 'app-events',
standalone: true,
imports: [AsyncPipe, DatePipe, NgFor],
imports: [AsyncPipe, DatePipe, NgFor, RouterLinkWithHref],
templateUrl: './events.component.html',
styleUrl: './events.component.scss',
})
+15 -7
View File
@@ -35,18 +35,26 @@
@if (config().donation_url) {
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Become a Member</a></li>
}
<li><a href="#">Volunteer</a></li>
<li><a href="#">Corporate Giving</a></li>
<li><a href="#">Planned Giving</a></li>
<li><a routerLink="/contact" [queryParams]="{subject: 'volunteer'}">Volunteer</a></li>
<li><a routerLink="/contact" [queryParams]="{subject: 'advertising'}">Corporate Giving</a></li>
<li><a routerLink="/contact" [queryParams]="{subject: 'general'}">Planned Giving</a></li>
</ul>
</div>
<div class="footer-col">
<h4>Follow Us</h4>
<div class="social-links">
<a href="#" aria-label="Facebook" class="social-link">📘</a>
<a href="#" aria-label="Twitter" class="social-link">🐦</a>
<a href="#" aria-label="Instagram" class="social-link">📷</a>
<a href="#" aria-label="YouTube" class="social-link">📺</a>
@if (config().social_facebook) {
<a [href]="config().social_facebook" target="_blank" rel="noopener noreferrer" aria-label="Facebook" class="social-link">📘</a>
}
@if (config().social_twitter) {
<a [href]="config().social_twitter" target="_blank" rel="noopener noreferrer" aria-label="Twitter" class="social-link">🐦</a>
}
@if (config().social_instagram) {
<a [href]="config().social_instagram" target="_blank" rel="noopener noreferrer" aria-label="Instagram" class="social-link">📷</a>
}
@if (config().social_youtube) {
<a [href]="config().social_youtube" target="_blank" rel="noopener noreferrer" aria-label="YouTube" class="social-link">📺</a>
}
</div>
</div>
</div>
+2 -2
View File
@@ -1,6 +1,6 @@
import { Component, inject, computed } from '@angular/core';
import { SlicePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { RouterLinkWithHref } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@@ -8,7 +8,7 @@ import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-footer',
standalone: true,
imports: [RouterLink, SlicePipe],
imports: [RouterLinkWithHref, SlicePipe],
templateUrl: './footer.component.html',
styleUrl: './footer.component.scss',
})
+5
View File
@@ -23,6 +23,11 @@ export interface StationConfig {
play_store_icon_url: string;
play_store_url: string;
app_store_embed_html: string;
// Social media
kfj001 marked this conversation as resolved
Review

I don't think we've added any admin UI to set these values.

I don't think we've added any admin UI to set these values.
social_facebook: string;
social_twitter: string;
social_instagram: string;
social_youtube: string;
// Page content
hero_subtitle: string;
about_intro: string;
@@ -32,6 +32,11 @@ const DEFAULT_CONFIG: StationConfig = {
play_store_icon_url: '',
play_store_url: '',
app_store_embed_html: '',
// Social media
social_facebook: '',
social_twitter: '',
social_instagram: '',
social_youtube: '',
// Page content
hero_subtitle: 'Your community-powered, nonprofit radio station — bringing music, stories, and connection to the mountain towns and beyond since 1987.',
about_intro: 'For nearly four decades, KMountain Flower Radio has been the voice of our community — a nonprofit, listener-supported radio station dedicated to music, local news, and the stories that bind mountain towns together.',