Compare commits
42
Commits
65af2824a9
...
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 | ||
|
|
58a25370b9 | ||
|
|
3687e91f7f | ||
|
|
3dead2921a | ||
|
|
5484c761b4 | ||
|
|
a7d994a122 | ||
|
|
e3a742b708 | ||
|
|
bea1c97e88 | ||
|
|
da02687260 | ||
|
|
b7e67fa244 |
No files matched your search
@@ -2,7 +2,7 @@ name: CI Pipeline
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop, feature/tests-and-ci, feature/multi-admin-roles]
|
branches: ['**']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -7,8 +7,10 @@ COPY . .
|
|||||||
RUN npx ng build --configuration production
|
RUN npx ng build --configuration production
|
||||||
|
|
||||||
# ── Serve stage ────────────────────────────────────────────────────────
|
# ── 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/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 nginx.conf.template /etc/nginx/conf.d/nginx.conf.template
|
||||||
COPY config.json.template /usr/share/nginx/html/config.json.template
|
COPY config.json.template /usr/share/nginx/html/config.json.template
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
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",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
|
"server": "src/main.server.ts",
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
@@ -55,6 +56,12 @@
|
|||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
"stylePreprocessorOptions": {
|
"stylePreprocessorOptions": {
|
||||||
"includePaths": ["src/styles"]
|
"includePaths": ["src/styles"]
|
||||||
|
},
|
||||||
|
"ssr": {
|
||||||
|
"entry": "server.ts"
|
||||||
|
},
|
||||||
|
"prerender": {
|
||||||
|
"routesFile": "prerender-routes.txt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
"""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
|
||||||
|
(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
|
||||||
|
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 StationConfig.website so each tenant gets the correct domain.
|
||||||
|
"""
|
||||||
|
base_url = await _get_base_url()
|
||||||
|
xml = build_sitemap_xml(base_url)
|
||||||
|
return Response(content=xml, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/robots.txt")
|
||||||
|
async def get_robots() -> Response:
|
||||||
|
"""Return a dynamically-generated robots.txt. Public endpoint.
|
||||||
|
|
||||||
|
Uses StationConfig.website so the Sitemap directive points to the
|
||||||
|
correct tenant domain.
|
||||||
|
"""
|
||||||
|
base_url = await _get_base_url()
|
||||||
|
txt = build_robots_txt(base_url)
|
||||||
|
return Response(content=txt, media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/regenerate", status_code=http_status.HTTP_200_OK)
|
||||||
|
async def regenerate_sitemap(
|
||||||
|
_: User = Depends(get_current_admin_user),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Force regenerate the sitemap cache. Admin only.
|
||||||
|
|
||||||
|
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 from StationConfig.website",
|
||||||
|
"base_url": base_url,
|
||||||
|
}
|
||||||
+7
-1
@@ -10,7 +10,7 @@ from app.config import settings
|
|||||||
from app.database import async_session, engine
|
from app.database import async_session, engine
|
||||||
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
||||||
from app.user_models import User
|
from app.user_models import User
|
||||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme
|
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme, sitemap
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_orphaned_sequences(sync_conn):
|
def _cleanup_orphaned_sequences(sync_conn):
|
||||||
@@ -78,6 +78,11 @@ def _migrate_add_missing_columns(sync_conn):
|
|||||||
("station_config", "color_black", sa.String(7), "#1a1816"),
|
("station_config", "color_black", sa.String(7), "#1a1816"),
|
||||||
# Multi-admin support: password_hash (nullable — existing rows stay NULL)
|
# Multi-admin support: password_hash (nullable — existing rows stay NULL)
|
||||||
("users", "password_hash", sa.String(255), None),
|
("users", "password_hash", sa.String(255), None),
|
||||||
|
# Social media columns
|
||||||
|
("station_config", "social_facebook", sa.String(500), ""),
|
||||||
|
("station_config", "social_twitter", sa.String(500), ""),
|
||||||
|
("station_config", "social_instagram", sa.String(500), ""),
|
||||||
|
("station_config", "social_youtube", sa.String(500), ""),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Determine dialect
|
# Determine dialect
|
||||||
@@ -230,6 +235,7 @@ app.include_router(underwriters.router, prefix="/api/underwriters", tags=["under
|
|||||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||||
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
|
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
|
||||||
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
|
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
|
||||||
|
app.include_router(sitemap.router, prefix="/api/sitemap", tags=["sitemap"])
|
||||||
|
|
||||||
# Admin user management (available in all environments)
|
# Admin user management (available in all environments)
|
||||||
from app.api import users
|
from app.api import users
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ class StationConfig(Base):
|
|||||||
# Donation
|
# Donation
|
||||||
donation_url = Column(String(500), nullable=False, default="")
|
donation_url = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
|
# Social media
|
||||||
|
social_facebook = Column(String(500), nullable=False, default="")
|
||||||
|
social_twitter = Column(String(500), nullable=False, default="")
|
||||||
|
social_instagram = Column(String(500), nullable=False, default="")
|
||||||
|
social_youtube = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
# Stream
|
# Stream
|
||||||
stream_url = Column(String(500), nullable=False, default="")
|
stream_url = Column(String(500), nullable=False, default="")
|
||||||
stream_metadata_url = Column(String(500), nullable=False, default="")
|
stream_metadata_url = Column(String(500), nullable=False, default="")
|
||||||
@@ -277,3 +283,8 @@ class MobileBuildArtifact(Base):
|
|||||||
sha256 = Column(String(64), nullable=False)
|
sha256 = Column(String(64), nullable=False)
|
||||||
size = Column(Integer, nullable=False)
|
size = Column(Integer, nullable=False)
|
||||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
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
|
||||||
@@ -78,6 +78,11 @@ class StationConfigResponse(BaseModel):
|
|||||||
play_store_icon_url: str
|
play_store_icon_url: str
|
||||||
play_store_url: str
|
play_store_url: str
|
||||||
app_store_embed_html: str
|
app_store_embed_html: str
|
||||||
|
# Social media
|
||||||
|
social_facebook: str
|
||||||
|
social_twitter: str
|
||||||
|
social_instagram: str
|
||||||
|
social_youtube: str
|
||||||
# Page content
|
# Page content
|
||||||
hero_subtitle: str
|
hero_subtitle: str
|
||||||
about_intro: str
|
about_intro: str
|
||||||
@@ -129,6 +134,11 @@ class StationConfigUpdate(BaseModel):
|
|||||||
play_store_icon_url: Optional[str] = None
|
play_store_icon_url: Optional[str] = None
|
||||||
play_store_url: Optional[str] = None
|
play_store_url: Optional[str] = None
|
||||||
app_store_embed_html: Optional[str] = None
|
app_store_embed_html: Optional[str] = None
|
||||||
|
# Social media
|
||||||
|
social_facebook: Optional[str] = None
|
||||||
|
social_twitter: Optional[str] = None
|
||||||
|
social_instagram: Optional[str] = None
|
||||||
|
social_youtube: Optional[str] = None
|
||||||
# Page content
|
# Page content
|
||||||
hero_subtitle: Optional[str] = None
|
hero_subtitle: Optional[str] = None
|
||||||
about_intro: Optional[str] = None
|
about_intro: Optional[str] = None
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Sitemap & robots.txt generation: tenant-aware SEO content.
|
||||||
|
|
||||||
|
Because this is a white-label app where each tenant has its own domain
|
||||||
|
(set via KMTN_WEBSITE_URL), the sitemap and robots.txt are generated
|
||||||
|
at runtime — no static files are needed. The FastAPI /api/sitemap/*
|
||||||
|
endpoints call these functions; Nginx proxies /sitemap.xml and
|
||||||
|
/robots.txt to those endpoints so search-engine crawlers hit the right
|
||||||
|
content at the root URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Public routes included in the sitemap with their change frequency and priority.
|
||||||
|
SEOMAP_ROUTES = [
|
||||||
|
{"path": "/", "changefreq": "daily", "priority": 1.0},
|
||||||
|
{"path": "/about", "changefreq": "weekly", "priority": 0.8},
|
||||||
|
{"path": "/schedule", "changefreq": "weekly", "priority": 0.8},
|
||||||
|
{"path": "/events", "changefreq": "daily", "priority": 0.8},
|
||||||
|
{"path": "/contact", "changefreq": "monthly", "priority": 0.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_sitemap_xml(base_url: str) -> str:
|
||||||
|
"""Build a valid sitemap.xml string for the given base URL."""
|
||||||
|
url = base_url.rstrip("/")
|
||||||
|
entries = []
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
entries.append(
|
||||||
|
f" <url>\n"
|
||||||
|
f" <loc>{url}{route['path']}</loc>\n"
|
||||||
|
f" <changefreq>{route['changefreq']}</changefreq>\n"
|
||||||
|
f" <priority>{route['priority']}</priority>\n"
|
||||||
|
f" </url>"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
|
+ "\n".join(entries) + "\n"
|
||||||
|
"</urlset>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_robots_txt(base_url: str) -> str:
|
||||||
|
"""Build a robots.txt string that references the correct sitemap URL."""
|
||||||
|
sitemap = base_url.rstrip("/") + "/sitemap.xml"
|
||||||
|
return (
|
||||||
|
"User-agent: *\n"
|
||||||
|
"Allow: /\n"
|
||||||
|
"\n"
|
||||||
|
"Disallow: /admin\n"
|
||||||
|
"Disallow: /login\n"
|
||||||
|
"\n"
|
||||||
|
f"Sitemap: {sitemap}\n"
|
||||||
|
)
|
||||||
@@ -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 ""
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""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."""
|
||||||
|
|
||||||
|
def _client(self):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_sitemap_xml_status(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/sitemap.xml")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_sitemap_xml_content_type(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/sitemap.xml")
|
||||||
|
assert resp.headers["content-type"].startswith("application/xml")
|
||||||
|
|
||||||
|
def test_sitemap_xml_contains_routes(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/sitemap.xml")
|
||||||
|
body = resp.text
|
||||||
|
for path in ["/", "/about", "/schedule", "/events", "/contact"]:
|
||||||
|
assert f"<loc>https://kmountainflower.org{path}</loc>" in body
|
||||||
|
|
||||||
|
def test_robots_txt_status(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/robots.txt")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_robots_txt_content_type(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/robots.txt")
|
||||||
|
assert resp.headers["content-type"].startswith("text/plain")
|
||||||
|
|
||||||
|
def test_robots_txt_contains_rules(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.get("/api/sitemap/robots.txt")
|
||||||
|
body = resp.text
|
||||||
|
assert "User-agent: *" in body
|
||||||
|
assert "Disallow: /admin" in body
|
||||||
|
assert "Disallow: /login" in body
|
||||||
|
assert "Sitemap: https://kmountainflower.org/sitemap.xml" in body
|
||||||
|
|
||||||
|
def test_regenerate_requires_auth(self):
|
||||||
|
client = self._client()
|
||||||
|
resp = client.post("/api/sitemap/regenerate")
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests for sitemap_export — sitemap.xml and robots.txt generation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from app.sitemap_export import (
|
||||||
|
SEOMAP_ROUTES,
|
||||||
|
build_sitemap_xml,
|
||||||
|
build_robots_txt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSitemapRoutes:
|
||||||
|
"""Verify the route configuration constants."""
|
||||||
|
|
||||||
|
def test_route_count(self):
|
||||||
|
assert len(SEOMAP_ROUTES) == 5
|
||||||
|
|
||||||
|
def test_home_route(self):
|
||||||
|
assert SEOMAP_ROUTES[0] == {
|
||||||
|
"path": "/",
|
||||||
|
"changefreq": "daily",
|
||||||
|
"priority": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_all_paths_present(self):
|
||||||
|
paths = [r["path"] for r in SEOMAP_ROUTES]
|
||||||
|
expected = ["/", "/about", "/schedule", "/events", "/contact"]
|
||||||
|
assert paths == expected
|
||||||
|
|
||||||
|
def test_priority_range(self):
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
assert 0.0 <= route["priority"] <= 1.0
|
||||||
|
|
||||||
|
def test_valid_changefreq_values(self):
|
||||||
|
valid = {"always", "hourly", "daily", "weekly", "monthly", "yearly", "never"}
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
assert route["changefreq"] in valid
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSitemapXml:
|
||||||
|
"""Tests for the sitemap XML builder."""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_contains_xml_declaration(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert '<?xml version="1.0" encoding="UTF-8"?>' in result
|
||||||
|
|
||||||
|
def test_contains_urlset_namespace(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' in result
|
||||||
|
|
||||||
|
def test_contains_all_routes(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
for route in SEOMAP_ROUTES:
|
||||||
|
assert f"<loc>https://example.com{route['path']}</loc>" in result
|
||||||
|
|
||||||
|
def test_strips_trailing_slash_from_base_url(self):
|
||||||
|
result = build_sitemap_xml("https://example.com/")
|
||||||
|
assert "https://example.com//about" not in result
|
||||||
|
assert "<loc>https://example.com/about</loc>" in result
|
||||||
|
|
||||||
|
def test_different_base_url(self):
|
||||||
|
result = build_sitemap_xml("https://mystation.org")
|
||||||
|
assert "https://mystation.org/schedule" in result
|
||||||
|
assert "https://example.com" not in result
|
||||||
|
|
||||||
|
def test_valid_xml(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
# Should parse without error
|
||||||
|
root = ET.fromstring(result)
|
||||||
|
# The root tag includes the namespace
|
||||||
|
assert "urlset" in root.tag
|
||||||
|
|
||||||
|
def test_contains_changefreq_and_priority(self):
|
||||||
|
result = build_sitemap_xml("https://example.com")
|
||||||
|
assert "<changefreq>daily</changefreq>" in result
|
||||||
|
assert "<priority>1.0</priority>" in result
|
||||||
|
assert "<priority>0.5</priority>" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRobotsTxt:
|
||||||
|
"""Tests for the robots.txt builder."""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_user_agent_allows_all(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert "User-agent: *" in result
|
||||||
|
assert "Allow: /" in result
|
||||||
|
|
||||||
|
def test_disallows_admin_and_login(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert "Disallow: /admin" in result
|
||||||
|
assert "Disallow: /login" in result
|
||||||
|
|
||||||
|
def test_sitemap_reference(self):
|
||||||
|
result = build_robots_txt("https://example.com")
|
||||||
|
assert "Sitemap: https://example.com/sitemap.xml" in result
|
||||||
|
|
||||||
|
def test_strips_trailing_slash(self):
|
||||||
|
result = build_robots_txt("https://example.com/")
|
||||||
|
assert "Sitemap: https://example.com//sitemap.xml" not in result
|
||||||
|
assert "Sitemap: https://example.com/sitemap.xml" in result
|
||||||
|
|
||||||
|
def test_different_base_url(self):
|
||||||
|
result = build_robots_txt("https://mystation.org")
|
||||||
|
assert "Sitemap: https://mystation.org/sitemap.xml" in result
|
||||||
@@ -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
|
#!/bin/sh
|
||||||
# Render config templates with environment variables, then start nginx.
|
# Start the Angular SSR Node process, then nginx.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Ensure nginx can write to the shared logs directory
|
# Ensure nginx can write to the shared logs directory
|
||||||
mkdir -p /logs && chown nginx:nginx /logs
|
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
|
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;'
|
exec nginx -g 'daemon off;'
|
||||||
+65
-11
@@ -1,9 +1,19 @@
|
|||||||
# Map ISO timestamp to date-only string for daily log rotation
|
# Full nginx configuration — rendered to /etc/nginx/nginx.conf
|
||||||
map $time_iso8601 $log_date {
|
# (NOT placed in conf.d/ to avoid include nesting issues with Alpine nginx)
|
||||||
"~^(?<date>\d{4}-\d{2}-\d{2})" $date;
|
|
||||||
default "unknown";
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
log_format kmtn_json escape=json
|
log_format kmtn_json escape=json
|
||||||
'{'
|
'{'
|
||||||
'"time":"$time_iso8601",'
|
'"time":"$time_iso8601",'
|
||||||
@@ -11,7 +21,12 @@ log_format kmtn_json escape=json
|
|||||||
'"x_forwarded_for":"$http_x_forwarded_for"'
|
'"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;
|
||||||
|
|
||||||
|
upstream ssr_backend {
|
||||||
|
server 127.0.0.1:4200;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -19,9 +34,29 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# SPA fallback — Angular router handles its own paths
|
# Serve static assets directly from disk (hashed filenames are immutable)
|
||||||
location / {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
|
||||||
try_files $uri $uri/ /index.html;
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /robots.txt {
|
||||||
|
proxy_pass __API_UPSTREAM__/api/sitemap/robots.txt;
|
||||||
|
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 API calls to the backend service
|
# Proxy API calls to the backend service
|
||||||
@@ -35,12 +70,31 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
# Disable caching through the double-proxy chain (NPM → frontend nginx → API)
|
# Disable caching through the double-proxy chain
|
||||||
# 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.
|
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" always;
|
add_header Pragma "no-cache" always;
|
||||||
add_header Vary "*" 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",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"build:ssr": "ng build",
|
||||||
|
"build:prerender": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:unit:watch": "vitest",
|
"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,
|
"private": true,
|
||||||
"packageManager": "npm@11.11.0",
|
"packageManager": "npm@11.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^21.2.0",
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.0",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.0",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.0",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.0",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
|
"@angular/platform-server": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
|
"@angular/ssr": "^21.2.0",
|
||||||
"@types/leaflet": "^1.9.17",
|
"@types/leaflet": "^1.9.17",
|
||||||
"@angular/cdk": "^21.2.0",
|
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"ng2-charts": "^10.0.0",
|
"ng2-charts": "^10.0.0",
|
||||||
@@ -34,11 +39,12 @@
|
|||||||
"@angular/build": "^21.2.14",
|
"@angular/build": "^21.2.14",
|
||||||
"@angular/cli": "^21.2.14",
|
"@angular/cli": "^21.2.14",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
"@vitest/coverage-v8": "^4.0.8",
|
"@vitest/coverage-v8": "^4.0.8",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^4.0.8",
|
"vitest": "^4.0.8"
|
||||||
"@types/jsdom": "^21.1.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 '';
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Intro -->
|
<!-- Intro -->
|
||||||
<div class="about-intro">
|
<div class="about-intro">
|
||||||
<img [src]="resolveImageUrl(config().hero_icon_url)" alt="" class="about-flower-decor" aria-hidden="true">
|
<img [src]="resolveImageUrl(config().hero_icon_url)" alt="" class="about-flower-decor" aria-hidden="true">
|
||||||
<h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2>
|
<h1>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h1>
|
||||||
<p class="section-intro">{{ config().about_intro }}</p>
|
<p class="section-intro">{{ config().about_intro }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
@if (config().donation_url) {
|
@if (config().donation_url) {
|
||||||
<div class="about-cta">
|
<div class="about-cta">
|
||||||
<div class="about-flower-divider" aria-hidden="true">
|
<div class="about-flower-divider" aria-hidden="true">
|
||||||
<img [src]="resolveImageUrl(config().logo_url)" alt="">
|
<img [src]="resolveImageUrl(config().logo_url)" alt="" aria-hidden="true">
|
||||||
</div>
|
</div>
|
||||||
<h3>Become a Member</h3>
|
<h3>Become a Member</h3>
|
||||||
<p class="section-intro">{{ config().about_donation_text }}</p>
|
<p class="section-intro">{{ config().about_donation_text }}</p>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
.about-cta {
|
.about-cta {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $spacing-2xl $spacing-lg;
|
padding: $spacing-2xl $spacing-lg;
|
||||||
background: $mixed-gradient;
|
background: var(--mixed-gradient);
|
||||||
border-radius: $radius-xl;
|
border-radius: $radius-xl;
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Media -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Social Media</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_facebook">Facebook URL</label>
|
||||||
|
<input id="social_facebook" type="url" [(ngModel)]="social_facebook" name="social_facebook"
|
||||||
|
placeholder="https://facebook.com/yourstation">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_twitter">Twitter / X URL</label>
|
||||||
|
<input id="social_twitter" type="url" [(ngModel)]="social_twitter" name="social_twitter"
|
||||||
|
placeholder="https://x.com/yourstation">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_instagram">Instagram URL</label>
|
||||||
|
<input id="social_instagram" type="url" [(ngModel)]="social_instagram" name="social_instagram"
|
||||||
|
placeholder="https://instagram.com/yourstation">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="social_youtube">YouTube URL</label>
|
||||||
|
<input id="social_youtube" type="url" [(ngModel)]="social_youtube" name="social_youtube"
|
||||||
|
placeholder="https://youtube.com/@yourstation">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3>Page Content</h3>
|
<h3>Page Content</h3>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
play_store_icon_url = '';
|
play_store_icon_url = '';
|
||||||
play_store_url = '';
|
play_store_url = '';
|
||||||
app_store_embed_html = '';
|
app_store_embed_html = '';
|
||||||
|
social_facebook = '';
|
||||||
|
social_twitter = '';
|
||||||
|
social_instagram = '';
|
||||||
|
social_youtube = '';
|
||||||
hero_subtitle = '';
|
hero_subtitle = '';
|
||||||
about_intro = '';
|
about_intro = '';
|
||||||
about_donation_text = '';
|
about_donation_text = '';
|
||||||
@@ -84,6 +88,10 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
this.play_store_icon_url = config.play_store_icon_url;
|
this.play_store_icon_url = config.play_store_icon_url;
|
||||||
this.play_store_url = config.play_store_url;
|
this.play_store_url = config.play_store_url;
|
||||||
this.app_store_embed_html = config.app_store_embed_html;
|
this.app_store_embed_html = config.app_store_embed_html;
|
||||||
|
this.social_facebook = config.social_facebook;
|
||||||
|
this.social_twitter = config.social_twitter;
|
||||||
|
this.social_instagram = config.social_instagram;
|
||||||
|
this.social_youtube = config.social_youtube;
|
||||||
this.hero_subtitle = config.hero_subtitle;
|
this.hero_subtitle = config.hero_subtitle;
|
||||||
this.about_intro = config.about_intro;
|
this.about_intro = config.about_intro;
|
||||||
this.about_donation_text = config.about_donation_text;
|
this.about_donation_text = config.about_donation_text;
|
||||||
@@ -180,6 +188,10 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
play_store_icon_url: this.play_store_icon_url,
|
play_store_icon_url: this.play_store_icon_url,
|
||||||
play_store_url: this.play_store_url,
|
play_store_url: this.play_store_url,
|
||||||
app_store_embed_html: this.app_store_embed_html,
|
app_store_embed_html: this.app_store_embed_html,
|
||||||
|
social_facebook: this.social_facebook,
|
||||||
|
social_twitter: this.social_twitter,
|
||||||
|
social_instagram: this.social_instagram,
|
||||||
|
social_youtube: this.social_youtube,
|
||||||
hero_subtitle: this.hero_subtitle,
|
hero_subtitle: this.hero_subtitle,
|
||||||
about_intro: this.about_intro,
|
about_intro: this.about_intro,
|
||||||
about_donation_text: this.about_donation_text,
|
about_donation_text: this.about_donation_text,
|
||||||
|
|||||||
+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 { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||||
import {
|
import {
|
||||||
appConfigInitProvider,
|
|
||||||
appConfigProvider,
|
appConfigProvider,
|
||||||
} from './services/app-config.service';
|
} 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 = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
|
||||||
provideHttpClient(withInterceptors([authInterceptor])),
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
provideRouter(routes),
|
||||||
appConfigProvider,
|
appConfigProvider,
|
||||||
appConfigInitProvider,
|
|
||||||
stationConfigInitProvider,
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<app-structured-data></app-structured-data>
|
||||||
<app-navbar></app-navbar>
|
<app-navbar></app-navbar>
|
||||||
<app-media-player></app-media-player>
|
<app-media-player></app-media-player>
|
||||||
<main [class.no-stream]="!hasStream()">
|
<main [class.no-stream]="!hasStream()">
|
||||||
|
|||||||
@@ -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 { Component, computed, effect, inject, OnInit, OnDestroy, PLATFORM_ID } from '@angular/core';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { NavbarComponent } from './navbar/navbar.component';
|
import { NavbarComponent } from './navbar/navbar.component';
|
||||||
import { MediaPlayerComponent } from './media-player/media-player.component';
|
import { MediaPlayerComponent } from './media-player/media-player.component';
|
||||||
import { FooterComponent } from './footer/footer.component';
|
import { FooterComponent } from './footer/footer.component';
|
||||||
|
import { StructuredDataComponent } from './structured-data/structured-data.component';
|
||||||
import { StationConfigService } from './services/station-config.service';
|
import { StationConfigService } from './services/station-config.service';
|
||||||
|
import { SeoService } from './services/seo.service';
|
||||||
|
import { loadAppConfigAsync } from './services/app-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent],
|
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent, StructuredDataComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
export class App implements OnInit, OnDestroy {
|
export class App implements OnInit, OnDestroy {
|
||||||
private titleService = inject(Title);
|
|
||||||
private stationConfig = inject(StationConfigService);
|
private stationConfig = inject(StationConfigService);
|
||||||
|
private seoService = inject(SeoService);
|
||||||
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
readonly hasStream = computed(() => {
|
readonly hasStream = computed(() => {
|
||||||
const cfg = this.stationConfig.config();
|
const cfg = this.stationConfig.config();
|
||||||
@@ -26,11 +32,12 @@ export class App implements OnInit, OnDestroy {
|
|||||||
private resizeObserver: ResizeObserver | null = null;
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// These DOM operations only run in the browser.
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
|
// Update favicon when station config changes.
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const config = this.stationConfig.config();
|
const config = this.stationConfig.config();
|
||||||
const fullName = `${config.name_primary} ${config.name_secondary}`.trim();
|
|
||||||
this.titleService.setTitle(fullName);
|
|
||||||
|
|
||||||
const faviconEl = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
const faviconEl = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
||||||
if (faviconEl && config.logo_url) {
|
if (faviconEl && config.logo_url) {
|
||||||
faviconEl.href = config.logo_url;
|
faviconEl.href = config.logo_url;
|
||||||
@@ -47,6 +54,23 @@ export class App implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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.
|
// Defer until the navbar is rendered in the DOM.
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
this.navbarEl = document.querySelector('app-navbar');
|
this.navbarEl = document.querySelector('app-navbar');
|
||||||
@@ -60,8 +84,10 @@ export class App implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateNavbarHeight(): void {
|
private updateNavbarHeight(): void {
|
||||||
if (!this.navbarEl) return;
|
if (!this.navbarEl) return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="contact-header">
|
<div class="contact-header">
|
||||||
<img src="svg/small-flower.svg" alt="" class="contact-flower" aria-hidden="true">
|
<img src="svg/small-flower.svg" alt="" class="contact-flower" aria-hidden="true">
|
||||||
<h2>Get in <span class="text-accent">Touch</span></h2>
|
<h1>Get in <span class="text-accent">Touch</span></h1>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
Have a song request, want to volunteer, or just want to say hello?
|
Have a song request, want to volunteer, or just want to say hello?
|
||||||
We'd love to hear from you.
|
We'd love to hear from you.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="events-header">
|
<div class="events-header">
|
||||||
<img src="svg/small-flower.svg" alt="" class="events-flower" aria-hidden="true">
|
<img src="svg/small-flower.svg" alt="" class="events-flower" aria-hidden="true">
|
||||||
<h2>Upcoming <span class="text-accent">Events</span></h2>
|
<h1>Upcoming <span class="text-accent">Events</span></h1>
|
||||||
<p class="section-intro">{{ config().events_intro }}</p>
|
<p class="section-intro">{{ config().events_intro }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
@if (event.rsvp_url) {
|
@if (event.rsvp_url) {
|
||||||
<a [href]="event.rsvp_url" class="event-rsvp" target="_blank" rel="noopener">RSVP →</a>
|
<a [href]="event.rsvp_url" class="event-rsvp" target="_blank" rel="noopener">RSVP →</a>
|
||||||
} @else {
|
} @else {
|
||||||
<a href="#" class="event-rsvp">Learn More →</a>
|
<a routerLink="/contact" [queryParams]="{subject: 'general'}" class="event-rsvp">Learn More →</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
.event-date {
|
.event-date {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: $mixed-gradient;
|
background: var(--mixed-gradient);
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, effect, inject } from '@angular/core';
|
||||||
import { AsyncPipe, DatePipe, NgFor } from '@angular/common';
|
import { AsyncPipe, DatePipe, NgFor } from '@angular/common';
|
||||||
|
import { RouterLinkWithHref } from '@angular/router';
|
||||||
import { map, Observable, startWith } from 'rxjs';
|
import { map, Observable, startWith } from 'rxjs';
|
||||||
|
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
import { StructuredDataService } from '../services/structured-data.service';
|
||||||
import { Event } from '../interfaces/event';
|
import { Event } from '../interfaces/event';
|
||||||
|
|
||||||
interface EventsState {
|
interface EventsState {
|
||||||
@@ -14,13 +16,14 @@ interface EventsState {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-events',
|
selector: 'app-events',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AsyncPipe, DatePipe, NgFor],
|
imports: [AsyncPipe, DatePipe, NgFor, RouterLinkWithHref],
|
||||||
templateUrl: './events.component.html',
|
templateUrl: './events.component.html',
|
||||||
styleUrl: './events.component.scss',
|
styleUrl: './events.component.scss',
|
||||||
})
|
})
|
||||||
export class EventsComponent {
|
export class EventsComponent {
|
||||||
private eventService = inject(EventService);
|
private eventService = inject(EventService);
|
||||||
readonly config = inject(StationConfigService).config;
|
readonly config = inject(StationConfigService).config;
|
||||||
|
private structuredData = inject(StructuredDataService);
|
||||||
|
|
||||||
/** Async pipe drives the template — guarantees change detection on every emission. */
|
/** Async pipe drives the template — guarantees change detection on every emission. */
|
||||||
readonly events$: Observable<EventsState> =
|
readonly events$: Observable<EventsState> =
|
||||||
@@ -28,4 +31,14 @@ export class EventsComponent {
|
|||||||
map((upcomingEvents) => ({ loading: false, upcomingEvents })),
|
map((upcomingEvents) => ({ loading: false, upcomingEvents })),
|
||||||
startWith({ loading: true, upcomingEvents: [] }),
|
startWith({ loading: true, upcomingEvents: [] }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Feed active events to structured data for Event schema injection.
|
||||||
|
effect(() => {
|
||||||
|
const snapshot = this.eventService.getEvents(true);
|
||||||
|
snapshot.subscribe((evts) => {
|
||||||
|
this.structuredData.activeEvents.set(evts);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -35,18 +35,26 @@
|
|||||||
@if (config().donation_url) {
|
@if (config().donation_url) {
|
||||||
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Become a Member</a></li>
|
<li><a [href]="config().donation_url" target="_blank" rel="noopener noreferrer">Become a Member</a></li>
|
||||||
}
|
}
|
||||||
<li><a href="#">Volunteer</a></li>
|
<li><a routerLink="/contact" [queryParams]="{subject: 'volunteer'}">Volunteer</a></li>
|
||||||
<li><a href="#">Corporate Giving</a></li>
|
<li><a routerLink="/contact" [queryParams]="{subject: 'advertising'}">Corporate Giving</a></li>
|
||||||
<li><a href="#">Planned Giving</a></li>
|
<li><a routerLink="/contact" [queryParams]="{subject: 'general'}">Planned Giving</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>Follow Us</h4>
|
<h4>Follow Us</h4>
|
||||||
<div class="social-links">
|
<div class="social-links">
|
||||||
<a href="#" aria-label="Facebook" class="social-link">📘</a>
|
@if (config().social_facebook) {
|
||||||
<a href="#" aria-label="Twitter" class="social-link">🐦</a>
|
<a [href]="config().social_facebook" target="_blank" rel="noopener noreferrer" aria-label="Facebook" class="social-link">📘</a>
|
||||||
<a href="#" aria-label="Instagram" class="social-link">📷</a>
|
}
|
||||||
<a href="#" aria-label="YouTube" class="social-link">📺</a>
|
@if (config().social_twitter) {
|
||||||
|
<a [href]="config().social_twitter" target="_blank" rel="noopener noreferrer" aria-label="Twitter" class="social-link">🐦</a>
|
||||||
|
}
|
||||||
|
@if (config().social_instagram) {
|
||||||
|
<a [href]="config().social_instagram" target="_blank" rel="noopener noreferrer" aria-label="Instagram" class="social-link">📷</a>
|
||||||
|
}
|
||||||
|
@if (config().social_youtube) {
|
||||||
|
<a [href]="config().social_youtube" target="_blank" rel="noopener noreferrer" aria-label="YouTube" class="social-link">📺</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, computed } from '@angular/core';
|
import { Component, inject, computed } from '@angular/core';
|
||||||
import { SlicePipe } from '@angular/common';
|
import { SlicePipe } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLinkWithHref } from '@angular/router';
|
||||||
|
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
import { resolveImageUrl } from '../services/app-config.service';
|
import { resolveImageUrl } from '../services/app-config.service';
|
||||||
@@ -8,7 +8,7 @@ import { resolveImageUrl } from '../services/app-config.service';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, SlicePipe],
|
imports: [RouterLinkWithHref, SlicePipe],
|
||||||
templateUrl: './footer.component.html',
|
templateUrl: './footer.component.html',
|
||||||
styleUrl: './footer.component.scss',
|
styleUrl: './footer.component.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ export interface StationConfig {
|
|||||||
play_store_icon_url: string;
|
play_store_icon_url: string;
|
||||||
play_store_url: string;
|
play_store_url: string;
|
||||||
app_store_embed_html: string;
|
app_store_embed_html: string;
|
||||||
|
// Social media
|
||||||
|
social_facebook: string;
|
||||||
|
social_twitter: string;
|
||||||
|
social_instagram: string;
|
||||||
|
social_youtube: string;
|
||||||
// Page content
|
// Page content
|
||||||
hero_subtitle: string;
|
hero_subtitle: string;
|
||||||
about_intro: string;
|
about_intro: 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 {
|
.login-section {
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
min-height: calc(100vh - 80px);
|
min-height: calc(100vh - 80px);
|
||||||
background: $sky-gradient;
|
background: var(--sky-gradient);
|
||||||
padding: $spacing-xl;
|
padding: $spacing-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,13 +21,13 @@
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-family: $font-heading;
|
font-family: $font-heading;
|
||||||
color: $primary-blue;
|
color: var(--color-primary);
|
||||||
margin: $spacing-sm 0 $spacing-xs;
|
margin: $spacing-sm 0 $spacing-xs;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: $neutral-medium;
|
color: var(--color-medium);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,14 +49,14 @@
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $neutral-dark;
|
color: var(--color-dark);
|
||||||
margin-bottom: $spacing-xs;
|
margin-bottom: $spacing-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: $spacing-sm $spacing-md;
|
padding: $spacing-sm $spacing-md;
|
||||||
border: 1px solid $neutral-light;
|
border: 1px solid var(--color-light);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
transition: border-color $transition-fast;
|
transition: border-color $transition-fast;
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: $primary-blue;
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
|
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
.login-error {
|
.login-error {
|
||||||
background: rgba($danger-red, 0.1);
|
background: rgba($danger-red, 0.1);
|
||||||
color: $danger-red;
|
color: var(--color-danger);
|
||||||
padding: $spacing-sm $spacing-md;
|
padding: $spacing-sm $spacing-md;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-login {
|
.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%;
|
width: 100%;
|
||||||
padding: $spacing-sm $spacing-lg;
|
padding: $spacing-sm $spacing-lg;
|
||||||
margin-top: $spacing-sm;
|
margin-top: $spacing-sm;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="schedule-header">
|
<div class="schedule-header">
|
||||||
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="schedule-flower" aria-hidden="true">
|
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="schedule-flower" aria-hidden="true">
|
||||||
<h2>Program <span class="text-accent">Schedule</span></h2>
|
<h1>Program <span class="text-accent">Schedule</span></h1>
|
||||||
<p class="section-intro">{{ config().schedule_intro }}</p>
|
<p class="section-intro">{{ config().schedule_intro }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, effect, inject } from '@angular/core';
|
||||||
import { AsyncPipe } from '@angular/common';
|
import { AsyncPipe } from '@angular/common';
|
||||||
import { map, Observable, startWith } from 'rxjs';
|
import { map, Observable, startWith } from 'rxjs';
|
||||||
|
|
||||||
import { ShowService } from '../services/show.service';
|
import { ShowService } from '../services/show.service';
|
||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
import { StructuredDataService } from '../services/structured-data.service';
|
||||||
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
|
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
|
||||||
import { resolveImageUrl } from '../services/app-config.service';
|
import { resolveImageUrl } from '../services/app-config.service';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ interface ScheduleState {
|
|||||||
export class ScheduleComponent {
|
export class ScheduleComponent {
|
||||||
private showService = inject(ShowService);
|
private showService = inject(ShowService);
|
||||||
readonly config = inject(StationConfigService).config;
|
readonly config = inject(StationConfigService).config;
|
||||||
|
private structuredData = inject(StructuredDataService);
|
||||||
|
|
||||||
/** ID of the currently expanded show card — null means none. */
|
/** ID of the currently expanded show card — null means none. */
|
||||||
expandedShowId: number | null = null;
|
expandedShowId: number | null = null;
|
||||||
@@ -47,4 +49,14 @@ export class ScheduleComponent {
|
|||||||
resolveImageUrl(url: string): string {
|
resolveImageUrl(url: string): string {
|
||||||
return resolveImageUrl(url);
|
return resolveImageUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Feed active shows to structured data for RadioSeason schema injection.
|
||||||
|
effect(() => {
|
||||||
|
const snapshot = this.showService.getShows();
|
||||||
|
snapshot.subscribe((shows) => {
|
||||||
|
this.structuredData.activeShows.set(shows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { APP_INITIALIZER, InjectionToken } from '@angular/core';
|
import { InjectionToken } from '@angular/core';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
@@ -16,15 +16,19 @@ const _appConfig: AppConfig = {
|
|||||||
apiBaseUrl: environment.apiBaseUrl,
|
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(
|
return lastValueFrom(http.get<AppConfig>('config.json')).then(
|
||||||
(config) => { Object.assign(_appConfig, config); },
|
(config) => { Object.assign(_appConfig, config); },
|
||||||
() => {
|
() => {
|
||||||
// Keep defaults if config.json is not available (dev mode).
|
// Keep defaults if config.json is not available (dev mode).
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appConfigProvider = {
|
export const appConfigProvider = {
|
||||||
@@ -32,12 +36,12 @@ export const appConfigProvider = {
|
|||||||
useValue: _appConfig,
|
useValue: _appConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const appConfigInitProvider = {
|
/**
|
||||||
provide: APP_INITIALIZER,
|
* @deprecated The APP_INITIALIZER is no longer registered by default.
|
||||||
useFactory: appConfigFactory,
|
* The `loadAppConfigAsync` function is called directly from `app.config.ts`
|
||||||
deps: [HttpClient],
|
* in a non-blocking way. This export is kept for backward compatibility only.
|
||||||
multi: true,
|
*/
|
||||||
};
|
export const appConfigInitProvider = null;
|
||||||
|
|
||||||
export function getAppConfig(): AppConfig {
|
export function getAppConfig(): AppConfig {
|
||||||
return _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 { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
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 { Event } from '../interfaces/event';
|
||||||
import { getAppConfig } from './app-config.service';
|
import { getAppConfig } from './app-config.service';
|
||||||
@@ -16,7 +17,12 @@ export class EventService {
|
|||||||
getEvents(active?: boolean): Observable<Event[]> {
|
getEvents(active?: boolean): Observable<Event[]> {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
if (active !== undefined) params = params.set('active', String(active));
|
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> {
|
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 { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
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 { Show } from '../interfaces/show';
|
||||||
import { getAppConfig } from './app-config.service';
|
import { getAppConfig } from './app-config.service';
|
||||||
@@ -41,7 +42,12 @@ export class ShowService {
|
|||||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`;
|
private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`;
|
||||||
|
|
||||||
getShows(): Observable<Show[]> {
|
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> {
|
getShow(id: number): Observable<Show> {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { StationConfig } from '../interfaces/station-config';
|
|||||||
import { getAppConfig } from './app-config.service';
|
import { getAppConfig } from './app-config.service';
|
||||||
import { ThemeService } from './theme.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 = {
|
const DEFAULT_CONFIG: StationConfig = {
|
||||||
id: 0,
|
id: 0,
|
||||||
key: 'default',
|
key: 'default',
|
||||||
@@ -32,6 +35,11 @@ const DEFAULT_CONFIG: StationConfig = {
|
|||||||
play_store_icon_url: '',
|
play_store_icon_url: '',
|
||||||
play_store_url: '',
|
play_store_url: '',
|
||||||
app_store_embed_html: '',
|
app_store_embed_html: '',
|
||||||
|
// Social media
|
||||||
|
social_facebook: '',
|
||||||
|
social_twitter: '',
|
||||||
|
social_instagram: '',
|
||||||
|
social_youtube: '',
|
||||||
// Page content
|
// Page content
|
||||||
hero_subtitle: 'Your community-powered, nonprofit radio station — bringing music, stories, and connection to the mountain towns and beyond since 1987.',
|
hero_subtitle: 'Your community-powered, nonprofit radio station — bringing music, stories, and connection to the mountain towns and beyond since 1987.',
|
||||||
about_intro: 'For nearly four decades, KMountain Flower Radio has been the voice of our community — a nonprofit, listener-supported radio station dedicated to music, local news, and the stories that bind mountain towns together.',
|
about_intro: 'For nearly four decades, KMountain Flower Radio has been the voice of our community — a nonprofit, listener-supported radio station dedicated to music, local news, and the stories that bind mountain towns together.',
|
||||||
@@ -66,7 +74,18 @@ export class StationConfigService {
|
|||||||
private themeService = inject(ThemeService);
|
private themeService = inject(ThemeService);
|
||||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
|
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. */
|
/** Load station config from the API. Falls back to defaults on error. */
|
||||||
async load(): Promise<void> {
|
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`.
|
* Apply color theme from StationConfig as CSS custom properties on `:root`.
|
||||||
* Also generates derived gradient variables.
|
* 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 {
|
apply(config: StationConfig): void {
|
||||||
const root = document.documentElement.style;
|
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="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<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 'zone.js';
|
||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { provideClientHydration } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
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) ==========
|
// ========== CSS Custom Properties (exposed to the browser) ==========
|
||||||
// These map SCSS variables to CSS custom properties so Angular components
|
// These map SCSS variables to CSS custom properties so Angular components
|
||||||
// can consume them via var() at runtime.
|
// 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 {
|
: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
|
// Typography
|
||||||
--font-primary: #{$font-primary};
|
--font-primary: #{$font-primary};
|
||||||
--font-heading: #{$font-heading};
|
--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 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. */
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */{
|
||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["src/**/*.spec.ts"]
|
"src/**/*.ts",
|
||||||
|
"server.ts",
|
||||||
|
"spider_detection.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ export default {
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['src/**/*.spec.ts'],
|
include: ['src/**/*.spec.ts', 'server.spec.ts'],
|
||||||
setupFiles: ['src/test-setup.ts'],
|
setupFiles: ['src/test-setup.ts'],
|
||||||
reporters: ['default'],
|
reporters: ['default'],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
Reference in new issue
Block a user