feat(seo): Phase 2 — route-level metadata service and JSON-LD structured data
CI Pipeline / backend-test (push) Successful in 5m16s
CI Pipeline / backend-test (pull_request) Successful in 5m5s
CI Pipeline / frontend-test (pull_request) Successful in 27s
CI Pipeline / frontend-build (pull_request) Failing after 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / frontend-test (push) Successful in 28s
CI Pipeline / frontend-build (push) Failing after 29s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (push) Successful in 5m16s
CI Pipeline / backend-test (pull_request) Successful in 5m5s
CI Pipeline / frontend-test (pull_request) Successful in 27s
CI Pipeline / frontend-build (pull_request) Failing after 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / frontend-test (push) Successful in 28s
CI Pipeline / frontend-build (push) Failing after 29s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Add SeoService for dynamic metadata management: - Per-route document titles (e.g., 'KMountain Flower Radio — About') - Meta descriptions from station config per route - Canonical URLs, Open Graph tags, Twitter Card tags - Input sanitization (HTML stripping, URL validation, attribute escaping) - noindex robots directives for /login and /admin routes - Centralized router watcher (zero per-component boilerplate) Add StructuredDataService + StructuredDataComponent for JSON-LD: - Organization schema (NCBOrganization) site-wide with contact info - WebSite schema with publisher info - WebPage schema per-route with breadcrumbs - Dynamic Event schema on /events page - RadioSeason schema on /schedule page Wire up SeoService.watchRouter() in app-root ngOnInit. Wire up StructuredDataComponent in app-root template. Wire up EventsComponent and ScheduleComponent to feed data to StructuredDataService for dynamic schema injection. Includes full test suite for SeoService (22 tests).
This commit is contained in:
1 parent
2716765223
commit
58267e8f59
10 files changed
+1194
-8
No files matched your search
@@ -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 } } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user