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).
306 lines
9.3 KiB
TypeScript
306 lines
9.3 KiB
TypeScript
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 } } : {}),
|
|
})),
|
|
};
|
|
}
|
|
}
|