From 58267e8f59acd7252ffb95f93f9319b5b77d902b Mon Sep 17 00:00:00 2001 From: robot Date: Wed, 5 Aug 2026 10:12:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(seo):=20Phase=202=20=E2=80=94=20route-?= =?UTF-8?q?level=20metadata=20service=20and=20JSON-LD=20structured=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/app/app.html | 1 + src/app/app.ts | 16 +- src/app/events/events.component.ts | 14 +- src/app/interfaces/seo-metadata.ts | 35 ++ src/app/interfaces/structured-data.ts | 139 +++++++ src/app/schedule/schedule.component.ts | 14 +- src/app/services/seo.service.spec.ts | 281 +++++++++++++++ src/app/services/seo.service.ts | 341 ++++++++++++++++++ src/app/services/structured-data.service.ts | 305 ++++++++++++++++ .../structured-data.component.ts | 56 +++ 10 files changed, 1194 insertions(+), 8 deletions(-) create mode 100644 src/app/interfaces/seo-metadata.ts create mode 100644 src/app/interfaces/structured-data.ts create mode 100644 src/app/services/seo.service.spec.ts create mode 100644 src/app/services/seo.service.ts create mode 100644 src/app/services/structured-data.service.ts create mode 100644 src/app/structured-data/structured-data.component.ts diff --git a/src/app/app.html b/src/app/app.html index 3d58163..6a99b6a 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,3 +1,4 @@ +
diff --git a/src/app/app.ts b/src/app/app.ts index 0d997ae..80b761b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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'); diff --git a/src/app/events/events.component.ts b/src/app/events/events.component.ts index ec96c21..b03af95 100644 --- a/src/app/events/events.component.ts +++ b/src/app/events/events.component.ts @@ -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 = @@ -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); + }); + }); + } } diff --git a/src/app/interfaces/seo-metadata.ts b/src/app/interfaces/seo-metadata.ts new file mode 100644 index 0000000..2f43720 --- /dev/null +++ b/src/app/interfaces/seo-metadata.ts @@ -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 }>; +} diff --git a/src/app/interfaces/structured-data.ts b/src/app/interfaces/structured-data.ts new file mode 100644 index 0000000..cfdf1c6 --- /dev/null +++ b/src/app/interfaces/structured-data.ts @@ -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[]; +} diff --git a/src/app/schedule/schedule.component.ts b/src/app/schedule/schedule.component.ts index 1b93f5f..5e91ff1 100644 --- a/src/app/schedule/schedule.component.ts +++ b/src/app/schedule/schedule.component.ts @@ -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); + }); + }); + } } diff --git a/src/app/services/seo.service.spec.ts b/src/app/services/seo.service.spec.ts new file mode 100644 index 0000000..e8e4139 --- /dev/null +++ b/src/app/services/seo.service.spec.ts @@ -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(); + 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; + let metaMock: ReturnType; + let stationConfigMock: ReturnType; + let routerMock: ReturnType; + 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: '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: 'Bold 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).next(new NavigationEnd(1, '/', '/')); + expect(titleMock.setTitle).toHaveBeenCalled(); + }); + + it('should not react to non-NavigationEnd events', () => { + service.watchRouter(); + (routerMock.events as Subject).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(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('should strip tags then escape remaining special characters', () => { + const html = service.generateTags({ + title: '', + description: '"quotes" & ', + }); + // sanitizeText strips as an HTML tag, leaving '"quotes" &' + // escapeAttr then escapes quotes and ampersand. + expect(html).not.toContain('