Compare commits
35
Commits
58a25370b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db402f8192 | ||
|
|
bb45e8d6ae | ||
|
|
e78a725839 | ||
|
|
849856aecb | ||
|
|
1b387700d3 | ||
|
|
b20fefc432 | ||
|
|
e44f27d1cd | ||
|
|
9561339cf5 | ||
|
|
c5d80f4caa | ||
|
|
96011ef200 | ||
|
|
54bd869042 | ||
|
|
5b1006d321 | ||
|
|
1db84a384f | ||
|
|
89cac854ae | ||
|
|
8f0adf9299 | ||
|
|
c4e852684e | ||
|
|
328d067d97 | ||
|
|
2ece38e52d | ||
|
|
c4e2dc5b9f | ||
|
|
e2356954ef | ||
|
|
a8b6e65831 | ||
|
|
63bc2fb22d | ||
|
|
e95a158d57 | ||
|
|
2d26269c1a | ||
|
|
0bde1cfc75 | ||
|
|
a783626f34 | ||
|
|
fdbc1d78da | ||
|
|
c61162bff4 | ||
|
|
58267e8f59 | ||
|
|
2716765223 | ||
|
|
4b123025e4 | ||
|
|
55be42ed99 | ||
|
|
70bf1830b2 | ||
|
|
da02687260 | ||
|
|
b7e67fa244 |
No files matched your search
+3
-1
@@ -7,8 +7,10 @@ COPY . .
|
||||
RUN npx ng build --configuration production
|
||||
|
||||
# ── Serve stage ────────────────────────────────────────────────────────
|
||||
FROM docker.io/library/nginx:alpine
|
||||
FROM docker.io/library/node:22-alpine
|
||||
RUN apk add --no-cache nginx gettext
|
||||
COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html
|
||||
COPY --from=build /app/dist/radio-station/server /app/server
|
||||
COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template
|
||||
COPY config.json.template /usr/share/nginx/html/config.json.template
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Prerendering & SSR Build Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The KMountain Flower Radio application uses Angular SSR with prerendering (SSG — Static Site Generation) for its five public-facing routes. Prerendered routes are compiled to static HTML during `ng build` and served directly from disk at runtime.
|
||||
|
||||
## Public Routes
|
||||
|
||||
All routes listed in `prerender-routes.txt`:
|
||||
|
||||
| Route | Build Output |
|
||||
|-------------|---------------------------------------------------|
|
||||
| `/` | `dist/radio-station/browser/index.html` |
|
||||
| `/about` | `dist/radio-station/browser/about/index.html` |
|
||||
| `/schedule` | `dist/radio-station/browser/schedule/index.html` |
|
||||
| `/events` | `dist/radio-station/browser/events/index.html` |
|
||||
| `/contact` | `dist/radio-station/browser/contact/index.html` |
|
||||
|
||||
## Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
|-----------------------------|--------------------------------------------------------------|
|
||||
| `angular.json` | Prerender + SSR config (`prerender.routesFile`, `ssr.entry`) |
|
||||
| `prerender-routes.txt` | One route per line — the list of routes to prerender |
|
||||
| `server.ts` | Node SSR entry point + `PRERENDERED_ROUTES` constant |
|
||||
| `src/app/app.server.routes.ts` | Server-side route definitions |
|
||||
|
||||
## Build Scripts
|
||||
|
||||
### `npm run build` / `npm run build:ssr` / `npm run build:prerender`
|
||||
|
||||
```bash
|
||||
npm run build:ssr
|
||||
# or equivalently
|
||||
npm run build:prerender
|
||||
# or
|
||||
ng build
|
||||
```
|
||||
|
||||
All three scripts execute `ng build` (default configuration: `production`). Because `angular.json` has both `prerender` and `ssr` configured, a single `ng build` produces:
|
||||
|
||||
1. **Browser bundle** — client-side JavaScript + CSS under `dist/radio-station/browser/`
|
||||
2. **Server bundle** — `server.mjs` + `main.server.mjs` under `dist/radio-station/server/`
|
||||
3. **Prerendered HTML** — static HTML files for each route in `prerender-routes.txt`
|
||||
|
||||
### `npm run serve:ssr:radio-station`
|
||||
|
||||
```bash
|
||||
node dist/radio-station/server.mjs
|
||||
```
|
||||
|
||||
Starts the Node SSR server on port 4200. Prerendered routes are served as static HTML; all other routes fall back to SSR or a 404 handler.
|
||||
|
||||
## Adding a New Prerendered Route
|
||||
|
||||
1. Add the route path (one per line) to `prerender-routes.txt`
|
||||
2. Add the route to `PRERENDERED_ROUTES` in `server.ts`
|
||||
3. Rebuild: `npm run build:ssr`
|
||||
|
||||
## Build Output Structure
|
||||
|
||||
```
|
||||
dist/radio-station/
|
||||
├── browser/
|
||||
│ ├── index.html <-- prerendered (/)
|
||||
│ ├── index.csr.html <-- CSR fallback
|
||||
│ ├── about/index.html <-- prerendered (/about)
|
||||
│ ├── schedule/index.html <-- prerendered (/schedule)
|
||||
│ ├── events/index.html <-- prerendered (/events)
|
||||
│ ├── contact/index.html <-- prerendered (/contact)
|
||||
│ └── ... <-- JS/CSS chunks, assets
|
||||
└── server/
|
||||
├── server.mjs <-- Node entry point
|
||||
├── main.server.mjs <-- Angular server bundle
|
||||
├── angular-app-engine-manifest.mjs
|
||||
└── ... <-- server chunks
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
@@ -44,6 +44,7 @@
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"server": "src/main.server.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
@@ -55,6 +56,12 @@
|
||||
"styles": ["src/styles.scss"],
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": ["src/styles"]
|
||||
},
|
||||
"ssr": {
|
||||
"entry": "server.ts"
|
||||
},
|
||||
"prerender": {
|
||||
"routesFile": "prerender-routes.txt"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
|
||||
@@ -1,35 +1,66 @@
|
||||
"""Sitemap & robots.txt API: tenant-aware SEO files served by the backend.
|
||||
|
||||
Because this is a white-label app where each tenant has its own domain
|
||||
(set via KMTN_WEBSITE_URL), sitemap.xml and robots.txt are generated
|
||||
on-the-fly from the station config — no static files needed.
|
||||
(stored in StationConfig.website), sitemap.xml and robots.txt are
|
||||
generated on-the-fly from the database — no static files needed.
|
||||
|
||||
This matches the reviewer feedback:
|
||||
'This is a whitelabel app - this file may need to be dynaimcally generated...'
|
||||
|
||||
Follows the same pattern as theme_export.py: content is derived from
|
||||
KMTN_WEBSITE_URL at runtime, so every tenant gets their own correct URL.
|
||||
the station config at runtime, so every tenant gets their own correct URL.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status as http_status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.auth import get_current_admin_user
|
||||
from app.config import settings
|
||||
from app.database import async_session
|
||||
from app.models import StationConfig
|
||||
from app.sitemap_export import build_robots_txt, build_sitemap_xml
|
||||
from app.user_models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_base_url(website: str) -> str:
|
||||
"""Build the canonical base URL from the station website field.
|
||||
|
||||
The website column stores the bare domain (e.g. 'kmountainflower.org'),
|
||||
so we prefix with https:// to produce a valid base URL.
|
||||
"""
|
||||
domain = website.strip().removesuffix("/")
|
||||
# The website field may or may not include a scheme; if it already
|
||||
# has one, use it as-is, otherwise default to https.
|
||||
if "://" in domain:
|
||||
return domain
|
||||
return f"https://{domain}"
|
||||
|
||||
|
||||
async def _get_base_url() -> str:
|
||||
"""Fetch the base URL from the station config in the database."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(StationConfig).where(StationConfig.key == "default")
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
# Fall back to env config if DB is empty (e.g. before seeding)
|
||||
return settings.WEBSITE_URL
|
||||
return _build_base_url(config.website)
|
||||
|
||||
|
||||
@router.get("/sitemap.xml")
|
||||
async def get_sitemap() -> Response:
|
||||
"""Return a dynamically-generated sitemap.xml. Public endpoint.
|
||||
|
||||
Uses KMTN_WEBSITE_URL so each tenant gets the correct domain.
|
||||
Uses StationConfig.website so each tenant gets the correct domain.
|
||||
"""
|
||||
xml = build_sitemap_xml(settings.WEBSITE_URL)
|
||||
base_url = await _get_base_url()
|
||||
xml = build_sitemap_xml(base_url)
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
|
||||
|
||||
@@ -37,10 +68,11 @@ async def get_sitemap() -> Response:
|
||||
async def get_robots() -> Response:
|
||||
"""Return a dynamically-generated robots.txt. Public endpoint.
|
||||
|
||||
Uses KMTN_WEBSITE_URL so the Sitemap directive points to the
|
||||
Uses StationConfig.website so the Sitemap directive points to the
|
||||
correct tenant domain.
|
||||
"""
|
||||
txt = build_robots_txt(settings.WEBSITE_URL)
|
||||
base_url = await _get_base_url()
|
||||
txt = build_robots_txt(base_url)
|
||||
return Response(content=txt, media_type="text/plain")
|
||||
|
||||
|
||||
@@ -53,8 +85,9 @@ async def regenerate_sitemap(
|
||||
In the dynamic model this is a no-op (files are generated per-request),
|
||||
but kept for API consistency with the theme endpoint.
|
||||
"""
|
||||
base_url = await _get_base_url()
|
||||
return {
|
||||
"status": "ok",
|
||||
"note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL",
|
||||
"base_url": settings.WEBSITE_URL,
|
||||
"note": "sitemap and robots.txt are dynamically generated per-request from StationConfig.website",
|
||||
"base_url": base_url,
|
||||
}
|
||||
@@ -283,3 +283,8 @@ class MobileBuildArtifact(Base):
|
||||
sha256 = Column(String(64), nullable=False)
|
||||
size = Column(Integer, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
|
||||
# Import user_models so that User, Role, UserRole are registered on Base.metadata
|
||||
# before SQLAlchemy validates cross-table foreign keys (e.g. mobile_build_requests.created_by_id -> users.id).
|
||||
from app.user_models import * # noqa: F401,F403
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Search engine spider / bot detection based on User-Agent headers.
|
||||
|
||||
Provides utilities to identify well-known search engine crawlers so the
|
||||
application can make informed decisions (e.g., serving pre-rendered HTML
|
||||
for SEO, rate-limiting bot traffic, or tracking crawl statistics).
|
||||
|
||||
Usage:
|
||||
from app.spider_detection import is_spider, get_spider_info
|
||||
|
||||
if is_spider(user_agent):
|
||||
spider = get_spider_info(user_agent)
|
||||
print(f\"{spider.name} ({spider.engine}) detected\")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class SpiderEngine(str, Enum):
|
||||
"""Well-known search engine crawlers."""
|
||||
|
||||
GOOGLE = "Google"
|
||||
BING = "Bing"
|
||||
YAHOO = "Yahoo"
|
||||
DUCKDUCKGO = "DuckDuckGo"
|
||||
BINGBOT = "Bing" # alias kept for naming clarity — maps to Bing engine
|
||||
YANDEX = "Yandex"
|
||||
BAIDU = "Baidu"
|
||||
SOCK = "Sogou"
|
||||
FACEBOT = "Facebook"
|
||||
TWITTERBOT = "Twitter"
|
||||
APPLEBOT = "Apple"
|
||||
PETLEO = "PetleO"
|
||||
IA_archiver = "IA Archiver"
|
||||
OTHER = "Other"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpiderInfo:
|
||||
"""Identified spider details.
|
||||
|
||||
Attributes:
|
||||
engine: Human-friendly name of the search engine / platform.
|
||||
name: Exact bot name from the User-Agent (e.g. 'Googlebot').
|
||||
category: High-level category — 'search', 'social', or 'other'.
|
||||
"""
|
||||
|
||||
engine: str
|
||||
name: str
|
||||
category: str # "search" | "social" | "other"
|
||||
|
||||
@property
|
||||
def is_search_spider(self) -> bool:
|
||||
return self.category == "search"
|
||||
|
||||
@property
|
||||
def is_social_bot(self) -> bool:
|
||||
return self.category == "social"
|
||||
|
||||
|
||||
# ── Spider patterns ──────────────────────────────────────────
|
||||
# Each tuple: (compiled-regex, engine-label, category)
|
||||
# Order matters: more specific patterns first to avoid false positives.
|
||||
|
||||
_SPIDER_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [
|
||||
# ── Google family ─────────────────────────────────────
|
||||
# Main crawler (desktop)
|
||||
(re.compile(r"Googlebot/[\d.]+", re.IGNORECASE), "Google", "search"),
|
||||
# Googlebot for mobile
|
||||
(re.compile(r"Googlebot-Mobile", re.IGNORECASE), "Google", "search"),
|
||||
# Google Images
|
||||
(re.compile(r"Googlebot-Image", re.IGNORECASE), "Google", "search"),
|
||||
# Google News
|
||||
(re.compile(r"Googlebot-News", re.IGNORECASE), "Google", "search"),
|
||||
# Google Ads/AdSense
|
||||
(re.compile(r"Mediapartners-Google", re.IGNORECASE), "Google", "search"),
|
||||
# Google Storebot
|
||||
(re.compile(r"Storebot-Google", re.IGNORECASE), "Google", "search"),
|
||||
# Google Other (GoogleOther)
|
||||
(re.compile(r"Google-AdSense", re.IGNORECASE), "Google", "search"),
|
||||
|
||||
# ── Bing / Microsoft ──────────────────────────────────
|
||||
(re.compile(r"bingbot/[\d.]+", re.IGNORECASE), "Bing", "search"),
|
||||
# MSNBot (legacy Bing crawler)
|
||||
(re.compile(r"msnbot/[\d.]+", re.IGNORECASE), "Bing", "search"),
|
||||
# MSNBot Media
|
||||
(re.compile(r"msnbot-media/[\d.]+", re.IGNORECASE), "Bing", "search"),
|
||||
# bingadsbot
|
||||
(re.compile(r"bingadsbot/[\d.]+", re.IGNORECASE), "Bing", "search"),
|
||||
# WebPreview / Slurp variants from Microsoft
|
||||
(re.compile(r"webpreview", re.IGNORECASE), "Bing", "search"),
|
||||
|
||||
# ── Yahoo / Slurp ─────────────────────────────────────
|
||||
(re.compile(r"Yahoo! Slurp", re.IGNORECASE), "Yahoo", "search"),
|
||||
(re.compile(r"Yahoo! Slurp China", re.IGNORECASE), "Yahoo", "search"),
|
||||
(re.compile(r"Yahoo Deer Spider", re.IGNORECASE), "Yahoo", "search"),
|
||||
(re.compile(r"YandexBot", re.IGNORECASE), "Yandex", "search"),
|
||||
|
||||
# ── DuckDuckGo ────────────────────────────────────────
|
||||
(re.compile(r"DuckDuckBot/[\d.]+", re.IGNORECASE), "DuckDuckGo", "search"),
|
||||
|
||||
# ── Yandex (RU) ───────────────────────────────────────
|
||||
(re.compile(r"YandexBot/[\d.]+", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexImages/[\d.]+", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexAccessibilityBot", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexBlogs", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexDirect", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexFavicons", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexMarket", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexMobileBot", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexMetrika", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexSpravochnik", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexSitelinks", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexTracker", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexVertis", re.IGNORECASE), "Yandex", "search"),
|
||||
(re.compile(r"YandexWebmaster", re.IGNORECASE), "Yandex", "search"),
|
||||
|
||||
# ── Baidu (CN) ────────────────────────────────────────
|
||||
(re.compile(r"Baiduspider/[\d.]+", re.IGNORECASE), "Baidu", "search"),
|
||||
(re.compile(r"Baiduboxapp", re.IGNORECASE), "Baidu", "search"),
|
||||
|
||||
# ── Sogou (CN) ────────────────────────────────────────
|
||||
(re.compile(r"Sogou [Ss]pider", re.IGNORECASE), "Sogou", "search"),
|
||||
(re.compile(r"Sogou web spider", re.IGNORECASE), "Sogou", "search"),
|
||||
|
||||
# ── Apple ─────────────────────────────────────────────
|
||||
(re.compile(r"Applebot/[\d.]+", re.IGNORECASE), "Apple", "search"),
|
||||
(re.compile(r"Applebot-Extended", re.IGNORECASE), "Apple", "search"),
|
||||
|
||||
# ── PetleO (Japan) ────────────────────────────────────
|
||||
(re.compile(r"petalbot", re.IGNORECASE), "PetleO", "search"),
|
||||
|
||||
# ── Internet Archive ──────────────────────────────────
|
||||
(re.compile(r"ia_archiver", re.IGNORECASE), "IA Archiver", "other"),
|
||||
(re.compile(r"archive\.org_bot", re.IGNORECASE), "IA Archiver", "other"),
|
||||
|
||||
# ── Social media bots ─────────────────────────────────
|
||||
(re.compile(r"facebookexternalhit/[\d.]+", re.IGNORECASE), "Facebook", "social"),
|
||||
(re.compile(r"facebookplatform", re.IGNORECASE), "Facebook", "social"),
|
||||
(re.compile(r"Twitterbot/[\d.]+", re.IGNORECASE), "Twitter", "social"),
|
||||
(re.compile(r"LinkedInBot/[\d.]+", re.IGNORECASE), "LinkedIn", "social"),
|
||||
(re.compile(r"W3C_Validator", re.IGNORECASE), "W3C", "other"),
|
||||
(re.compile(r"WhatsApp/[\d.]+", re.IGNORECASE), "WhatsApp", "social"),
|
||||
|
||||
# ── Generic / catch-all bot signatures ────────────────────────
|
||||
# These are last to reduce false positives on real browsers that
|
||||
# happen to include the word "bot" in extension names.
|
||||
# Standard bot naming convention: SomeBot/X.Y
|
||||
(re.compile(r"Bot/[\d.]", re.IGNORECASE), "Other", "other"),
|
||||
# Semicolon-delimited bot markers: "; bot" or ";bot"
|
||||
(re.compile(r";\s*bot\s*;?", re.IGNORECASE), "Other", "other"),
|
||||
# Spider anywhere in the UA
|
||||
(re.compile(r"spider", re.IGNORECASE), "Other", "other"),
|
||||
# Crawler anywhere in the UA
|
||||
(re.compile(r"crawler", re.IGNORECASE), "Other", "other"),
|
||||
]
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────
|
||||
|
||||
def is_spider(user_agent: str) -> bool:
|
||||
"""Return True if the User-Agent string belongs to a known spider/bot.
|
||||
|
||||
This is a fast check — use `get_spider_info()` when you need details.
|
||||
|
||||
Args:
|
||||
user_agent: The raw User-Agent header value.
|
||||
|
||||
Returns:
|
||||
True if a spider pattern matches, False for normal browsers.
|
||||
"""
|
||||
if not user_agent:
|
||||
return False
|
||||
for pattern, _, _ in _SPIDER_PATTERNS:
|
||||
if pattern.search(user_agent):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_spider_info(user_agent: str) -> Optional[SpiderInfo]:
|
||||
"""Identify a spider from a User-Agent string.
|
||||
|
||||
Returns the first matching SpiderInfo or None if the UA does not
|
||||
belong to a recognized bot.
|
||||
|
||||
Args:
|
||||
user_agent: The raw User-Agent header value.
|
||||
|
||||
Returns:
|
||||
SpiderInfo with engine/name/category, or None for regular browsers.
|
||||
"""
|
||||
if not user_agent:
|
||||
return None
|
||||
for pattern, engine, category in _SPIDER_PATTERNS:
|
||||
match = pattern.search(user_agent)
|
||||
if match:
|
||||
return SpiderInfo(engine=engine, name=match.group(0), category=category)
|
||||
return None
|
||||
|
||||
|
||||
def is_search_spider(user_agent: str) -> bool:
|
||||
"""Return True only for search-engine crawlers (not social bots).
|
||||
|
||||
Useful when you need SSR/pre-rendering only for SEO-relevant crawlers.
|
||||
|
||||
Args:
|
||||
user_agent: The raw User-Agent header value.
|
||||
|
||||
Returns:
|
||||
True only if a search-engine spider pattern matches.
|
||||
"""
|
||||
info = get_spider_info(user_agent)
|
||||
return info.is_search_spider if info else False
|
||||
|
||||
|
||||
# ── FastAPI middleware helper ────────────────────────────────
|
||||
|
||||
def get_user_agent(headers: dict) -> str:
|
||||
"""Extract the User-Agent string from a headers dict (case-insensitive).
|
||||
|
||||
Args:
|
||||
headers: A dict-like mapping of header names to values.
|
||||
|
||||
Returns:
|
||||
The User-Agent value, empty string if not present.
|
||||
"""
|
||||
for key, value in headers.items():
|
||||
if key.lower() == "user-agent":
|
||||
return value
|
||||
return ""
|
||||
@@ -1,12 +1,38 @@
|
||||
"""Tests for sitemap API endpoints — /api/sitemap/sitemap.xml, /api/sitemap/robots.txt, /api/sitemap/regenerate."""
|
||||
"""Tests for sitemap API endpoints — /api/sitemap/sitemap.xml, /api/sitemap/robots.txt, /api/sitemap/regenerate.
|
||||
|
||||
Note: The sitemap and robots.txt endpoints now read the site URL from
|
||||
StationConfig.website in the database (falling back to KMTN_WEBSITE_URL
|
||||
if the config is missing). These tests assume a seeded database with the
|
||||
default station config (website = 'kmountainflower.org').
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.api.sitemap import _build_base_url
|
||||
from app.main import app
|
||||
|
||||
|
||||
class TestBuildBaseUrl:
|
||||
"""Tests for the _build_base_url helper."""
|
||||
|
||||
def test_bare_domain_gets_https(self):
|
||||
assert _build_base_url("kmountainflower.org") == "https://kmountainflower.org"
|
||||
|
||||
def test_domain_with_trailing_slash(self):
|
||||
assert _build_base_url("kmountainflower.org/") == "https://kmountainflower.org"
|
||||
|
||||
def test_domain_with_whitespace(self):
|
||||
assert _build_base_url(" mystation.org ") == "https://mystation.org"
|
||||
|
||||
def test_already_has_https_scheme(self):
|
||||
assert _build_base_url("https://kmountainflower.org") == "https://kmountainflower.org"
|
||||
|
||||
def test_already_has_http_scheme(self):
|
||||
assert _build_base_url("http://kmountainflower.org") == "http://kmountainflower.org"
|
||||
|
||||
|
||||
class TestSitemapEndpoints:
|
||||
"""Tests for sitemap API routes."""
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Unit tests for spider/bot User-Agent detection."""
|
||||
|
||||
import pytest
|
||||
from app.spider_detection import (
|
||||
get_spider_info,
|
||||
get_user_agent,
|
||||
is_search_spider,
|
||||
is_spider,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Known spider User-Agents (should return True)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
SPIDER_UA = [
|
||||
# Google family
|
||||
("Googlebot (desktop)", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"),
|
||||
("Googlebot Mobile", "Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)"),
|
||||
("Googlebot Image", "Mozilla/5.0 (compatible; Googlebot-Image/1.0; +http://www.google.com/bot.html)"),
|
||||
("Googlebot News", "Mozilla/5.0 (compatible; Googlebot-News; +http://www.google.com/bot.html)"),
|
||||
("Google Ads", "Mozilla/5.0 (compatible; Mediapartners-Google/2.1; +http://www.google.com/bot.html)"),
|
||||
("Storebot Google", "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Mobile Safari/537.36 (compatible; Storebot-Google/1.0)"),
|
||||
("Google AdSense", "Google-AdSense-TopContent"),
|
||||
|
||||
# Bing family
|
||||
("Bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"),
|
||||
("MSNBot", "Mozilla/5.0 (compatible; msnbot/2.0b; +http://search.msn.com/msnbot.htm)"),
|
||||
("MSNBot Media", "Mozilla/5.0 (compatible; msnbot-media/1.1; +http://search.msn.com/msnbot.htm)"),
|
||||
("Bing Ads Bot", "Mozilla/5.0 (compatible; bingadsbot/2.0; +http://www.bing.com/bingbot.htm)"),
|
||||
|
||||
# Yahoo
|
||||
("Yahoo Slurp", "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)"),
|
||||
("Yahoo Deer Spider", "Mozilla/5.0 (compatible; Yahoo! Deer Spider; http://help.yahoo.com/help/us/ysearch/crawlers)"),
|
||||
|
||||
# DuckDuckGo
|
||||
("DuckDuckBot", "DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)"),
|
||||
|
||||
# Yandex
|
||||
("YandexBot", "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)"),
|
||||
("Yandex Images", "Mozilla/5.0 (compatible; YandexImages/3.0; +http://yandex.com/bots)"),
|
||||
("Yandex Mobile Bot", "Mozilla/5.0 (compatible; YandexMobileBot/3.0; +http://yandex.com/bots)"),
|
||||
("Yandex Metrika", "Mozilla/5.0 (compatible; YandexMetrika/7.0; +http://yandex.com/bots)"),
|
||||
("Yandex Direct", "Mozilla/5.0 compatible; YandexDirect/3.0; +http://yandex.com/bots)"),
|
||||
|
||||
# Baidu
|
||||
("Baiduspider", "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"),
|
||||
("Baiduboxapp", "Mozilla/5.0 (compatible; Baiduboxapp; +http://www.baidu.com/search/spider.html)"),
|
||||
|
||||
# Sogou
|
||||
("Sogou Spider", "Mozilla/5.0 (compatible; Sogou web spider/4.0; +http://www.sogou.com/docs/help/webmasters.htm)"),
|
||||
("Sogou Spider (alt)", "Sogou Spider/4.0"),
|
||||
|
||||
# Apple
|
||||
("Applebot", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML, like Gecko) Version/8.0.2 Safari/600.2.5 (Applebot/0.1)"),
|
||||
("Applebot-Extended", "Mozilla/5.0 (compatible; Applebot-Extended/0.2; +https://www.apple.com/go/applebot-extended)"),
|
||||
|
||||
# PetleO (PetalBot)
|
||||
("PetalBot", "Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/)"),
|
||||
|
||||
# Internet Archive
|
||||
("IA Archiver", "Mozilla/5.0 (compatible; archive.org_bot +http://archive.org/details/archive.org_bot)"),
|
||||
("ia_archiver", "ia_archiver-web.common-crawl"),
|
||||
|
||||
# Social bots
|
||||
("Facebook External Hit", "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"),
|
||||
("Facebook Platform", "facebookplatform"),
|
||||
("Twitterbot", "Twitterbot/1.0"),
|
||||
("LinkedInBot", "LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +https://www.linkedin.com)"),
|
||||
("WhatsApp", "WhatsApp/2.21.1"),
|
||||
|
||||
# Generic bot/crawler signatures (case variants)
|
||||
("Generic bot (semicolon)", "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"),
|
||||
("Generic spider", "Mozilla/5.0 (compatible; Spider/1.0)"),
|
||||
("Generic crawler", "MyCrawler/1.0"),
|
||||
]
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Known browser User-Agents (should return False)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
BROWSER_UA = [
|
||||
("Chrome (Windows)", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
("Chrome (Linux)", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
("Chrome (macOS)", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
("Chrome (Android)", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"),
|
||||
("Chrome (iPhone)", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1"),
|
||||
|
||||
("Firefox (Windows)", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"),
|
||||
("Firefox (Linux)", "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"),
|
||||
("Firefox (Android)", "Mozilla/5.0 (Android 13; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0"),
|
||||
|
||||
("Safari (macOS)", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"),
|
||||
("Safari (iPhone)", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"),
|
||||
("Safari (iPad)", "Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"),
|
||||
|
||||
("Edge (Windows)", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91"),
|
||||
|
||||
("Opera", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/105.0.0.0"),
|
||||
|
||||
("Samsung Internet", "Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/120.0.0.0 Mobile Safari/537.36"),
|
||||
]
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Tests
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsSpider:
|
||||
"""is_spider() should return True for known spider UAs and False for browsers."""
|
||||
|
||||
@pytest.mark.parametrize("name,ua", SPIDER_UA)
|
||||
def test_known_spiders_detected(self, name: str, ua: str):
|
||||
assert is_spider(ua) is True, f"{name} should be detected as a spider"
|
||||
|
||||
@pytest.mark.parametrize("name,ua", BROWSER_UA)
|
||||
def test_browsers_not_detected(self, name: str, ua: str):
|
||||
assert is_spider(ua) is False, f"{name} should NOT be detected as a spider"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert is_spider("") is False
|
||||
|
||||
def test_none_like_missing_header(self):
|
||||
assert is_spider(" ") is False
|
||||
|
||||
|
||||
class TestGetSpiderInfo:
|
||||
"""get_spider_info() should return SpiderInfo for spiders, None for browsers."""
|
||||
|
||||
@pytest.mark.parametrize("name,ua", SPIDER_UA)
|
||||
def test_spider_returns_info(self, name: str, ua: str):
|
||||
info = get_spider_info(ua)
|
||||
assert info is not None, f"{name}: expected SpiderInfo, got None"
|
||||
assert info.name, f"{name}: SpiderInfo.name should not be empty"
|
||||
assert info.engine, f"{name}: SpiderInfo.engine should not be empty"
|
||||
assert info.category in ("search", "social", "other"), (
|
||||
f"{name}: unknown category {info.category!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("name,ua", BROWSER_UA)
|
||||
def test_browser_returns_none(self, name: str, ua: str):
|
||||
assert get_spider_info(ua) is None, f"{name} should return None"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert get_spider_info("") is None
|
||||
|
||||
|
||||
class TestIsSearchSpider:
|
||||
"""is_search_spider() should distinguish search crawlers from social bots."""
|
||||
|
||||
SEARCH_SPIDER_UA = [
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
||||
"DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)",
|
||||
"Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)",
|
||||
"Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)",
|
||||
"Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)",
|
||||
]
|
||||
|
||||
SOCIAL_BOT_UA = [
|
||||
"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
|
||||
"Twitterbot/1.0",
|
||||
"LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +https://www.linkedin.com)",
|
||||
"WhatsApp/2.21.1",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("ua", SEARCH_SPIDER_UA)
|
||||
def test_search_spiders_return_true(self, ua: str):
|
||||
assert is_search_spider(ua) is True
|
||||
|
||||
@pytest.mark.parametrize("ua", SOCIAL_BOT_UA)
|
||||
def test_social_bots_return_false(self, ua: str):
|
||||
assert is_search_spider(ua) is False
|
||||
|
||||
@pytest.mark.parametrize("name,ua", BROWSER_UA)
|
||||
def test_browsers_return_false(self, name: str, ua: str):
|
||||
assert is_search_spider(ua) is False, f"{name} should not be a search spider"
|
||||
|
||||
|
||||
class TestSpiderInfoDataclass:
|
||||
"""SpiderInfo properties should be correct."""
|
||||
|
||||
def test_is_search_spider_property(self):
|
||||
info = get_spider_info("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
|
||||
assert info is not None
|
||||
assert info.is_search_spider is True
|
||||
assert info.is_social_bot is False
|
||||
|
||||
def test_is_social_bot_property(self):
|
||||
info = get_spider_info("facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)")
|
||||
assert info is not None
|
||||
assert info.is_social_bot is True
|
||||
assert info.is_search_spider is False
|
||||
|
||||
def test_spider_info_frozen(self):
|
||||
info = get_spider_info("Mozilla/5.0 (compatible; bingbot/2.0)")
|
||||
assert info is not None
|
||||
with pytest.raises(Exception): # FrozenInstanceError in Python 3.11+, AttributeError before
|
||||
info.engine = "modified" # type: ignore
|
||||
|
||||
|
||||
class TestGetUserAgent:
|
||||
"""get_user_agent() should extract UA from headers dict case-insensitively."""
|
||||
|
||||
def test_standard_case(self):
|
||||
headers = {"User-Agent": "test-agent"}
|
||||
assert get_user_agent(headers) == "test-agent"
|
||||
|
||||
def test_lower_case_key(self):
|
||||
headers = {"user-agent": "test-agent"}
|
||||
assert get_user_agent(headers) == "test-agent"
|
||||
|
||||
def test_upper_case_key(self):
|
||||
headers = {"USER-AGENT": "test-agent"}
|
||||
assert get_user_agent(headers) == "test-agent"
|
||||
|
||||
def test_missing_header(self):
|
||||
headers = {"Content-Type": "text/html"}
|
||||
assert get_user_agent(headers) == ""
|
||||
|
||||
def test_empty_headers(self):
|
||||
assert get_user_agent({}) == ""
|
||||
|
||||
def test_combined_with_is_spider(self):
|
||||
headers = {
|
||||
"Host": "example.com",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||
"Accept": "text/html",
|
||||
}
|
||||
ua = get_user_agent(headers)
|
||||
assert is_spider(ua) is True
|
||||
|
||||
|
||||
class TestCaseInsensitivity:
|
||||
"""Spider detection patterns should be case-insensitive."""
|
||||
|
||||
def test_googlebot_uppercase(self):
|
||||
assert is_spider("GOOGLEBOT/2.1") is True
|
||||
|
||||
def test_googlebot_mixed_case(self):
|
||||
assert is_spider("googleBot/2.1") is True
|
||||
|
||||
def test_bingbot_uppercase(self):
|
||||
assert is_spider("BINGBOT/2.0") is True
|
||||
|
||||
def test_baiduspider_lowercase(self):
|
||||
assert is_spider("baiduspider/2.0") is True
|
||||
|
||||
def test_facebook_mixed(self):
|
||||
assert is_spider("FaceBookExternalHit/1.1") is True
|
||||
|
||||
|
||||
class TestSpecificSpiderDetails:
|
||||
"""Verify engine and category are correctly assigned for specific bots."""
|
||||
|
||||
def test_googlebot_engine(self):
|
||||
info = get_spider_info("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
|
||||
assert info is not None
|
||||
assert info.engine == "Google"
|
||||
assert info.category == "search"
|
||||
|
||||
def test_bingbot_engine(self):
|
||||
info = get_spider_info("Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)")
|
||||
assert info is not None
|
||||
assert info.engine == "Bing"
|
||||
assert info.category == "search"
|
||||
|
||||
def test_facebook_category(self):
|
||||
info = get_spider_info("facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)")
|
||||
assert info is not None
|
||||
assert info.engine == "Facebook"
|
||||
assert info.category == "social"
|
||||
|
||||
def test_twitter_category(self):
|
||||
info = get_spider_info("Twitterbot/1.0")
|
||||
assert info is not None
|
||||
assert info.engine == "Twitter"
|
||||
assert info.category == "social"
|
||||
|
||||
def test_yandex_engine(self):
|
||||
info = get_spider_info("Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)")
|
||||
assert info is not None
|
||||
assert info.engine == "Yandex"
|
||||
assert info.category == "search"
|
||||
|
||||
def test_apple_engine(self):
|
||||
info = get_spider_info("Mozilla/5.0 (compatible; Applebot/0.1; +https://www.apple.com/go/applebot)")
|
||||
assert info is not None
|
||||
assert info.engine == "Apple"
|
||||
assert info.category == "search"
|
||||
+27
-2
@@ -1,11 +1,36 @@
|
||||
#!/bin/sh
|
||||
# Render config templates with environment variables, then start nginx.
|
||||
# Start the Angular SSR Node process, then nginx.
|
||||
set -e
|
||||
|
||||
# Ensure nginx can write to the shared logs directory
|
||||
mkdir -p /logs && chown nginx:nginx /logs
|
||||
|
||||
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf
|
||||
# Render nginx config from template to /etc/nginx/nginx.conf
|
||||
# (full config, not conf.d/ snippet — avoids Alpine include nesting issues)
|
||||
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/nginx.conf
|
||||
|
||||
# Render client-side config.json for fallback use
|
||||
envsubst '${API_PUBLIC_URL}' < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json
|
||||
|
||||
# Start the Angular SSR server (Node process) in the background
|
||||
# Point at nginx (port 80) so SSR API calls go through the nginx proxy chain
|
||||
# to the upstream backend. Nginx is started after SSR so the chain is complete
|
||||
# by the time the first SSR request actually needs API data.
|
||||
export SSR_API_BASE_URL="http://localhost:80"
|
||||
echo "[entrypoint] Starting Angular SSR server on port 4200..."
|
||||
node /app/server/server.mjs &
|
||||
SSR_PID=$!
|
||||
|
||||
# Wait for the SSR server to be ready (up to 10 seconds)
|
||||
echo "[entrypoint] Waiting for SSR server to be ready..."
|
||||
for i in $(seq 1 20); do
|
||||
if node -e "require('http').get('http://localhost:4200/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" 2>/dev/null; then
|
||||
echo "[entrypoint] SSR server is ready."
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Start nginx in the foreground (primary process)
|
||||
echo "[entrypoint] Starting nginx..."
|
||||
exec nginx -g 'daemon off;'
|
||||
+50
-14
@@ -1,30 +1,47 @@
|
||||
# Map ISO timestamp to date-only string for daily log rotation
|
||||
map $time_iso8601 $log_date {
|
||||
"~^(?<date>\d{4}-\d{2}-\d{2})" $date;
|
||||
default "unknown";
|
||||
# Full nginx configuration — rendered to /etc/nginx/nginx.conf
|
||||
# (NOT placed in conf.d/ to avoid include nesting issues with Alpine nginx)
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
log_format kmtn_json escape=json
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format kmtn_json escape=json
|
||||
'{'
|
||||
'"time":"$time_iso8601",'
|
||||
'"remote_addr":"$remote_addr",'
|
||||
'"x_forwarded_for":"$http_x_forwarded_for"'
|
||||
'}';
|
||||
|
||||
access_log /logs/access-$log_date.log kmtn_json;
|
||||
access_log /logs/access.log kmtn_json;
|
||||
sendfile on;
|
||||
|
||||
server {
|
||||
upstream ssr_backend {
|
||||
server 127.0.0.1:4200;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — Angular router handles its own paths
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Serve static assets directly from disk (hashed filenames are immutable)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Dynamic sitemap.xml + robots.txt — proxied to the backend so each
|
||||
# Dynamic sitemap.xml + robots.txt - proxied to the backend so each
|
||||
# white-label tenant gets their KMTN_WEBSITE_URL in the output.
|
||||
location = /sitemap.xml {
|
||||
proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml;
|
||||
@@ -53,12 +70,31 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Disable caching through the double-proxy chain (NPM → frontend nginx → API)
|
||||
# Without this, browsers cache responses keyed by the HTTPS origin, then send
|
||||
# conditional requests (If-None-Match) that get lost or mismatched in the proxy chain.
|
||||
# Disable caching through the double-proxy chain
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Vary "*" always;
|
||||
}
|
||||
|
||||
# Proxy all page requests to the Angular SSR server.
|
||||
# The SSR server handles both spider SSR rendering and serves CSR shells
|
||||
# to regular users, with fresh station config injected at runtime.
|
||||
location / {
|
||||
proxy_pass http://ssr_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# SSR responses should not be cached by intermediate proxies
|
||||
proxy_cache off;
|
||||
add_header X-Powered-By "Angular SSR" always;
|
||||
|
||||
# Prevent browsers from caching HTML pages — content changes frequently
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+622
-577
File diff suppressed because it is too large.
Load diff
+10
-4
@@ -5,23 +5,28 @@
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:ssr": "ng build",
|
||||
"build:prerender": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "vitest run --coverage"
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"serve:ssr:radio-station": "node dist/radio-station/server/server.mjs"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.11.0",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^21.2.0",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/platform-server": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/ssr": "^21.2.0",
|
||||
"@types/leaflet": "^1.9.17",
|
||||
"@angular/cdk": "^21.2.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ng2-charts": "^10.0.0",
|
||||
@@ -34,11 +39,12 @@
|
||||
"@angular/build": "^21.2.14",
|
||||
"@angular/cli": "^21.2.14",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^26.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.8",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.8",
|
||||
"@types/jsdom": "^21.1.7"
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/
|
||||
/about
|
||||
/schedule
|
||||
/events
|
||||
/contact
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||
import '@angular/compiler';
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the @angular/ssr/node imports that pull in Node.js http server code
|
||||
// — we only want to test the request handler logic here.
|
||||
vi.mock('@angular/ssr/node', () => ({
|
||||
AngularNodeAppEngine: class {},
|
||||
createNodeRequestHandler: (fn: Function) => fn,
|
||||
isMainModule: () => false,
|
||||
writeResponseToNodeResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:http', () => ({
|
||||
default: { createServer: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
default: { readFileSync: vi.fn(() => '<!DOCTYPE html><html>static shell</html>') },
|
||||
readFileSync: vi.fn(() => '<!DOCTYPE html><html>static shell</html>'),
|
||||
}));
|
||||
|
||||
vi.mock('node:path', () => ({
|
||||
default: { join: (...args: string[]) => args.join('/'), dirname: () => '/app/server' },
|
||||
join: (...args: string[]) => args.join('/'),
|
||||
dirname: () => '/app/server',
|
||||
}));
|
||||
|
||||
// Mock the engine manifest that only exists after ng build
|
||||
vi.mock('./angular-app-engine-manifest.mjs', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
describe('SSR server — server.ts', () => {
|
||||
it('should export a request handler', async () => {
|
||||
const mod = await import('./server');
|
||||
expect(mod.requestHandler).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have spider detection imports and serveCsrShell', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('isSpider');
|
||||
expect(content).toContain('getUserAgent');
|
||||
expect(content).toContain('serveCsrShell');
|
||||
expect(content).toContain('spider_detection');
|
||||
});
|
||||
|
||||
it('should gate SSR behind spider detection', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
// Verify the spider detection gate exists
|
||||
expect(content).toContain("if (!isSpider(userAgent))");
|
||||
expect(content).toContain('serveCsrShell(res)');
|
||||
});
|
||||
|
||||
it('should inject fresh station config as window.__STATION_CONFIG', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('window.__STATION_CONFIG');
|
||||
expect(content).toContain('buildConfigScript');
|
||||
expect(content).toContain('fetchStationConfig');
|
||||
});
|
||||
|
||||
it('should inject inline theme CSS custom properties', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('buildThemeStyle');
|
||||
expect(content).toContain('COLOR_MAP');
|
||||
expect(content).toContain('--color-primary');
|
||||
expect(content).toContain(':root{');
|
||||
});
|
||||
|
||||
it('should serveCsrShell be async and accept (res)', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toMatch(/async function serveCsrShell\s*\(\s*res/);
|
||||
});
|
||||
|
||||
it('should fetch station config from SSR_API_BASE_URL with 2s timeout', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('SSR_API_BASE_URL');
|
||||
expect(content).toContain('api/station-config');
|
||||
expect(content).toContain('setTimeout(2000');
|
||||
});
|
||||
|
||||
it('should set no-cache headers on CSR shell responses', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain("Cache-Control");
|
||||
expect(content).toContain("no-cache, no-store, must-revalidate");
|
||||
});
|
||||
});
|
||||
|
||||
describe('spider_detection.ts — TypeScript spider detection', () => {
|
||||
it('should detect Googlebot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Bingbot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect DuckDuckBot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect YandexBot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Baiduspider', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Applebot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; Applebot/0.1; +https://www.apple.com/go/applebot)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Facebook bot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Twitterbot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Twitterbot/1.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect Chrome', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect Firefox', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect Safari', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect Edge', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect mobile Safari', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty string', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('')).toBe(false);
|
||||
});
|
||||
|
||||
it('isSearchSpider should distinguish search from social bots', async () => {
|
||||
const { isSearchSpider, isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('facebookexternalhit/1.1')).toBe(true);
|
||||
expect(isSearchSpider('facebookexternalhit/1.1')).toBe(false);
|
||||
expect(isSearchSpider('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe(true);
|
||||
});
|
||||
|
||||
it('getSpiderInfo should return SpiderInfo for known bots', async () => {
|
||||
const { getSpiderInfo } = await import('./spider_detection');
|
||||
const info = getSpiderInfo('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');
|
||||
expect(info).not.toBeNull();
|
||||
expect(info?.engine).toBe('Google');
|
||||
expect(info?.category).toBe('search');
|
||||
});
|
||||
|
||||
it('getSpiderInfo should return null for browsers', async () => {
|
||||
const { getSpiderInfo } = await import('./spider_detection');
|
||||
expect(getSpiderInfo('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0')).toBeNull();
|
||||
});
|
||||
|
||||
it('getUserAgent should extract UA from request headers', async () => {
|
||||
const { getUserAgent } = await import('./spider_detection');
|
||||
const headers: Record<string, string | string[] | undefined> = {
|
||||
'user-agent': 'TestBot/1.0',
|
||||
'host': 'example.com',
|
||||
};
|
||||
expect(getUserAgent(headers)).toBe('TestBot/1.0');
|
||||
});
|
||||
|
||||
it('getUserAgent should handle missing UA header', async () => {
|
||||
const { getUserAgent } = await import('./spider_detection');
|
||||
expect(getUserAgent({ host: 'example.com' })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app.server.routes.ts — RenderMode configuration', () => {
|
||||
it('should use RenderMode.Server for all routes (SSR server handles routing)', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
|
||||
|
||||
// Should have RenderMode.Server entries
|
||||
const serverMatches = content.match(/RenderMode\.Server/g);
|
||||
expect(serverMatches).not.toBeNull();
|
||||
expect(serverMatches.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('should have no Prerender routes (eliminates stale baked-in DEFAULT_CONFIG)', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
|
||||
expect(content).not.toContain('RenderMode.Prerender');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker-entrypoint.sh — SSR + nginx lifecycle', () => {
|
||||
it('should be executable', () => {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync('./docker-entrypoint.sh');
|
||||
expect(stat.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('should start the SSR node process before nginx', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
|
||||
// SSR must start before nginx
|
||||
const ssrStart = content.indexOf('node /app/server/server.mjs');
|
||||
const nginxStart = content.indexOf('exec nginx');
|
||||
expect(ssrStart).toBeGreaterThan(-1);
|
||||
expect(nginxStart).toBeGreaterThan(-1);
|
||||
expect(ssrStart).toBeLessThan(nginxStart);
|
||||
});
|
||||
|
||||
it('should set SSR_API_BASE_URL for the SSR server', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
|
||||
expect(content).toContain('SSR_API_BASE_URL');
|
||||
expect(content).toContain('http://localhost:80');
|
||||
});
|
||||
|
||||
it('should wait for SSR server readiness before starting nginx', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
|
||||
expect(content).toContain('Waiting for SSR server to be ready');
|
||||
expect(content).toContain('localhost:4200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dockerfile — multi-stage build with SSR server', () => {
|
||||
it('should copy the server bundle in addition to browser output', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
expect(content).toMatch(/COPY.*server.*\/app\/server/);
|
||||
});
|
||||
|
||||
it('should use a Node.js base image for the serve stage', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
// Second FROM should be Node-based (not plain nginx)
|
||||
const fromLines = content.split('\n').filter(l => l.startsWith('FROM '));
|
||||
expect(fromLines.length).toBeGreaterThanOrEqual(2);
|
||||
expect(fromLines[1]).toContain('node');
|
||||
});
|
||||
|
||||
it('should install nginx in the serve stage', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
expect(content).toContain('apk add --no-cache nginx');
|
||||
});
|
||||
|
||||
it('should expose port 80', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./Dockerfile', 'utf-8');
|
||||
expect(content).toContain('EXPOSE 80');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nginx.conf.template — SSR proxy chain', () => {
|
||||
it('should define an upstream pointing to SSR server on port 4200', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('upstream ssr_backend');
|
||||
expect(content).toContain('127.0.0.1:4200');
|
||||
});
|
||||
|
||||
it('should proxy page requests to the SSR backend', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('proxy_pass http://ssr_backend');
|
||||
});
|
||||
|
||||
it('should serve static assets directly from disk with long cache', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('location ~*');
|
||||
expect(content).toContain('js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map');
|
||||
expect(content).toContain('expires 1y');
|
||||
expect(content).toContain('public, immutable');
|
||||
});
|
||||
|
||||
it('should still proxy /api/ to the upstream backend', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('location /api/');
|
||||
expect(content).toContain('__API_UPSTREAM__');
|
||||
});
|
||||
|
||||
it('should add Cache-Control no-cache headers on HTML page responses', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
|
||||
expect(content).toContain('add_header Cache-Control "no-cache, no-store, must-revalidate" always');
|
||||
expect(content).toContain('add_header Pragma "no-cache" always');
|
||||
});
|
||||
});
|
||||
|
||||
describe('main.server.ts — SSR API base URL injection', () => {
|
||||
it('should override APP_CONFIG with SSR-aware apiBaseUrl', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
|
||||
expect(content).toContain('SSR_API_BASE_URL');
|
||||
expect(content).toContain('ssrAppConfig');
|
||||
expect(content).toContain('provide: APP_CONFIG');
|
||||
});
|
||||
|
||||
it('should read SSR_API_BASE_URL from environment with fallback', () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
|
||||
expect(content).toContain("process.env['SSR_API_BASE_URL']");
|
||||
expect(content).toContain("http://localhost:80");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
AngularNodeAppEngine,
|
||||
createNodeRequestHandler,
|
||||
isMainModule,
|
||||
writeResponseToNodeResponse,
|
||||
} from '@angular/ssr/node';
|
||||
import * as fs from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as path from 'node:path';
|
||||
import { isSpider, getUserAgent } from './spider_detection';
|
||||
|
||||
/**
|
||||
* Manually import and register the Angular app engine manifest.
|
||||
* The Angular 21 builder generates angular-app-engine-manifest.mjs at build time
|
||||
* but does NOT automatically inject the import into server.mjs.
|
||||
* Without this, AngularNodeAppEngine fails with "Angular app engine manifest is not set"
|
||||
* and every SSR request returns 404.
|
||||
*/
|
||||
// @ts-ignore — generated at build time by @angular/build
|
||||
import engineManifest from './angular-app-engine-manifest.mjs';
|
||||
import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
|
||||
ɵsetAngularAppEngineManifest(engineManifest);
|
||||
|
||||
const appEngine = new AngularNodeAppEngine();
|
||||
|
||||
/**
|
||||
* Station config shape — mirrors the backend API response.
|
||||
* Only the color fields are used for theme injection; the rest are
|
||||
* passed through verbatim for the client-side StationConfigService.
|
||||
*/
|
||||
interface StationConfig {
|
||||
color_primary: string;
|
||||
color_primary_light: string;
|
||||
color_primary_dark: string;
|
||||
color_primary_muted: string;
|
||||
color_accent: string;
|
||||
color_accent_light: string;
|
||||
color_accent_dark: string;
|
||||
color_accent_muted: string;
|
||||
color_background: string;
|
||||
color_text: string;
|
||||
color_success: string;
|
||||
color_danger: string;
|
||||
color_info: string;
|
||||
color_white: string;
|
||||
color_light: string;
|
||||
color_medium: string;
|
||||
color_black: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** CSS custom property names mapped from StationConfig color fields. */
|
||||
const COLOR_MAP: ReadonlyArray<{ field: keyof StationConfig; prop: string }> = [
|
||||
{ field: 'color_primary', prop: '--color-primary' },
|
||||
{ field: 'color_primary_light', prop: '--color-primary-light' },
|
||||
{ field: 'color_primary_dark', prop: '--color-primary-dark' },
|
||||
{ field: 'color_primary_muted', prop: '--color-primary-muted' },
|
||||
{ field: 'color_accent', prop: '--color-accent' },
|
||||
{ field: 'color_accent_light', prop: '--color-accent-light' },
|
||||
{ field: 'color_accent_dark', prop: '--color-accent-dark' },
|
||||
{ field: 'color_accent_muted', prop: '--color-accent-muted' },
|
||||
{ field: 'color_background', prop: '--color-cream' },
|
||||
{ field: 'color_text', prop: '--color-dark' },
|
||||
{ field: 'color_success', prop: '--color-success' },
|
||||
{ field: 'color_danger', prop: '--color-danger' },
|
||||
{ field: 'color_info', prop: '--color-info' },
|
||||
{ field: 'color_white', prop: '--color-white' },
|
||||
{ field: 'color_light', prop: '--color-light' },
|
||||
{ field: 'color_medium', prop: '--color-medium' },
|
||||
{ field: 'color_black', prop: '--color-black' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the static CSR shell path (index.csr.html).
|
||||
*
|
||||
* IMPORTANT: We MUST prefer index.csr.html over index.html.
|
||||
* The Angular build with prerender config produces both:
|
||||
* - index.html: prerendered at build time with stale DEFAULT_CONFIG baked in
|
||||
* - index.csr.html: pure CSR shell with empty <app-root> — Angular hydrates client-side
|
||||
*
|
||||
* Serving index.html to regular users causes the flash: the prerendered DOM
|
||||
* shows stale build-time content until Angular re-renders with fresh data.
|
||||
* Even with window.__STATION_CONFIG injection, the prerendered innerHTML inside
|
||||
* <app-root> still shows old content on the first paint.
|
||||
*
|
||||
* Production (Docker): browser files are copied to /usr/share/nginx/html/
|
||||
* Local dev (ng build): browser files live in dist/radio-station/browser/
|
||||
* Try each candidate in order and return the first that exists.
|
||||
*/
|
||||
function browserIndexPath(): string {
|
||||
// Production path — Dockerfile copies browser output here
|
||||
// CSR shell first — avoid prerendered index.html with stale content
|
||||
const productionPaths = [
|
||||
'/usr/share/nginx/html/index.csr.html',
|
||||
'/usr/share/nginx/html/index.html',
|
||||
'/var/www/html/index.csr.html',
|
||||
'/var/www/html/index.html',
|
||||
];
|
||||
|
||||
// Local dev path — relative to the compiled server directory
|
||||
const dir = typeof __dirname !== 'undefined' ? __dirname : path.dirname(new URL(import.meta.url).pathname);
|
||||
const localPaths = [
|
||||
path.join(dir, 'browser', 'index.csr.html'),
|
||||
path.join(dir, 'browser', 'index.html'),
|
||||
path.join(dir, '..', 'browser', 'index.csr.html'),
|
||||
path.join(dir, '..', 'browser', 'index.html'),
|
||||
];
|
||||
|
||||
const allCandidates = [...productionPaths, ...localPaths];
|
||||
for (const candidate of allCandidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// ignore — keep trying
|
||||
}
|
||||
}
|
||||
// Return the first candidate so the error path has a sensible value to log
|
||||
return allCandidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an inline `<script>` that seeds `window.__STATION_CONFIG` so the
|
||||
* client-side StationConfigService can read fresh config synchronously
|
||||
* during bootstrap — no async API round-trip needed, no DEFAULT_CONFIG flash.
|
||||
*/
|
||||
function buildConfigScript(config: StationConfig): string {
|
||||
const json = JSON.stringify(config);
|
||||
return `<script>window.__STATION_CONFIG=${json};</script>`;
|
||||
}
|
||||
|
||||
/** Build a gradient CSS value from three color stops. */
|
||||
function gradient(from: string, mid: string, to: string): string {
|
||||
return `linear-gradient(135deg, ${from} 0%, ${mid} 40%, ${to} 100%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inline CSS custom properties on `:root` so the first paint carries
|
||||
* the correct color scheme — eliminates the color flash that occurs when
|
||||
* ThemeService.apply() runs asynchronously after the API call.
|
||||
*
|
||||
* Includes both flat color properties AND derived gradient variables
|
||||
* (--sky-gradient, --sunset-gradient, --mixed-gradient) so components
|
||||
* using var() for backgrounds render correctly on the very first paint.
|
||||
*/
|
||||
function buildThemeStyle(config: StationConfig): string {
|
||||
const props: string[] = [];
|
||||
|
||||
for (const { field, prop } of COLOR_MAP) {
|
||||
const value = (config as any)[field];
|
||||
if (value && typeof value === 'string') {
|
||||
props.push(`${prop}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived gradients — mirror the same formulas ThemeService.apply() uses.
|
||||
// These must be injected server-side; otherwise components referencing
|
||||
// var(--sky-gradient) etc. fall back to their SCSS build-time defaults
|
||||
// on first paint, causing a visible color flash.
|
||||
const cp = (field: keyof StationConfig): string => (config as any)[field] || '';
|
||||
props.push(`--sky-gradient: ${gradient(cp('color_primary_dark'), cp('color_primary'), cp('color_primary_light'))}`);
|
||||
props.push(`--sunset-gradient: ${gradient(cp('color_accent_dark'), cp('color_accent'), cp('color_accent_light'))}`);
|
||||
props.push(`--mixed-gradient: linear-gradient(135deg, ${cp('color_primary_dark')} 0%, ${cp('color_primary')} 50%, ${cp('color_accent')} 100%)`);
|
||||
|
||||
if (props.length === 0) return '';
|
||||
|
||||
const css = `:root{${props.join(';')}}`;
|
||||
return `<style>${css}</style>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fresh station config from the backend API.
|
||||
* This runs server-side — it goes through the nginx proxy chain to the
|
||||
* upstream FastAPI backend. On failure we fall back to serving the shell
|
||||
* unmodified (client-side fetch picks up the slack).
|
||||
*/
|
||||
async function fetchStationConfig(): Promise<StationConfig | null> {
|
||||
// SSR_API_BASE_URL points at nginx (localhost:80) which proxies /api/ to
|
||||
// the upstream FastAPI backend. This avoids cross-origin issues and reuses
|
||||
// the same proxy chain the SSR engine itself would use.
|
||||
const baseUrl = process.env['SSR_API_BASE_URL'] || 'http://localhost:80';
|
||||
const url = `${baseUrl}/api/station-config`;
|
||||
|
||||
try {
|
||||
const client = baseUrl.startsWith('https') ? https : http;
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const req = client.get(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(2000, () => { req.destroy(new Error('Station config fetch timed out')); });
|
||||
});
|
||||
return JSON.parse(data) as StationConfig;
|
||||
} catch (err) {
|
||||
console.warn(`[SSR] Failed to fetch station config from ${url}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the CSR app shell, enhanced with fresh station config injected at
|
||||
* runtime so the client bootstraps with real data — no DEFAULT_CONFIG flash.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Read CSR shell HTML from disk
|
||||
* 2. Fetch live station config from the backend API
|
||||
* 3. Inject window.__STATION_CONFIG as an inline <script> before </head>
|
||||
* 4. Inject theme CSS custom properties as an inline <style> before </head>
|
||||
* 5. The client-side StationConfigService reads window.__STATION_CONFIG
|
||||
* synchronously on construction — it skips the API call when seeded.
|
||||
*/
|
||||
async function serveCsrShell(res: http.ServerResponse): Promise<void> {
|
||||
const indexPath = browserIndexPath();
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(indexPath, 'utf-8');
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
res.end('Internal server error — index.html not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to fetch fresh config to inject into the shell.
|
||||
const config = await fetchStationConfig();
|
||||
|
||||
let enhanced = html;
|
||||
if (config) {
|
||||
const script = buildConfigScript(config);
|
||||
const style = buildThemeStyle(config);
|
||||
const injection = `${style}\n${script}`;
|
||||
enhanced = html.replace('</head>', `${injection}</head>`);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('X-Render-Mode', 'CSR');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.end(enhanced);
|
||||
}
|
||||
|
||||
const requestHandler = createNodeRequestHandler(async (req, res, next) => {
|
||||
try {
|
||||
const response = await appEngine.handle(req);
|
||||
|
||||
if (response) {
|
||||
await writeResponseToNodeResponse(response, res);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
console.log('Node.js SSR server ready');
|
||||
const server = http.createServer();
|
||||
server.on('request', async (req, res) => {
|
||||
try {
|
||||
// ── Spider detection gate ──────────────────────────
|
||||
// Only search engine spiders get full SSR rendering.
|
||||
// Regular users receive the CSR app shell from disk,
|
||||
// enhanced with fresh station config injected at runtime.
|
||||
const userAgent = getUserAgent(req.headers);
|
||||
if (!isSpider(userAgent)) {
|
||||
await serveCsrShell(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Spider — render full HTML via Angular SSR with live API data
|
||||
await requestHandler(req, res, () => {
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SSR error:', error);
|
||||
res.statusCode = 500;
|
||||
res.end('Internal server error');
|
||||
}
|
||||
});
|
||||
server.listen(4200, () => {
|
||||
console.log('SSR server listening on http://localhost:4200');
|
||||
});
|
||||
}
|
||||
|
||||
export { requestHandler };
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Search engine spider / bot detection based on User-Agent headers.
|
||||
*
|
||||
* TypeScript mirror of backend/app/spider_detection.py.
|
||||
* Used by the SSR server to decide whether to render full HTML (for spiders)
|
||||
* or serve the static CSR app shell (for regular users).
|
||||
*
|
||||
* Usage:
|
||||
* import { isSpider, getSpiderInfo } from './spider_detection';
|
||||
*
|
||||
* if (isSpider(userAgent)) {
|
||||
* // Full SSR rendering with fresh API data
|
||||
* } else {
|
||||
* // Serve static CSR app shell
|
||||
* }
|
||||
*/
|
||||
|
||||
export class SpiderEngine {
|
||||
public static readonly GOOGLE = 'Google';
|
||||
public static readonly BING = 'Bing';
|
||||
public static readonly YAHOO = 'Yahoo';
|
||||
public static readonly DUCKDUCKGO = 'DuckDuckGo';
|
||||
public static readonly YANDEX = 'Yandex';
|
||||
public static readonly BAIDU = 'Baidu';
|
||||
public static readonly SOGOU = 'Sogou';
|
||||
public static readonly APPLE = 'Apple';
|
||||
public static readonly PETALBOT = 'PetalBot';
|
||||
public static readonly IA_ARCHIVER = 'IA Archiver';
|
||||
public static readonly FACEBOOK = 'Facebook';
|
||||
public static readonly TWITTER = 'Twitter';
|
||||
public static readonly LINKEDIN = 'LinkedIn';
|
||||
public static readonly WHATSAPP = 'WhatsApp';
|
||||
public static readonly OTHER = 'Other';
|
||||
}
|
||||
|
||||
export interface SpiderInfo {
|
||||
engine: string;
|
||||
name: string;
|
||||
category: 'search' | 'social' | 'other';
|
||||
}
|
||||
|
||||
// ── Spider patterns ──────────────────────────────────────────
|
||||
// Each tuple: [compiled-regex, engine-label, category]
|
||||
// Order matters: more specific patterns first to avoid false positives.
|
||||
|
||||
type SpiderPattern = [RegExp, string, 'search' | 'social' | 'other'];
|
||||
|
||||
const SPIDER_PATTERNS: SpiderPattern[] = [
|
||||
// ── Google family ─────────────────────────────────────
|
||||
[/(?:Googlebot\/[\d.]+)/i, 'Google', 'search'],
|
||||
[/(?:Googlebot-Mobile)/i, 'Google', 'search'],
|
||||
[/(?:Googlebot-Image)/i, 'Google', 'search'],
|
||||
[/(?:Googlebot-News)/i, 'Google', 'search'],
|
||||
[/(?:Mediapartners-Google)/i, 'Google', 'search'],
|
||||
[/(?:Storebot-Google)/i, 'Google', 'search'],
|
||||
[/(?:Google-AdSense)/i, 'Google', 'search'],
|
||||
|
||||
// ── Bing / Microsoft ──────────────────────────────────
|
||||
[/(?:bingbot\/[\d.]+)/i, 'Bing', 'search'],
|
||||
[/(?:msnbot\/[\d.]+)/i, 'Bing', 'search'],
|
||||
[/(?:msnbot-media\/[\d.]+)/i, 'Bing', 'search'],
|
||||
[/(?:bingadsbot\/[\d.]+)/i, 'Bing', 'search'],
|
||||
[/(?:webpreview)/i, 'Bing', 'search'],
|
||||
|
||||
// ── Yahoo / Slurp ─────────────────────────────────────
|
||||
[/(?:Yahoo!\s*Slurp)/i, 'Yahoo', 'search'],
|
||||
[/(?:Yahoo!\s*Deer\s*Spider)/i, 'Yahoo', 'search'],
|
||||
[/(?:YandexBot)/i, 'Yandex', 'search'],
|
||||
|
||||
// ── DuckDuckGo ────────────────────────────────────────
|
||||
[/(?:DuckDuckBot\/[\d.]+)/i, 'DuckDuckGo', 'search'],
|
||||
|
||||
// ── Yandex (RU) ───────────────────────────────────────
|
||||
[/(?:YandexBot\/[\d.]+)/i, 'Yandex', 'search'],
|
||||
[/(?:YandexImages\/[\d.]+)/i, 'Yandex', 'search'],
|
||||
[/(?:YandexAccessibilityBot)/i, 'Yandex', 'search'],
|
||||
[/(?:YandexMobileBot)/i, 'Yandex', 'search'],
|
||||
[/(?:YandexMetrika)/i, 'Yandex', 'search'],
|
||||
[/(?:YandexDirect)/i, 'Yandex', 'search'],
|
||||
[/(?:YandexMobileBot)/i, 'Yandex', 'search'],
|
||||
|
||||
// ── Baidu (CN) ────────────────────────────────────────
|
||||
[/(?:Baiduspider\/[\d.]+)/i, 'Baidu', 'search'],
|
||||
[/(?:Baiduboxapp)/i, 'Baidu', 'search'],
|
||||
|
||||
// ── Sogou (CN) ────────────────────────────────────────
|
||||
[/(?:Sogou\s*Spider)/i, 'Sogou', 'search'],
|
||||
[/(?:Sogou\s*web\s*spider)/i, 'Sogou', 'search'],
|
||||
|
||||
// ── Apple ─────────────────────────────────────────────
|
||||
[/(?:Applebot\/[\d.]+)/i, 'Apple', 'search'],
|
||||
[/(?:Applebot-Extended)/i, 'Apple', 'search'],
|
||||
|
||||
// ── PetalBot (China) ──────────────────────────────────
|
||||
[/(?:petalbot)/i, 'PetalBot', 'search'],
|
||||
|
||||
// ── Internet Archive ──────────────────────────────────
|
||||
[/(?:ia_archiver)/i, 'IA Archiver', 'other'],
|
||||
[/(?:archive\.org_bot)/i, 'IA Archiver', 'other'],
|
||||
|
||||
// ── Social media bots ─────────────────────────────────
|
||||
[/(?:facebookexternalhit\/[\d.]+)/i, 'Facebook', 'social'],
|
||||
[/(?:facebookplatform)/i, 'Facebook', 'social'],
|
||||
[/(?:Twitterbot\/[\d.]+)/i, 'Twitter', 'social'],
|
||||
[/(?:LinkedInBot\/[\d.]+)/i, 'LinkedIn', 'social'],
|
||||
[/(?:WhatsApp\/[\d.]+)/i, 'WhatsApp', 'social'],
|
||||
[/(?:W3C_Validator)/i, 'W3C', 'other'],
|
||||
|
||||
// ── Generic / catch-all bot signatures ────────────────
|
||||
[/(?:Bot\/[\d.])/i, 'Other', 'other'],
|
||||
[/(?:;\s*bot\s*;?)/i, 'Other', 'other'],
|
||||
[/(?:spider)/i, 'Other', 'other'],
|
||||
[/(?:crawler)/i, 'Other', 'other'],
|
||||
];
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────
|
||||
|
||||
export function isSpider(userAgent: string): boolean {
|
||||
if (!userAgent) return false;
|
||||
for (const [pattern] of SPIDER_PATTERNS) {
|
||||
if (pattern.test(userAgent)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getSpiderInfo(userAgent: string): SpiderInfo | null {
|
||||
if (!userAgent) return null;
|
||||
for (const [pattern, engine, category] of SPIDER_PATTERNS) {
|
||||
const match = userAgent.match(pattern);
|
||||
if (match) {
|
||||
return { engine, name: match[0], category };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSearchSpider(userAgent: string): boolean {
|
||||
const info = getSpiderInfo(userAgent);
|
||||
return info?.category === 'search';
|
||||
}
|
||||
|
||||
export function getUserAgent(headers: Record<string, string | string[] | undefined>): string {
|
||||
const ua = headers['user-agent'] || headers['User-Agent'];
|
||||
if (typeof ua === 'string') return ua;
|
||||
if (Array.isArray(ua) && ua.length > 0) return ua[0];
|
||||
return '';
|
||||
}
|
||||
@@ -213,7 +213,7 @@
|
||||
.about-cta {
|
||||
text-align: center;
|
||||
padding: $spacing-2xl $spacing-lg;
|
||||
background: $mixed-gradient;
|
||||
background: var(--mixed-gradient);
|
||||
border-radius: $radius-xl;
|
||||
color: var(--color-white);
|
||||
|
||||
|
||||
+2
-15
@@ -1,31 +1,18 @@
|
||||
import { APP_INITIALIZER, ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||
import {
|
||||
appConfigInitProvider,
|
||||
appConfigProvider,
|
||||
} from './services/app-config.service';
|
||||
import { StationConfigService } from './services/station-config.service';
|
||||
|
||||
export const stationConfigInitProvider = {
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: (stationConfigService: StationConfigService) => {
|
||||
return () => stationConfigService.load();
|
||||
},
|
||||
deps: [StationConfigService],
|
||||
multi: true,
|
||||
};
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideRouter(routes),
|
||||
appConfigProvider,
|
||||
appConfigInitProvider,
|
||||
stationConfigInitProvider,
|
||||
],
|
||||
};
|
||||
@@ -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()">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||
import '@angular/compiler';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RenderMode } from '@angular/ssr';
|
||||
import { serverRoutes } from './app.server.routes';
|
||||
|
||||
describe('SSR server routes — app.server.routes.ts', () => {
|
||||
it('should define 8 route entries', () => {
|
||||
expect(serverRoutes).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('should use SSR mode for the home route', () => {
|
||||
const home = serverRoutes.find((r) => r.path === '');
|
||||
expect(home).toBeDefined();
|
||||
expect(home!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should use SSR mode for /about', () => {
|
||||
const about = serverRoutes.find((r) => r.path === 'about');
|
||||
expect(about).toBeDefined();
|
||||
expect(about!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should use SSR mode for /schedule', () => {
|
||||
const schedule = serverRoutes.find((r) => r.path === 'schedule');
|
||||
expect(schedule).toBeDefined();
|
||||
expect(schedule!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should use SSR mode for /events', () => {
|
||||
const events = serverRoutes.find((r) => r.path === 'events');
|
||||
expect(events).toBeDefined();
|
||||
expect(events!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should use SSR mode for /contact', () => {
|
||||
const contact = serverRoutes.find((r) => r.path === 'contact');
|
||||
expect(contact).toBeDefined();
|
||||
expect(contact!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should use SSR mode for /login', () => {
|
||||
const login = serverRoutes.find((r) => r.path === 'login');
|
||||
expect(login).toBeDefined();
|
||||
expect(login!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should use SSR mode for /admin', () => {
|
||||
const admin = serverRoutes.find((r) => r.path === 'admin');
|
||||
expect(admin).toBeDefined();
|
||||
expect(admin!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should fall back to SSR for unmatched routes', () => {
|
||||
const wildcard = serverRoutes.find((r) => r.path === '**');
|
||||
expect(wildcard).toBeDefined();
|
||||
expect(wildcard!.renderMode).toBe(RenderMode.Server);
|
||||
});
|
||||
|
||||
it('should have no prerendered routes (eliminates stale DEFAULT_CONFIG)', () => {
|
||||
const prerendered = serverRoutes.filter((r) => r.renderMode === RenderMode.Prerender);
|
||||
expect(prerendered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should have exactly 8 SSR routes (all routes)', () => {
|
||||
const ssr = serverRoutes.filter((r) => r.renderMode === RenderMode.Server);
|
||||
expect(ssr).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
/**
|
||||
* Server routes configuration for Angular SSR.
|
||||
*
|
||||
* ALL routes use RenderMode.Server — every request hits the SSR server,
|
||||
* which fetches fresh station config from the API before rendering HTML.
|
||||
* This eliminates the stale-asset flash that occurred with prerendered HTML
|
||||
* (prerender runs at build time when the API is unavailable, baking in
|
||||
* DEFAULT_CONFIG values).
|
||||
*/
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{ path: '', renderMode: RenderMode.Server },
|
||||
{ path: 'about', renderMode: RenderMode.Server },
|
||||
{ path: 'schedule', renderMode: RenderMode.Server },
|
||||
{ path: 'events', renderMode: RenderMode.Server },
|
||||
{ path: 'contact', renderMode: RenderMode.Server },
|
||||
{ path: 'login', renderMode: RenderMode.Server },
|
||||
{ path: 'admin', renderMode: RenderMode.Server },
|
||||
{ path: '**', renderMode: RenderMode.Server },
|
||||
];
|
||||
+33
-7
@@ -1,21 +1,27 @@
|
||||
import { Component, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Component, computed, effect, inject, OnInit, OnDestroy, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
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';
|
||||
import { loadAppConfigAsync } from './services/app-config.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);
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
readonly hasStream = computed(() => {
|
||||
const cfg = this.stationConfig.config();
|
||||
@@ -26,11 +32,12 @@ export class App implements OnInit, OnDestroy {
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
constructor() {
|
||||
// These DOM operations only run in the browser.
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
// 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 +54,23 @@ export class App implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load station config on BOTH server and browser. On the server this
|
||||
// fetches fresh data from the API so SSR renders with real colors,
|
||||
// assets, and content — not DEFAULT_CONFIG baked at build time.
|
||||
this.stationConfig.load().catch(() => {});
|
||||
|
||||
// These DOM operations only run in the browser.
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
// Load runtime config.json asynchronously in the background — do not
|
||||
// block rendering.
|
||||
loadAppConfigAsync(this.http).catch(() => {});
|
||||
|
||||
// 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');
|
||||
@@ -60,8 +84,10 @@ export class App implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
this.resizeObserver?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private updateNavbarHeight(): void {
|
||||
if (!this.navbarEl) return;
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
.event-date {
|
||||
flex-shrink: 0;
|
||||
background: $mixed-gradient;
|
||||
background: var(--mixed-gradient);
|
||||
color: var(--color-white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
.login-section {
|
||||
@include flex-center;
|
||||
min-height: calc(100vh - 80px);
|
||||
background: $sky-gradient;
|
||||
background: var(--sky-gradient);
|
||||
padding: $spacing-xl;
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
|
||||
h1 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
color: var(--color-primary);
|
||||
margin: $spacing-sm 0 $spacing-xs;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $neutral-medium;
|
||||
color: var(--color-medium);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -49,14 +49,14 @@
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
color: var(--color-dark);
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid $neutral-light;
|
||||
border: 1px solid var(--color-light);
|
||||
border-radius: $radius-md;
|
||||
font-size: 1rem;
|
||||
transition: border-color $transition-fast;
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-blue;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
.login-error {
|
||||
background: rgba($danger-red, 0.1);
|
||||
color: $danger-red;
|
||||
color: var(--color-danger);
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.875rem;
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
||||
@include button-style(var(--color-primary), var(--color-white), var(--color-primary-light));
|
||||
width: 100%;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
margin-top: $spacing-sm;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { APP_INITIALIZER, InjectionToken } from '@angular/core';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
@@ -16,15 +16,19 @@ const _appConfig: AppConfig = {
|
||||
apiBaseUrl: environment.apiBaseUrl,
|
||||
};
|
||||
|
||||
export function appConfigFactory(http: HttpClient) {
|
||||
return (): Promise<void> => {
|
||||
/**
|
||||
* Load runtime config.json asynchronously AFTER the app boots.
|
||||
* Replaces the former APP_INITIALIZER that blocked the initial render.
|
||||
* This way the page renders immediately with environment defaults, then
|
||||
* patches in the runtime API URL once it arrives.
|
||||
*/
|
||||
export function loadAppConfigAsync(http: HttpClient): Promise<void> {
|
||||
return lastValueFrom(http.get<AppConfig>('config.json')).then(
|
||||
(config) => { Object.assign(_appConfig, config); },
|
||||
() => {
|
||||
// Keep defaults if config.json is not available (dev mode).
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const appConfigProvider = {
|
||||
@@ -32,12 +36,12 @@ export const appConfigProvider = {
|
||||
useValue: _appConfig,
|
||||
};
|
||||
|
||||
export const appConfigInitProvider = {
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: appConfigFactory,
|
||||
deps: [HttpClient],
|
||||
multi: true,
|
||||
};
|
||||
/**
|
||||
* @deprecated The APP_INITIALIZER is no longer registered by default.
|
||||
* The `loadAppConfigAsync` function is called directly from `app.config.ts`
|
||||
* in a non-blocking way. This export is kept for backward compatibility only.
|
||||
*/
|
||||
export const appConfigInitProvider = null;
|
||||
|
||||
export function getAppConfig(): AppConfig {
|
||||
return _appConfig;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||
import '@angular/compiler';
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Injector } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { EventService } from './event.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
// Mock the app-config service
|
||||
vi.mock('./app-config.service', () => ({
|
||||
getAppConfig: () => ({ apiBaseUrl: 'http://localhost:8000' }),
|
||||
}));
|
||||
|
||||
describe('EventService — SSR error handling', () => {
|
||||
let httpMock: any;
|
||||
let service: EventService;
|
||||
|
||||
beforeEach(() => {
|
||||
httpMock = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{ provide: HttpClient, useValue: httpMock },
|
||||
EventService,
|
||||
],
|
||||
});
|
||||
service = injector.get(EventService);
|
||||
});
|
||||
|
||||
it('should return empty array when API fails (SSR fallback)', async () => {
|
||||
const { throwError } = await import('rxjs');
|
||||
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
|
||||
|
||||
const result = await firstValueFrom(service.getEvents());
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when API fails with active filter', async () => {
|
||||
const { throwError } = await import('rxjs');
|
||||
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
|
||||
|
||||
const result = await firstValueFrom(service.getEvents(true));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should call the API endpoint', async () => {
|
||||
const { of } = await import('rxjs');
|
||||
httpMock.get.mockReturnValue(of([{ id: 1, name: 'Test Event' }]));
|
||||
|
||||
await firstValueFrom(service.getEvents());
|
||||
expect(httpMock.get).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { Event } from '../interfaces/event';
|
||||
import { getAppConfig } from './app-config.service';
|
||||
@@ -16,7 +17,12 @@ export class EventService {
|
||||
getEvents(active?: boolean): Observable<Event[]> {
|
||||
let params = new HttpParams();
|
||||
if (active !== undefined) params = params.set('active', String(active));
|
||||
return this.http.get<Event[]>(this.baseUrl, { params });
|
||||
return this.http.get<Event[]>(this.baseUrl, { params }).pipe(
|
||||
catchError((err) => {
|
||||
console.warn('EventService: Failed to load events, returning empty array.', err);
|
||||
return of<Event[]>([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getEvent(id: number): Observable<Event> {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Load the JIT compiler BEFORE any Angular router/common imports so that
|
||||
// injectables like PlatformLocation can be instantiated without TestBed.
|
||||
import '@angular/compiler';
|
||||
|
||||
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,51 @@
|
||||
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||
import '@angular/compiler';
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Injector } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ShowService } from './show.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
// Mock the app-config service
|
||||
vi.mock('./app-config.service', () => ({
|
||||
getAppConfig: () => ({ apiBaseUrl: 'http://localhost:8000' }),
|
||||
}));
|
||||
|
||||
describe('ShowService — SSR error handling', () => {
|
||||
let httpMock: any;
|
||||
let service: ShowService;
|
||||
|
||||
beforeEach(() => {
|
||||
httpMock = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{ provide: HttpClient, useValue: httpMock },
|
||||
ShowService,
|
||||
],
|
||||
});
|
||||
service = injector.get(ShowService);
|
||||
});
|
||||
|
||||
it('should return empty array when API fails (SSR fallback)', async () => {
|
||||
const { throwError } = await import('rxjs');
|
||||
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
|
||||
|
||||
const result = await firstValueFrom(service.getShows());
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should call the API endpoint', async () => {
|
||||
const { of } = await import('rxjs');
|
||||
httpMock.get.mockReturnValue(of([{ id: 1, title: 'Test Show' }]));
|
||||
|
||||
await firstValueFrom(service.getShows());
|
||||
expect(httpMock.get).toHaveBeenCalledWith('http://localhost:8000/api/shows');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { Show } from '../interfaces/show';
|
||||
import { getAppConfig } from './app-config.service';
|
||||
@@ -41,7 +42,12 @@ export class ShowService {
|
||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`;
|
||||
|
||||
getShows(): Observable<Show[]> {
|
||||
return this.http.get<Show[]>(this.baseUrl);
|
||||
return this.http.get<Show[]>(this.baseUrl).pipe(
|
||||
catchError((err) => {
|
||||
console.warn('ShowService: Failed to load shows, returning empty array.', err);
|
||||
return of<Show[]>([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getShow(id: number): Observable<Show> {
|
||||
|
||||
@@ -6,7 +6,10 @@ import { StationConfig } from '../interfaces/station-config';
|
||||
import { getAppConfig } from './app-config.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
|
||||
/**
|
||||
* Hardcoded defaults — used when the API is unavailable (dev fallback).
|
||||
* Rendered immediately on the initial paint so the page is never blank.
|
||||
*/
|
||||
const DEFAULT_CONFIG: StationConfig = {
|
||||
id: 0,
|
||||
key: 'default',
|
||||
@@ -71,7 +74,18 @@ export class StationConfigService {
|
||||
private themeService = inject(ThemeService);
|
||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
|
||||
|
||||
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
|
||||
/**
|
||||
* Read pre-seeded config injected by the server at runtime.
|
||||
* The SSR server fetches fresh station config and injects it as
|
||||
* `window.__STATION_CONFIG` before the Angular bundle loads.
|
||||
* This eliminates the DEFAULT_CONFIG flash: the client bootstraps
|
||||
* with real data instead of hardcoded defaults.
|
||||
*/
|
||||
readonly config = signal<StationConfig>(
|
||||
typeof window !== 'undefined' && (window as any).__STATION_CONFIG
|
||||
? (window as any).__STATION_CONFIG as StationConfig
|
||||
: DEFAULT_CONFIG,
|
||||
);
|
||||
|
||||
/** Load station config from the API. Falls back to defaults on error. */
|
||||
async load(): Promise<void> {
|
||||
|
||||
@@ -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,170 @@
|
||||
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||
import '@angular/compiler';
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { Injector } from '@angular/core';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { StationConfig } from '../interfaces/station-config';
|
||||
|
||||
describe('ThemeService', () => {
|
||||
let service: ThemeService;
|
||||
|
||||
const mockConfig: StationConfig = {
|
||||
id: 1,
|
||||
key: 'default',
|
||||
callsign: 'KMOF',
|
||||
name_primary: 'KMountain',
|
||||
name_secondary: 'Flower Radio',
|
||||
tagline: 'Community Radio',
|
||||
frequency: '91.7',
|
||||
founded_year: 2020,
|
||||
nonprofit_type: '501c3',
|
||||
ein: '12-3456789',
|
||||
logo_url: 'https://kmtnflower.org/logo.png',
|
||||
hero_background_url: '',
|
||||
hero_icon_url: '',
|
||||
hero_divider_url: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
email: 'info@kmtnflower.org',
|
||||
website: 'kmtnflower.org',
|
||||
donation_url: '',
|
||||
stream_url: '',
|
||||
stream_metadata_url: '',
|
||||
play_store_icon_url: '',
|
||||
play_store_url: '',
|
||||
app_store_embed_html: '',
|
||||
social_facebook: '',
|
||||
social_twitter: '',
|
||||
social_instagram: '',
|
||||
social_youtube: '',
|
||||
hero_subtitle: 'Your community-powered radio station.',
|
||||
about_intro: 'About KMountain Flower Radio.',
|
||||
about_donation_text: '',
|
||||
schedule_intro: 'Our weekly schedule.',
|
||||
events_intro: 'Community events.',
|
||||
color_primary: '#4a90d9',
|
||||
color_primary_light: '#6ba3eb',
|
||||
color_primary_dark: '#2e6faa',
|
||||
color_primary_muted: '#7baee7',
|
||||
color_accent: '#e85d3a',
|
||||
color_accent_light: '#eb7d5e',
|
||||
color_accent_dark: '#c94422',
|
||||
color_accent_muted: '#e98a72',
|
||||
color_background: '#faf6f0',
|
||||
color_text: '#333333',
|
||||
color_success: '#4caf50',
|
||||
color_danger: '#f44336',
|
||||
color_info: '#2196f3',
|
||||
color_white: '#ffffff',
|
||||
color_light: '#eeeeee',
|
||||
color_medium: '#9e9e9e',
|
||||
color_black: '#000000',
|
||||
} as const as StationConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = Injector.create({
|
||||
providers: [ThemeService],
|
||||
});
|
||||
service = injector.get(ThemeService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear all inline CSS custom properties after each test.
|
||||
const root = document.documentElement.style;
|
||||
for (const { prop } of [
|
||||
{ prop: '--color-primary' },
|
||||
{ prop: '--color-primary-light' },
|
||||
{ prop: '--color-primary-dark' },
|
||||
{ prop: '--color-primary-muted' },
|
||||
{ prop: '--color-accent' },
|
||||
{ prop: '--color-accent-light' },
|
||||
{ prop: '--color-accent-dark' },
|
||||
{ prop: '--color-accent-muted' },
|
||||
{ prop: '--color-cream' },
|
||||
{ prop: '--color-dark' },
|
||||
{ prop: '--color-success' },
|
||||
{ prop: '--color-danger' },
|
||||
{ prop: '--color-info' },
|
||||
{ prop: '--color-white' },
|
||||
{ prop: '--color-light' },
|
||||
{ prop: '--color-medium' },
|
||||
{ prop: '--color-black' },
|
||||
{ prop: '--sky-gradient' },
|
||||
{ prop: '--sunset-gradient' },
|
||||
{ prop: '--mixed-gradient' },
|
||||
]) {
|
||||
root.removeProperty(prop);
|
||||
}
|
||||
});
|
||||
|
||||
it('should apply all CSS color variables', () => {
|
||||
const setPropertySpy = vi.spyOn(document.documentElement.style, 'setProperty');
|
||||
service.apply(mockConfig);
|
||||
// 17 color fields + 3 gradient fields = 20 setProperty calls
|
||||
expect(setPropertySpy).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
|
||||
it('should set primary color CSS variable', () => {
|
||||
service.apply(mockConfig);
|
||||
const rootStyle = document.documentElement.style;
|
||||
expect(rootStyle.getPropertyValue('--color-primary')).toBe('#4a90d9');
|
||||
});
|
||||
|
||||
it('should set accent color CSS variable', () => {
|
||||
service.apply(mockConfig);
|
||||
const rootStyle = document.documentElement.style;
|
||||
expect(rootStyle.getPropertyValue('--color-accent')).toBe('#e85d3a');
|
||||
});
|
||||
|
||||
it('should set background color to --color-cream', () => {
|
||||
service.apply(mockConfig);
|
||||
const rootStyle = document.documentElement.style;
|
||||
expect(rootStyle.getPropertyValue('--color-cream')).toBe('#faf6f0');
|
||||
});
|
||||
|
||||
it('should derive --sky-gradient from primary colors', () => {
|
||||
service.apply(mockConfig);
|
||||
const rootStyle = document.documentElement.style;
|
||||
const sky = rootStyle.getPropertyValue('--sky-gradient');
|
||||
expect(sky).toContain('linear-gradient(135deg');
|
||||
expect(sky).toContain('#2e6faa');
|
||||
expect(sky).toContain('#4a90d9');
|
||||
expect(sky).toContain('#6ba3eb');
|
||||
});
|
||||
|
||||
it('should derive --sunset-gradient from accent colors', () => {
|
||||
service.apply(mockConfig);
|
||||
const rootStyle = document.documentElement.style;
|
||||
const sunset = rootStyle.getPropertyValue('--sunset-gradient');
|
||||
expect(sunset).toContain('linear-gradient(135deg');
|
||||
expect(sunset).toContain('#c94422');
|
||||
expect(sunset).toContain('#e85d3a');
|
||||
expect(sunset).toContain('#eb7d5e');
|
||||
});
|
||||
|
||||
it('should derive --mixed-gradient from primary and accent', () => {
|
||||
service.apply(mockConfig);
|
||||
const rootStyle = document.documentElement.style;
|
||||
const mixed = rootStyle.getPropertyValue('--mixed-gradient');
|
||||
expect(mixed).toContain('linear-gradient(135deg');
|
||||
expect(mixed).toContain('#2e6faa');
|
||||
expect(mixed).toContain('#4a90d9');
|
||||
expect(mixed).toContain('#e85d3a');
|
||||
});
|
||||
|
||||
it('should work on both browser and server platforms (no platform check)', () => {
|
||||
// ThemeService no longer guards on isPlatformBrowser — it applies CSS vars
|
||||
// during SSR so the server-rendered HTML carries the correct theme.
|
||||
service.apply(mockConfig);
|
||||
const rootStyle = document.documentElement.style;
|
||||
expect(rootStyle.getPropertyValue('--color-primary')).toBe('#4a90d9');
|
||||
});
|
||||
|
||||
it('should skip fields with empty string values', () => {
|
||||
const emptyConfig = { ...mockConfig, color_primary: '' } as StationConfig;
|
||||
const setPropertySpy = vi.spyOn(document.documentElement.style, 'setProperty');
|
||||
service.apply(emptyConfig);
|
||||
expect(setPropertySpy).not.toHaveBeenCalledWith('--color-primary', '');
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,11 @@ export class ThemeService {
|
||||
/**
|
||||
* Apply color theme from StationConfig as CSS custom properties on `:root`.
|
||||
* Also generates derived gradient variables.
|
||||
*
|
||||
* Works on both browser and server platforms. During SSR the style properties
|
||||
* are written to the virtual DOM and serialised into the HTML output so the
|
||||
* prerendered / server-rendered page carries the correct colour scheme —
|
||||
* eliminating the "white flash" before hydration.
|
||||
*/
|
||||
apply(config: StationConfig): void {
|
||||
const root = document.documentElement.style;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||
import '@angular/compiler';
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Injector, PLATFORM_ID } from '@angular/core';
|
||||
import { StructuredDataComponent } from './structured-data.component';
|
||||
import { StructuredDataService } from '../services/structured-data.service';
|
||||
|
||||
describe('StructuredDataComponent — SSR compatibility', () => {
|
||||
let serviceMock: any;
|
||||
|
||||
beforeEach(() => {
|
||||
serviceMock = {
|
||||
structuredData: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
it('should NOT throw when instantiated on the server platform', () => {
|
||||
expect(() => {
|
||||
Injector.create({
|
||||
providers: [
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
{ provide: StructuredDataService, useValue: serviceMock },
|
||||
{
|
||||
provide: StructuredDataComponent,
|
||||
useFactory: () => new StructuredDataComponent(),
|
||||
deps: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should instantiate without errors on the browser platform', () => {
|
||||
expect(() => {
|
||||
Injector.create({
|
||||
providers: [
|
||||
{ provide: PLATFORM_ID, useValue: 'browser' },
|
||||
{ provide: StructuredDataService, useValue: serviceMock },
|
||||
{
|
||||
provide: StructuredDataComponent,
|
||||
useFactory: () => new StructuredDataComponent(),
|
||||
deps: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Component, effect, inject, PLATFORM_ID, OnDestroy } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
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 implements OnDestroy {
|
||||
private service = inject(StructuredDataService);
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
|
||||
constructor() {
|
||||
// Only manipulate DOM in the browser; skip on the server.
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
effect(() => {
|
||||
const entries = this.service.structuredData();
|
||||
this.renderEntries(entries);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<!-- Preload hero background image (LCP element). The default SVG is inlined
|
||||
here as a fallback; the runtime value from station config replaces it
|
||||
dynamically after hydration. -->
|
||||
<link rel="preload" as="image" href="svg/mountain-landscape.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { APP_ID, APP_INITIALIZER, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
|
||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
import { APP_CONFIG, AppConfig } from './app/services/app-config.service';
|
||||
import { StationConfigService } from './app/services/station-config.service';
|
||||
import { serverRoutes } from './app/app.server.routes';
|
||||
|
||||
// During SSR, the API base URL must be absolute (HttpClient runs in Node.js).
|
||||
// nginx proxies /api/ to the upstream, so point at nginx (port 80) locally.
|
||||
const ssrApiBaseUrl = process.env['SSR_API_BASE_URL'] || 'http://localhost:80';
|
||||
const ssrAppConfig: AppConfig = { apiBaseUrl: ssrApiBaseUrl };
|
||||
|
||||
const serverAppConfig = {
|
||||
...appConfig,
|
||||
providers: [
|
||||
...appConfig.providers,
|
||||
{ provide: APP_ID, useValue: 'server' },
|
||||
{ provide: APP_CONFIG, useValue: ssrAppConfig },
|
||||
provideServerRendering(withRoutes(serverRoutes)),
|
||||
// Block server rendering until station config is fetched from the API.
|
||||
// This ensures SSR renders with real DB data (colors, assets, content)
|
||||
// instead of DEFAULT_CONFIG baked at build time. In the browser, this
|
||||
// is a no-op — config loads async in ngOnInit after first paint.
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
deps: [StationConfigService, PLATFORM_ID],
|
||||
useFactory: (svc: StationConfigService, platformId: object) => {
|
||||
if (!isPlatformBrowser(platformId)) {
|
||||
return () => svc.load().catch(() => {});
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Bootstrap function for server-side rendering.
|
||||
* Exports a function that accepts BootstrapContext and returns ApplicationRef,
|
||||
* as required by @angular/ssr's CommonEngine bootstrap signature.
|
||||
*/
|
||||
export default (ctx: BootstrapContext) =>
|
||||
bootstrapApplication(App, serverAppConfig, ctx);
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
import 'zone.js';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||
bootstrapApplication(App, {
|
||||
...appConfig,
|
||||
providers: [...appConfig.providers, provideClientHydration()],
|
||||
}).catch((err) => console.error(err));
|
||||
+7
-25
@@ -3,33 +3,15 @@
|
||||
// ========== CSS Custom Properties (exposed to the browser) ==========
|
||||
// These map SCSS variables to CSS custom properties so Angular components
|
||||
// can consume them via var() at runtime.
|
||||
//
|
||||
// COLOR variables (color_*, gradient_*) are NOT set here anymore.
|
||||
// They are injected at runtime by the SSR server as an inline <style> block
|
||||
// so the first paint always carries the correct station colors.
|
||||
// Client-side ThemeService.apply() keeps them in sync after Angular boots.
|
||||
//
|
||||
// Non-color variables (fonts, radii, shadows) remain below as build-time defaults.
|
||||
|
||||
:root {
|
||||
// Primary
|
||||
--color-primary: #{$primary-blue};
|
||||
--color-primary-light: #{$primary-blue-light};
|
||||
--color-primary-dark: #{$primary-blue-dark};
|
||||
--color-primary-muted: #{$primary-blue-muted};
|
||||
|
||||
// Accent
|
||||
--color-accent: #{$accent-orange};
|
||||
--color-accent-light: #{$accent-orange-light};
|
||||
--color-accent-dark: #{$accent-orange-dark};
|
||||
--color-accent-muted: #{$accent-orange-muted};
|
||||
|
||||
// Neutrals
|
||||
--color-white: #{$neutral-white};
|
||||
--color-cream: #{$neutral-cream};
|
||||
--color-light: #{$neutral-light};
|
||||
--color-medium: #{$neutral-medium};
|
||||
--color-dark: #{$neutral-dark};
|
||||
--color-black: #{$neutral-black};
|
||||
|
||||
// Semantic
|
||||
--color-success: #{$success-green};
|
||||
--color-danger: #{$danger-red};
|
||||
--color-info: #{$info-blue};
|
||||
|
||||
// Typography
|
||||
--font-primary: #{$font-primary};
|
||||
--font-heading: #{$font-heading};
|
||||
|
||||
+13
-6
@@ -1,11 +1,18 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
/* To learn more about Typescript configuration file: https://typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"server.ts",
|
||||
"spider_detection.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ export default {
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
include: ['src/**/*.spec.ts', 'server.spec.ts'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
|
||||
Reference in new issue
Block a user