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
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:
1 parent
2716765223
commit
58267e8f59
10 files changed
+1194
-8
No files matched your search
@@ -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 } } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user