Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdbc1d78da | ||
|
|
c61162bff4 | ||
|
|
58267e8f59 |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
<app-structured-data></app-structured-data>
|
||||
<app-navbar></app-navbar>
|
||||
<app-media-player></app-media-player>
|
||||
<main [class.no-stream]="!hasStream()">
|
||||
|
||||
+10
-6
@@ -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');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { AsyncPipe, DatePipe, NgFor } from '@angular/common';
|
||||
import { RouterLinkWithHref } from '@angular/router';
|
||||
import { map, Observable, startWith } from 'rxjs';
|
||||
|
||||
import { EventService } from '../services/event.service';
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
import { StructuredDataService } from '../services/structured-data.service';
|
||||
import { Event } from '../interfaces/event';
|
||||
|
||||
interface EventsState {
|
||||
@@ -22,6 +23,7 @@ interface EventsState {
|
||||
export class EventsComponent {
|
||||
private eventService = inject(EventService);
|
||||
readonly config = inject(StationConfigService).config;
|
||||
private structuredData = inject(StructuredDataService);
|
||||
|
||||
/** Async pipe drives the template — guarantees change detection on every emission. */
|
||||
readonly events$: Observable<EventsState> =
|
||||
@@ -29,4 +31,14 @@ export class EventsComponent {
|
||||
map((upcomingEvents) => ({ loading: false, upcomingEvents })),
|
||||
startWith({ loading: true, upcomingEvents: [] }),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Feed active events to structured data for Event schema injection.
|
||||
effect(() => {
|
||||
const snapshot = this.eventService.getEvents(true);
|
||||
snapshot.subscribe((evts) => {
|
||||
this.structuredData.activeEvents.set(evts);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 { 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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