feat(seo): Phase 2 — route-level metadata service and JSON-LD structured data
CI Pipeline / backend-test (push) Successful in 5m16s
CI Pipeline / backend-test (pull_request) Successful in 5m5s
CI Pipeline / frontend-test (pull_request) Successful in 27s
CI Pipeline / frontend-build (pull_request) Failing after 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / frontend-test (push) Successful in 28s
CI Pipeline / frontend-build (push) Failing after 29s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / quality-gate (pull_request) Successful in 2s

Add SeoService for dynamic metadata management:
- Per-route document titles (e.g., 'KMountain Flower Radio — About')
- Meta descriptions from station config per route
- Canonical URLs, Open Graph tags, Twitter Card tags
- Input sanitization (HTML stripping, URL validation, attribute escaping)
- noindex robots directives for /login and /admin routes
- Centralized router watcher (zero per-component boilerplate)

Add StructuredDataService + StructuredDataComponent for JSON-LD:
- Organization schema (NCBOrganization) site-wide with contact info
- WebSite schema with publisher info
- WebPage schema per-route with breadcrumbs
- Dynamic Event schema on /events page
- RadioSeason schema on /schedule page

Wire up SeoService.watchRouter() in app-root ngOnInit.
Wire up StructuredDataComponent in app-root template.
Wire up EventsComponent and ScheduleComponent to feed data to
StructuredDataService for dynamic schema injection.

Includes full test suite for SeoService (22 tests).
This commit is contained in:
robot committed 2026-08-05 10:12:55 +00:00
1 parent 2716765223
commit 58267e8f59
10 files changed
+1194 -8

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { StationConfigService } from './station-config.service';
import { resolveImageUrl } from './app-config.service';
import {
JsonLdEntry,
ListItem,
OrganizationData,
WebSiteData,
WebPageData,
BreadcrumbList,
EventData,
SiteStructuredData,
RouteStructuredData,
} from '../interfaces/structured-data';
import { StationConfig } from '../interfaces/station-config';
import { Event } from '../interfaces/event';
import { Show } from '../interfaces/show';
/** Map route path segments to human-readable page titles. */
const PAGE_TITLES: Record<string, string> = {
'': 'Home',
about: 'About',
schedule: 'Schedule',
events: 'Events',
contact: 'Contact',
};
/** Page-specific descriptions for structured data. */
const PAGE_DESCRIPTIONS: Record<string, (cfg: StationConfig) => string> = {
'': (cfg) =>
`${cfg.name_primary} ${cfg.name_secondary} — ${cfg.tagline}. ${cfg.hero_subtitle}`,
about: (cfg) => cfg.about_intro,
schedule: (cfg) => cfg.schedule_intro,
events: (cfg) => cfg.events_intro,
contact: (cfg) =>
`Get in touch with ${cfg.name_primary} ${cfg.name_secondary}. ${cfg.address.replace(/\n/g, ', ')}. Phone: ${cfg.phone}, Email: ${cfg.email}.`,
};
/**
* Resolves the canonical website URL for structured data.
* Uses the StationConfig website field (white-label aware).
*/
function canonicalBaseUrl(cfg: StationConfig): string {
const website = cfg.website?.trim();
if (!website) return 'https://kmountainflower.org';
if (website.startsWith('http://') || website.startsWith('https://')) return website;
return `https://${website}`;
}
/**
* Structured data service: builds Schema.org JSON-LD for the current page.
*
* Site-wide data (Organization, WebSite) is shared across all pages.
* Per-route data (WebPage, Event, Schedule) is computed from the active route
* and available data (events, shows).
*
* Usage: inject StructuredDataService, read structuredData() signal.
* The StructuredDataComponent handles DOM injection.
*/
@Injectable({
providedIn: 'root',
})
export class StructuredDataService {
private router = inject(Router);
private stationConfig = inject(StationConfigService);
/** Current route path (e.g., '', 'about', 'events'). */
readonly currentRoute = computed(() => {
const url = this.router.url.split('?')[0].replace(/^\/$/, '').replace(/\/$/, '');
return url || '';
});
// ── External data hooks (set by route components) ──────────────
readonly activeEvents = signal<Event[]>([]);
readonly activeShows = signal<Show[]>([]);
// ── Site-wide structured data (shared across all pages) ────────
readonly siteData = computed<SiteStructuredData>(() => {
const cfg = this.stationConfig.config();
const baseUrl = canonicalBaseUrl(cfg);
const fullName = `${cfg.name_primary} ${cfg.name_secondary}`.trim();
const logoUrl = cfg.logo_url ? resolveImageUrl(cfg.logo_url) : '';
const organization: OrganizationData = {
'@context': 'https://schema.org',
'@type': 'NCBOrganization',
name: fullName,
legalName: fullName,
url: baseUrl,
logo: logoUrl || undefined,
description: `${fullName} — ${cfg.tagline}`,
foundingDate: cfg.founded_year ? `${cfg.founded_year}-01-01` : undefined,
contactPoint: [
{
'@type': 'ContactPoint',
telephone: cfg.phone || undefined,
email: cfg.email || undefined,
contactType: 'customer service',
areaServed: 'US',
availableLanguage: 'English',
},
],
address: cfg.address
? this.parsePostalAddress(cfg.address)
: undefined,
};
const website: WebSiteData = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: fullName,
url: baseUrl,
description: `${fullName} — ${cfg.tagline}`,
publisher: {
'@type': 'Organization',
name: fullName,
...(logoUrl ? { logo: { '@type': 'ImageObject', url: logoUrl } } : {}),
},
};
return { organization, website };
});
// ── Per-route structured data ──────────────────────────────────
readonly routeData = computed<RouteStructuredData>(() => {
const cfg = this.stationConfig.config();
const route = this.currentRoute();
const baseUrl = canonicalBaseUrl(cfg);
const fullName = `${cfg.name_primary} ${cfg.name_secondary}`.trim();
const pageTitle = PAGE_TITLES[route] || 'Page';
const pageDescFn = PAGE_DESCRIPTIONS[route];
const description = pageDescFn ? pageDescFn(cfg) : undefined;
const webpage: WebPageData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: `${pageTitle} — ${fullName}`,
url: route ? `${baseUrl}/${route}` : baseUrl,
description,
isPartOf: {
'@type': 'WebSite',
url: baseUrl,
name: fullName,
},
about: {
'@type': 'Organization',
name: fullName,
url: baseUrl,
},
breadcrumb: this.buildBreadcrumbs(route, baseUrl, fullName),
};
const additional: JsonLdEntry[] = [];
// Events page: add Event schema for upcoming events
if (route === 'events') {
for (const evt of this.activeEvents()) {
additional.push(this.buildEventSchema(evt, baseUrl, fullName));
}
}
// Schedule page: add RadioSeason / Show schemas
if (route === 'schedule') {
const scheduleData = this.buildScheduleSchema(cfg, baseUrl, fullName);
if (scheduleData) additional.push(scheduleData);
}
return { webpage, additional: additional.length ? additional : undefined };
});
// ── Combined output: all JSON-LD entries for the current page ──
readonly structuredData = computed<JsonLdEntry[]>(() => {
const site = this.siteData();
const route = this.routeData();
const entries: JsonLdEntry[] = [
site.organization,
site.website,
route.webpage,
...(route.additional ?? []),
];
return entries;
});
// ── Private helpers ────────────────────────────────────────────
private parsePostalAddress(address: string): {
'@type': 'PostalAddress';
streetAddress?: string;
addressLocality?: string;
addressRegion?: string;
postalCode?: string;
addressCountry?: string;
} {
const lines = address.split('\n').map((l) => l.trim()).filter(Boolean);
const result: {
'@type': 'PostalAddress';
streetAddress?: string;
addressLocality?: string;
addressRegion?: string;
postalCode?: string;
addressCountry?: string;
} = { '@type': 'PostalAddress' };
if (lines.length >= 1) result.streetAddress = lines[0];
if (lines.length >= 2) {
const parts = lines[1].split(',').map((p) => p.trim());
if (parts.length >= 1) result.addressLocality = parts[0];
if (parts.length >= 2) result.addressRegion = parts[1];
if (parts.length >= 3) result.postalCode = parts[2];
}
result.addressCountry = 'US';
return result;
}
private buildBreadcrumbs(
route: string,
baseUrl: string,
siteName: string,
): BreadcrumbList {
const items: ListItem[] = [
{
'@type': 'ListItem',
position: 1,
name: siteName,
item: baseUrl,
},
];
if (route) {
const title = PAGE_TITLES[route] || route;
items.push({
'@type': 'ListItem',
position: 2,
name: title,
item: `${baseUrl}/${route}`,
});
}
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items,
};
}
private buildEventSchema(
evt: Event,
baseUrl: string,
stationName: string,
): EventData {
return {
'@context': 'https://schema.org',
'@type': 'Event',
name: evt.title,
url: `${baseUrl}/events`,
description: evt.description || undefined,
startDate: evt.date || undefined,
location: evt.location
? {
'@type': 'Place',
name: evt.location,
}
: undefined,
organizer: {
'@context': 'https://schema.org',
'@type': 'Organization',
name: stationName,
url: baseUrl,
},
offers: {
'@context': 'https://schema.org',
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
};
}
private buildScheduleSchema(
cfg: StationConfig,
baseUrl: string,
fullName: string,
): JsonLdEntry | null {
const shows = this.activeShows();
if (!shows.length) return null;
return {
'@context': 'https://schema.org',
'@type': 'RadioSeason',
name: `${fullName} Program Schedule`,
url: `${baseUrl}/schedule`,
description: cfg.schedule_intro || undefined,
performer: {
'@type': 'RadioStation',
name: fullName,
url: baseUrl,
},
episode: shows.map((show) => ({
'@type': 'RadioEpisode',
name: show.title,
description: show.description || undefined,
...(show.host ? { director: { '@type': 'Person', name: show.host } } : {}),
})),
};
}
}