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
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:
commit
fdbc1d78da
10 files changed
+1194
-8
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
<app-structured-data></app-structured-data>
|
||||||
<app-navbar></app-navbar>
|
<app-navbar></app-navbar>
|
||||||
<app-media-player></app-media-player>
|
<app-media-player></app-media-player>
|
||||||
<main [class.no-stream]="!hasStream()">
|
<main [class.no-stream]="!hasStream()">
|
||||||
|
|||||||
+10
-6
@@ -1,21 +1,22 @@
|
|||||||
import { Component, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
|
import { Component, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { Title } from '@angular/platform-browser';
|
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { NavbarComponent } from './navbar/navbar.component';
|
import { NavbarComponent } from './navbar/navbar.component';
|
||||||
import { MediaPlayerComponent } from './media-player/media-player.component';
|
import { MediaPlayerComponent } from './media-player/media-player.component';
|
||||||
import { FooterComponent } from './footer/footer.component';
|
import { FooterComponent } from './footer/footer.component';
|
||||||
|
import { StructuredDataComponent } from './structured-data/structured-data.component';
|
||||||
import { StationConfigService } from './services/station-config.service';
|
import { StationConfigService } from './services/station-config.service';
|
||||||
|
import { SeoService } from './services/seo.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent],
|
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent, StructuredDataComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
export class App implements OnInit, OnDestroy {
|
export class App implements OnInit, OnDestroy {
|
||||||
private titleService = inject(Title);
|
|
||||||
private stationConfig = inject(StationConfigService);
|
private stationConfig = inject(StationConfigService);
|
||||||
|
private seoService = inject(SeoService);
|
||||||
|
|
||||||
readonly hasStream = computed(() => {
|
readonly hasStream = computed(() => {
|
||||||
const cfg = this.stationConfig.config();
|
const cfg = this.stationConfig.config();
|
||||||
@@ -26,11 +27,9 @@ export class App implements OnInit, OnDestroy {
|
|||||||
private resizeObserver: ResizeObserver | null = null;
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Update favicon when station config changes.
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const config = this.stationConfig.config();
|
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;
|
const faviconEl = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
||||||
if (faviconEl && config.logo_url) {
|
if (faviconEl && config.logo_url) {
|
||||||
faviconEl.href = config.logo_url;
|
faviconEl.href = config.logo_url;
|
||||||
@@ -47,6 +46,11 @@ export class App implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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.
|
// Defer until the navbar is rendered in the DOM.
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
this.navbarEl = document.querySelector('app-navbar');
|
this.navbarEl = document.querySelector('app-navbar');
|
||||||
|
|||||||
@@ -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 { AsyncPipe, DatePipe, NgFor } from '@angular/common';
|
||||||
import { RouterLinkWithHref } from '@angular/router';
|
import { RouterLinkWithHref } from '@angular/router';
|
||||||
import { map, Observable, startWith } from 'rxjs';
|
import { map, Observable, startWith } from 'rxjs';
|
||||||
|
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
import { StructuredDataService } from '../services/structured-data.service';
|
||||||
import { Event } from '../interfaces/event';
|
import { Event } from '../interfaces/event';
|
||||||
|
|
||||||
interface EventsState {
|
interface EventsState {
|
||||||
@@ -22,6 +23,7 @@ interface EventsState {
|
|||||||
export class EventsComponent {
|
export class EventsComponent {
|
||||||
private eventService = inject(EventService);
|
private eventService = inject(EventService);
|
||||||
readonly config = inject(StationConfigService).config;
|
readonly config = inject(StationConfigService).config;
|
||||||
|
private structuredData = inject(StructuredDataService);
|
||||||
|
|
||||||
/** Async pipe drives the template — guarantees change detection on every emission. */
|
/** Async pipe drives the template — guarantees change detection on every emission. */
|
||||||
readonly events$: Observable<EventsState> =
|
readonly events$: Observable<EventsState> =
|
||||||
@@ -29,4 +31,14 @@ export class EventsComponent {
|
|||||||
map((upcomingEvents) => ({ loading: false, upcomingEvents })),
|
map((upcomingEvents) => ({ loading: false, upcomingEvents })),
|
||||||
startWith({ loading: true, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 }>;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, effect, inject } from '@angular/core';
|
||||||
import { AsyncPipe } from '@angular/common';
|
import { AsyncPipe } from '@angular/common';
|
||||||
import { map, Observable, startWith } from 'rxjs';
|
import { map, Observable, startWith } from 'rxjs';
|
||||||
|
|
||||||
import { ShowService } from '../services/show.service';
|
import { ShowService } from '../services/show.service';
|
||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
import { StructuredDataService } from '../services/structured-data.service';
|
||||||
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
|
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
|
||||||
import { resolveImageUrl } from '../services/app-config.service';
|
import { resolveImageUrl } from '../services/app-config.service';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ interface ScheduleState {
|
|||||||
export class ScheduleComponent {
|
export class ScheduleComponent {
|
||||||
private showService = inject(ShowService);
|
private showService = inject(ShowService);
|
||||||
readonly config = inject(StationConfigService).config;
|
readonly config = inject(StationConfigService).config;
|
||||||
|
private structuredData = inject(StructuredDataService);
|
||||||
|
|
||||||
/** ID of the currently expanded show card — null means none. */
|
/** ID of the currently expanded show card — null means none. */
|
||||||
expandedShowId: number | null = null;
|
expandedShowId: number | null = null;
|
||||||
@@ -47,4 +49,14 @@ export class ScheduleComponent {
|
|||||||
resolveImageUrl(url: string): string {
|
resolveImageUrl(url: string): string {
|
||||||
return resolveImageUrl(url);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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('"quotes" &');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user