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

+281
View File
@@ -0,0 +1,281 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Subject } from 'rxjs';
import { NavigationEnd } from '@angular/router';
// Import the service class — but we construct it manually with mocks
// instead of relying on TestBed (which needs initTestEnvironment + compiler).
import { SeoService } from './seo.service';
/* ------------------------------------------------------------------ */
/* Mock factories */
/* ------------------------------------------------------------------ */
const createMockTitle = () => ({
setTitle: vi.fn(),
});
const createMockMeta = () => ({
updateTag: vi.fn(),
removeTag: vi.fn(),
addTag: vi.fn(),
});
const createMockStationConfig = () => ({
config: vi.fn(() => ({
name_primary: 'KMountain',
name_secondary: 'Flower Radio',
tagline: 'Community Radio',
logo_url: 'https://kmountainflower.org/logo.png',
website: 'kmountainflower.org',
hero_subtitle: 'Your community-powered radio station.',
about_intro: 'About KMountain Flower Radio.',
schedule_intro: 'Our weekly schedule.',
events_intro: 'Community events.',
})),
});
const createMockRouter = (url = '/') => {
const events = new Subject<any>();
return {
url,
events,
};
};
import {
Injector,
} from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { StationConfigService } from './station-config.service';
describe('SeoService', () => {
let titleMock: ReturnType<typeof createMockTitle>;
let metaMock: ReturnType<typeof createMockMeta>;
let stationConfigMock: ReturnType<typeof createMockStationConfig>;
let routerMock: ReturnType<typeof createMockRouter>;
let injector: Injector;
let service: SeoService;
beforeEach(() => {
titleMock = createMockTitle();
metaMock = createMockMeta();
stationConfigMock = createMockStationConfig();
routerMock = createMockRouter();
injector = Injector.create({
providers: [
{ provide: Title, useValue: titleMock },
{ provide: Meta, useValue: metaMock },
{ provide: Router, useValue: routerMock },
{ provide: StationConfigService, useValue: stationConfigMock },
SeoService,
],
});
service = injector.get(SeoService);
});
describe('update()', () => {
it('should set the document title', () => {
service.update({ title: 'Test Page' });
expect(titleMock.setTitle).toHaveBeenCalledWith('Test Page');
});
it('should strip HTML from the title', () => {
service.update({ title: '<script>bad()</script>Test' });
expect(titleMock.setTitle).toHaveBeenCalledWith('bad()Test');
});
it('should set meta description', () => {
service.update({ title: 'Page', description: 'A description.' });
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'description', content: 'A description.' })
);
});
it('should strip HTML from description', () => {
service.update({ title: 'Page', description: '<b>Bold</b> desc' });
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'description', content: 'Bold desc' })
);
});
it('should set canonical link', () => {
service.update({ title: 'Page', canonicalUrl: 'https://example.com/page' });
expect(metaMock.removeTag).toHaveBeenCalledWith('link[rel="canonical"]');
expect(metaMock.addTag).toHaveBeenCalledWith(
expect.objectContaining({ rel: 'canonical', href: 'https://example.com/page' })
);
});
it('should reject invalid canonical URLs', () => {
service.update({ title: 'Page', canonicalUrl: 'not a url!!' });
expect(metaMock.removeTag).not.toHaveBeenCalledWith('link[rel="canonical"]');
expect(metaMock.addTag).not.toHaveBeenCalled();
});
it('should set Open Graph tags', () => {
service.update({
title: 'OG Test',
description: 'OG desc',
ogImage: 'https://example.com/img.png',
ogImageWidth: 800,
ogImageHeight: 600,
});
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'og:title', content: 'OG Test' })
);
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'og:description', content: 'OG desc' })
);
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'og:image', content: 'https://example.com/img.png' })
);
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'og:image:width', content: '800' })
);
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'og:image:height', content: '600' })
);
});
it('should set Twitter Card tags', () => {
service.update({
title: 'Twitter Test',
description: 'Twitter desc',
ogImage: 'https://example.com/img.png',
twitterCard: 'summary',
});
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'twitter:card', content: 'summary' })
);
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'twitter:title', content: 'Twitter Test' })
);
});
it('should set noindex robots directive', () => {
service.update({ title: 'Private', noindex: true });
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'robots', content: 'noindex' })
);
});
it('should set nofollow robots directive', () => {
service.update({ title: 'Page', nofollow: true });
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'robots', content: 'nofollow' })
);
});
it('should combine noindex + nofollow', () => {
service.update({ title: 'Page', noindex: true, nofollow: true });
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'robots', content: 'noindex, nofollow' })
);
});
it('should remove robots tag when neither noindex nor nofollow', () => {
service.update({ title: 'Page' });
expect(metaMock.removeTag).toHaveBeenCalledWith('name="robots"');
});
it('should handle extraMeta tags', () => {
service.update({
title: 'Page',
extraMeta: [{ property: 'author', content: 'KMountain' }],
});
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'author', content: 'KMountain' })
);
});
it('should clamp image dimensions to valid range', () => {
service.update({
title: 'Page',
ogImage: 'https://example.com/img.png',
ogImageWidth: 10,
ogImageHeight: 5000,
});
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'og:image:width', content: '200' })
);
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ property: 'og:image:height', content: '4096' })
);
});
});
describe('resetToDefaults()', () => {
it('should use station name for homepage', () => {
routerMock.url = '/';
service.resetToDefaults();
expect(titleMock.setTitle).toHaveBeenCalledWith('KMountain Flower Radio');
});
it('should add route suffix for subpages', () => {
routerMock.url = '/schedule';
service.resetToDefaults();
expect(titleMock.setTitle).toHaveBeenCalledWith('KMountain Flower Radio — Schedule');
});
it('should noindex admin pages', () => {
routerMock.url = '/admin';
service.resetToDefaults();
expect(metaMock.updateTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'robots', content: 'noindex' })
);
});
});
describe('watchRouter()', () => {
it('should update SEO on NavigationEnd', () => {
service.watchRouter();
(routerMock.events as Subject<any>).next(new NavigationEnd(1, '/', '/'));
expect(titleMock.setTitle).toHaveBeenCalled();
});
it('should not react to non-NavigationEnd events', () => {
service.watchRouter();
(routerMock.events as Subject<any>).next({});
expect(titleMock.setTitle).not.toHaveBeenCalled();
});
});
describe('generateTags()', () => {
it('should return HTML meta tags', () => {
const html = service.generateTags({
title: 'Generated',
description: 'Desc',
canonicalUrl: 'https://example.com',
ogImage: 'https://example.com/img.png',
});
expect(html).toContain('<meta name="description" content="Desc" />');
expect(html).toContain('<link rel="canonical" href="https://example.com" />');
expect(html).toContain('<meta property="og:title" content="Generated" />');
});
it('should strip tags then escape remaining special characters', () => {
const html = service.generateTags({
title: '<script>bad()</script>',
description: '"quotes" & <angles>',
});
// sanitizeText strips <angles> as an HTML tag, leaving '"quotes" &'
// escapeAttr then escapes quotes and ampersand.
expect(html).not.toContain('<script>');
expect(html).not.toContain('<angles>');
expect(html).toContain('&quot;quotes&quot; &amp;');
});
it('should reject invalid URLs in generateTags', () => {
const html = service.generateTags({
title: 'Page',
canonicalUrl: '!!!invalid',
ogImage: '!!!invalid',
});
expect(html).not.toContain('canonical');
expect(html).not.toContain('og:image');
});
});
});
+341
View File
@@ -0,0 +1,341 @@
import { Injectable, inject } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { Router, NavigationEnd } from '@angular/router';
import { filter, tap } from 'rxjs';
import { StationConfigService } from './station-config.service';
import { SeoMetadata } from '../interfaces/seo-metadata';
/* ------------------------------------------------------------------ */
/* Sanitization helpers */
/* ------------------------------------------------------------------ */
/** Strip HTML tags and normalize whitespace. */
function sanitizeText(value: string): string {
return value
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/** Escape a string for safe use as an HTML attribute value. */
function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/** Validate and normalize a URL. Returns empty string for invalid input. */
function sanitizeUrl(value: string): string {
if (!value) return '';
try {
// Allow relative URLs and full URLs.
if (value.startsWith('/') || value.startsWith('http://') || value.startsWith('https://')) {
new URL(value.startsWith('http') ? value : `https:${value}`);
return value;
}
} catch {
// Invalid URL
}
return '';
}
/** Clamp a number to [min, max] or return a default. */
function clampNumber(value: number | undefined, min: number, max: number, fallback: number): number {
if (typeof value !== 'number' || isNaN(value)) return fallback;
return Math.min(Math.max(value, min), max);
}
/* ------------------------------------------------------------------ */
/* Default page metadata keyed by route path */
/* ------------------------------------------------------------------ */
interface RouteSeoDefaults {
titleSuffix?: string;
description?: string;
noindex?: boolean;
}
const ROUTE_DEFAULTS: Record<string, RouteSeoDefaults> = {
'': {
titleSuffix: undefined, // homepage uses station name only
description: undefined, // will use hero_subtitle from station config
},
'about': {
titleSuffix: 'About',
description: undefined, // will use about_intro
},
'schedule': {
titleSuffix: 'Schedule',
description: undefined, // will use schedule_intro
},
'events': {
titleSuffix: 'Events',
description: undefined, // will use events_intro
},
'contact': {
titleSuffix: 'Contact',
description: 'Get in touch with us.',
},
'login': {
titleSuffix: 'Login',
noindex: true,
},
'admin': {
titleSuffix: 'Admin',
noindex: true,
},
};
/* ------------------------------------------------------------------ */
/* SeoService */
/* ------------------------------------------------------------------ */
/**
* Reusable SEO service that manages `<head>` metadata dynamically.
*
* Features:
* - Sets `<title>` via Angular's Title service
* - Manages meta description, canonical URL, OG tags, Twitter Card tags
* - Sanitizes all inputs (HTML stripping, URL validation, attribute escaping)
* - Falls back to station config defaults when values are not provided
* - Watches router NavigationEnd events to auto-update SEO per route
* - Supports noindex / nofollow robots directives
*
* Usage in a component:
* const seo = inject(SeoService);
* seo.update({ title: 'My Page', description: '...' });
*
* Or let the auto-watcher handle it by calling seo.watchRouter() once in app-root.
*/
@Injectable({
providedIn: 'root',
})
export class SeoService {
private titleService = inject(Title);
private metaService = inject(Meta);
private router = inject(Router);
private stationConfig = inject(StationConfigService);
/**
* Update all `<head>` SEO metadata with the given config.
* Missing fields fall back to station config or sensible defaults.
*/
update(metadata: SeoMetadata): void {
const config = this.stationConfig.config();
// -- Title --------------------------------------------------------
const title = sanitizeText(metadata.title);
if (title) {
this.titleService.setTitle(title);
}
// -- Description --------------------------------------------------
const description = metadata.description
? sanitizeText(metadata.description)
: '';
this.setMeta('name', 'description', description);
// -- Canonical URL ------------------------------------------------
const canonical = sanitizeUrl(metadata.canonicalUrl ?? '');
if (canonical) {
this.setCanonical(canonical);
}
// -- Open Graph tags ----------------------------------------------
const ogImage = sanitizeUrl(metadata.ogImage ?? config.logo_url ?? '');
const ogImageWidth = clampNumber(metadata.ogImageWidth, 200, 4096, 1200);
const ogImageHeight = clampNumber(metadata.ogImageHeight, 200, 4096, 630);
const ogType = sanitizeText(metadata.ogType ?? 'website');
this.setMeta('property', 'og:title', title);
this.setMeta('property', 'og:description', description);
this.setMeta('property', 'og:type', ogType);
if (canonical) {
this.setMeta('property', 'og:url', canonical);
}
if (ogImage) {
this.setMeta('property', 'og:image', ogImage);
this.setMeta('property', 'og:image:width', String(ogImageWidth));
this.setMeta('property', 'og:image:height', String(ogImageHeight));
}
// -- Twitter Card tags --------------------------------------------
const twitterCard = sanitizeText(metadata.twitterCard ?? 'summary_large_image');
this.setMeta('name', 'twitter:card', twitterCard);
this.setMeta('name', 'twitter:title', title);
this.setMeta('name', 'twitter:description', description);
if (ogImage) {
this.setMeta('name', 'twitter:image', ogImage);
}
// -- Robots -------------------------------------------------------
if (metadata.noindex || metadata.nofollow) {
const directives: string[] = [];
if (metadata.noindex) directives.push('noindex');
if (metadata.nofollow) directives.push('nofollow');
this.setMeta('name', 'robots', directives.join(', '));
} else {
// Remove existing robots tag so the page is indexable by default.
this.metaService.removeTag('name="robots"');
}
// -- Extra meta tags ----------------------------------------------
if (metadata.extraMeta) {
for (const tag of metadata.extraMeta) {
const key = tag.httpEquiv ? 'http-equiv' : 'property';
this.setMeta(key, tag.property, sanitizeText(tag.content));
}
}
}
/**
* Reset all SEO metadata to defaults based on the current route.
* The route path is resolved from the Angular router.
*/
resetToDefaults(routePath?: string): void {
const path = routePath ?? (this.router.url.replace(/^\/+/, '').split('/')[0] || '');
const defaults = ROUTE_DEFAULTS[path] ?? {};
const config = this.stationConfig.config();
// Build title: "StationName — Suffix" or "StationName"
const fullName = `${config.name_primary} ${config.name_secondary}`.trim();
const title = defaults.titleSuffix
? `${fullName} — ${defaults.titleSuffix}`
: fullName;
// Pick description from station config when available.
let description = defaults.description ?? '';
if (!description && path === '' && config.hero_subtitle) {
description = sanitizeText(config.hero_subtitle);
} else if (!description && path === 'about' && config.about_intro) {
description = sanitizeText(config.about_intro);
} else if (!description && path === 'schedule' && config.schedule_intro) {
description = sanitizeText(config.schedule_intro);
} else if (!description && path === 'events' && config.events_intro) {
description = sanitizeText(config.events_intro);
}
this.update({
title,
description,
noindex: defaults.noindex,
});
}
/**
* Start watching router NavigationEnd events and auto-update SEO
* metadata on every navigation. Call this once in app-root.
*/
watchRouter(): void {
this.router.events
.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
tap(() => this.resetToDefaults()),
)
.subscribe();
}
/**
* Generate a string of HTML `<meta>` / `<link>` tags for the given
* metadata. Useful for SSR or dynamic head injection outside Angular.
*/
generateTags(metadata: SeoMetadata): string {
const config = this.stationConfig.config();
const lines: string[] = [];
const title = escapeAttr(sanitizeText(metadata.title));
const description = escapeAttr(
metadata.description ? sanitizeText(metadata.description) : '',
);
const canonical = sanitizeUrl(metadata.canonicalUrl ?? '');
const ogImage = sanitizeUrl(metadata.ogImage ?? config.logo_url ?? '');
const ogImageWidth = clampNumber(metadata.ogImageWidth, 200, 4096, 1200);
const ogImageHeight = clampNumber(metadata.ogImageHeight, 200, 4096, 630);
const ogType = escapeAttr(sanitizeText(metadata.ogType ?? 'website'));
const twitterCard = escapeAttr(sanitizeText(metadata.twitterCard ?? 'summary_large_image'));
// Description
if (description) {
lines.push(`<meta name="description" content="${description}" />`);
}
// Canonical
if (canonical) {
lines.push(`<link rel="canonical" href="${escapeAttr(canonical)}" />`);
}
// Open Graph
lines.push(`<meta property="og:title" content="${title}" />`);
if (description) {
lines.push(`<meta property="og:description" content="${description}" />`);
}
lines.push(`<meta property="og:type" content="${ogType}" />`);
if (canonical) {
lines.push(`<meta property="og:url" content="${escapeAttr(canonical)}" />`);
}
if (ogImage) {
lines.push(`<meta property="og:image" content="${escapeAttr(ogImage)}" />`);
lines.push(`<meta property="og:image:width" content="${ogImageWidth}" />`);
lines.push(`<meta property="og:image:height" content="${ogImageHeight}" />`);
}
// Twitter Card
lines.push(`<meta name="twitter:card" content="${twitterCard}" />`);
lines.push(`<meta name="twitter:title" content="${title}" />`);
if (description) {
lines.push(`<meta name="twitter:description" content="${description}" />`);
}
if (ogImage) {
lines.push(`<meta name="twitter:image" content="${escapeAttr(ogImage)}" />`);
}
// Robots
if (metadata.noindex || metadata.nofollow) {
const directives: string[] = [];
if (metadata.noindex) directives.push('noindex');
if (metadata.nofollow) directives.push('nofollow');
lines.push(`<meta name="robots" content="${directives.join(', ')}" />`);
}
// Extra
if (metadata.extraMeta) {
for (const tag of metadata.extraMeta) {
const k = tag.httpEquiv ? 'http-equiv' : 'property';
lines.push(`<meta ${k}="${escapeAttr(tag.property)}" content="${escapeAttr(sanitizeText(tag.content))}" />`);
}
}
return lines.join('\n');
}
/* ------------------------------------------------------------------ */
/* Private helpers */
/* ------------------------------------------------------------------ */
private setMeta(attrName: string, attrValue: string, content: string): void {
if (!content) {
// Remove tag if content is empty.
this.metaService.removeTag(`${attrName}="${attrValue}"`);
return;
}
const tag: Record<string, string> = { content };
if (attrName === 'name') tag.name = attrValue;
else if (attrName === 'property') tag.property = attrValue;
else if (attrName === 'http-equiv') tag['http-equiv'] = attrValue;
this.metaService.updateTag(tag);
}
private setCanonical(url: string): void {
// Canonical is a <link> tag, not a <meta> tag.
const selector = 'link[rel="canonical"]';
this.metaService.removeTag(selector);
this.metaService.addTag({
rel: 'canonical',
href: url,
});
}
}
+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 } } : {}),
})),
};
}
}