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('