Merge pull request 'feat(seo): Phase 2 — route-level metadata service and JSON-LD structured data' (#6) from feat/seo-phase2-metadata-structured-data into main
CI Pipeline / backend-test (push) Successful in 8m29s
CI Pipeline / frontend-test (push) Successful in 28s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 3s

Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/6
Reviewed-by: kfj001 <kfj001@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
kfj001 committed 2026-08-05 11:18:48 -07:00
commit fdbc1d78da
10 files changed
+1194 -8

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
<app-structured-data></app-structured-data>
<app-navbar></app-navbar>
<app-media-player></app-media-player>
<main [class.no-stream]="!hasStream()">
+10 -6
View File
@@ -1,21 +1,22 @@
import { Component, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterOutlet } from '@angular/router';
import { NavbarComponent } from './navbar/navbar.component';
import { MediaPlayerComponent } from './media-player/media-player.component';
import { FooterComponent } from './footer/footer.component';
import { StructuredDataComponent } from './structured-data/structured-data.component';
import { StationConfigService } from './services/station-config.service';
import { SeoService } from './services/seo.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent],
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent, StructuredDataComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App implements OnInit, OnDestroy {
private titleService = inject(Title);
private stationConfig = inject(StationConfigService);
private seoService = inject(SeoService);
readonly hasStream = computed(() => {
const cfg = this.stationConfig.config();
@@ -26,11 +27,9 @@ export class App implements OnInit, OnDestroy {
private resizeObserver: ResizeObserver | null = null;
constructor() {
// Update favicon when station config changes.
effect(() => {
const config = this.stationConfig.config();
const fullName = `${config.name_primary} ${config.name_secondary}`.trim();
this.titleService.setTitle(fullName);
const faviconEl = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
if (faviconEl && config.logo_url) {
faviconEl.href = config.logo_url;
@@ -47,6 +46,11 @@ export class App implements OnInit, OnDestroy {
}
ngOnInit(): void {
// Start watching router for automatic SEO metadata updates per route.
this.seoService.watchRouter();
// Handle the initial route (NavigationEnd already fired before ngOnInit).
this.seoService.resetToDefaults();
// Defer until the navbar is rendered in the DOM.
queueMicrotask(() => {
this.navbarEl = document.querySelector('app-navbar');
+13 -1
View File
@@ -1,10 +1,11 @@
import { Component, inject } from '@angular/core';
import { Component, effect, 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';
import { StationConfigService } from '../services/station-config.service';
import { StructuredDataService } from '../services/structured-data.service';
import { Event } from '../interfaces/event';
interface EventsState {
@@ -22,6 +23,7 @@ interface EventsState {
export class EventsComponent {
private eventService = inject(EventService);
readonly config = inject(StationConfigService).config;
private structuredData = inject(StructuredDataService);
/** Async pipe drives the template — guarantees change detection on every emission. */
readonly events$: Observable<EventsState> =
@@ -29,4 +31,14 @@ export class EventsComponent {
map((upcomingEvents) => ({ loading: false, upcomingEvents })),
startWith({ loading: true, upcomingEvents: [] }),
);
constructor() {
// Feed active events to structured data for Event schema injection.
effect(() => {
const snapshot = this.eventService.getEvents(true);
snapshot.subscribe((evts) => {
this.structuredData.activeEvents.set(evts);
});
});
}
}
+35
View File
@@ -0,0 +1,35 @@
/** SEO metadata shape passed to SeoService.update(). */
export interface SeoMetadata {
/** Page title shown in the browser tab and search results. */
title: string;
/** Meta description (shown in search result snippets). */
description?: string;
/** Canonical URL for this page. */
canonicalUrl?: string;
/** Open Graph image URL. */
ogImage?: string;
/** Open Graph image width (default 1200). */
ogImageWidth?: number;
/** Open Graph image height (default 630). */
ogImageHeight?: number;
/** Open Graph type (default 'website'). */
ogType?: string;
/** Twitter Card type (default 'summary_large_image'). */
twitterCard?: string;
/** Whether to noindex this page (search engines won't index it). */
noindex?: boolean;
/** Whether to follow links on this page. */
nofollow?: boolean;
/** Additional arbitrary meta tags to set. */
extraMeta?: Array<{ property: string; content: string; httpEquiv?: string }>;
}
+139
View File
@@ -0,0 +1,139 @@
/** Schema.org JSON-LD structured data types for kmtnflower. */
/** Top-level JSON-LD graph entry with @context and @type. */
export interface JsonLdEntry {
'@context'?: string;
'@type': string;
[key: string]: unknown;
}
/** Schema.org object used as a nested property (no @context needed). */
export interface SchemaObject {
'@type': string;
[key: string]: unknown;
}
/** Organization / RadioStation structured data. */
export interface OrganizationData extends JsonLdEntry {
'@type': 'Organization' | 'RadioStation' | 'NCBOrganization';
name: string;
legalName?: string;
url: string;
logo?: string;
description?: string;
foundingDate?: string;
contactPoint?: ContactPoint[];
address?: PostalAddress;
sameAs?: string[];
}
export interface ContactPoint extends SchemaObject {
'@type': 'ContactPoint';
telephone?: string;
email?: string;
contactType?: string;
areaServed?: string;
availableLanguage?: string;
}
export interface PostalAddress extends SchemaObject {
'@type': 'PostalAddress';
streetAddress?: string;
addressLocality?: string;
addressRegion?: string;
postalCode?: string;
addressCountry?: string;
}
/** WebSite structured data with optional search action. */
export interface WebSiteData extends JsonLdEntry {
'@type': 'WebSite';
name: string;
url: string;
description?: string;
publisher?: { '@type': string; name: string; logo?: { '@type': string; url: string } };
potentialAction?: SearchAction;
}
export interface SearchAction extends SchemaObject {
'@type': 'SearchAction';
target: { '@type': 'EntryPoint'; url: string } | string;
'query-input': string;
}
/** WebPage structured data (per-route). */
export interface WebPageData extends JsonLdEntry {
'@type': 'WebPage' | 'WebPageElement';
name: string;
url: string;
description?: string;
isPartOf?: { '@type': 'WebSite'; url: string; name: string };
about?: { '@type': string; name: string; url: string };
breadcrumb?: BreadcrumbList;
}
/** BreadcrumbList for hierarchical navigation. */
export interface BreadcrumbList extends SchemaObject {
'@type': 'BreadcrumbList';
itemListElement: ListItem[];
}
export interface ListItem extends SchemaObject {
'@type': 'ListItem';
position: number;
name: string;
item: string;
}
/** Event structured data for individual events. */
export interface EventData extends JsonLdEntry {
'@type': 'Event';
name: string;
url: string;
description?: string;
startDate?: string;
endDate?: string;
location?: EventLocation;
organizer?: OrganizationData;
image?: string;
offers?: EventOffer;
}
export interface EventLocation extends SchemaObject {
'@type': 'Place' | 'VirtualLocation';
name: string;
address?: PostalAddress;
url?: string;
}
export interface EventOffer extends SchemaObject {
'@type': 'Offer';
price: string;
priceCurrency: string;
availability?: string;
url?: string;
}
/** Schedule structured data (RadioSeason / TVSeries style for show schedules). */
export interface ScheduleData extends JsonLdEntry {
'@type': 'Schedule' | 'RadioSeason';
name: string;
url: string;
description?: string;
repeats?: string;
startTime?: string;
endTime?: string;
performer?: { '@type': 'RadioStation'; name: string; url: string };
}
/** Site-wide structured data that applies to every page. */
export interface SiteStructuredData {
organization: OrganizationData;
website: WebSiteData;
}
/** Per-route structured data (overrides/extends site-wide). */
export interface RouteStructuredData {
webpage: WebPageData;
additional?: JsonLdEntry[];
}
+13 -1
View File
@@ -1,10 +1,11 @@
import { Component, inject } from '@angular/core';
import { Component, effect, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { map, Observable, startWith } from 'rxjs';
import { ShowService } from '../services/show.service';
import { Show } from '../interfaces/show';
import { StationConfigService } from '../services/station-config.service';
import { StructuredDataService } from '../services/structured-data.service';
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
import { resolveImageUrl } from '../services/app-config.service';
@@ -23,6 +24,7 @@ interface ScheduleState {
export class ScheduleComponent {
private showService = inject(ShowService);
readonly config = inject(StationConfigService).config;
private structuredData = inject(StructuredDataService);
/** ID of the currently expanded show card — null means none. */
expandedShowId: number | null = null;
@@ -47,4 +49,14 @@ export class ScheduleComponent {
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
constructor() {
// Feed active shows to structured data for RadioSeason schema injection.
effect(() => {
const snapshot = this.showService.getShows();
snapshot.subscribe((shows) => {
this.structuredData.activeShows.set(shows);
});
});
}
}
+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 } } : {}),
})),
};
}
}
@@ -0,0 +1,56 @@
import { Component, effect, inject } from '@angular/core';
import { StructuredDataService } from '../services/structured-data.service';
/**
* Renders JSON-LD structured data as `<script type="application/ld+json">` tags
* in the document <head>.
*
* Reads from StructuredDataService.structuredData() and keeps the head tags
* in sync on every route change or config update.
*
* Tags are identified by the data attribute `data="structured-data"` so they
* can be cleaned up on destroy without affecting other <head> scripts.
*/
@Component({
selector: 'app-structured-data',
standalone: true,
template: '', // renders nothing in the body — writes to <head>
})
export class StructuredDataComponent {
private service = inject(StructuredDataService);
constructor() {
effect(() => {
const entries = this.service.structuredData();
this.renderEntries(entries);
});
}
ngOnDestroy(): void {
this.clearTags();
}
// ── Private ────────────────────────────────────────────────────
/** Write JSON-LD script tags to <head>, replacing any previous ones. */
private renderEntries(entries: unknown[]): void {
this.clearTags();
const head = document.head;
for (const entry of entries) {
const script = document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data', 'structured-data');
script.textContent = JSON.stringify(entry);
head.appendChild(script);
}
}
/** Remove all previously injected structured-data script tags. */
private clearTags(): void {
const tags = document.querySelectorAll('script[data="structured-data"]');
for (const tag of tags) {
tag.remove();
}
}
}