Compare commits

...
Author SHA1 Message Date
kfj001 9594187fc1 Merge branch 'main' into bugfix/spider-ssr-gate
CI Pipeline / backend-test (push) Successful in 18m31s
CI Pipeline / frontend-test (push) Successful in 26s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 19m12s
CI Pipeline / frontend-test (pull_request) Successful in 25s
CI Pipeline / frontend-build (pull_request) Successful in 33s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 4s
CI Pipeline / backend-lint (pull_request) Successful in 11s
2026-08-09 12:05:40 -07:00
robot cd6dc96214 style(admin): restyle admin user form to match global design system
CI Pipeline / backend-test (push) Successful in 9m51s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (pull_request) Successful in 14m47s
CI Pipeline / quality-gate (push) Successful in 3s
- Replace hardcoded CSS custom properties with SCSS variables
- Add .form-error/.form-success alert banners
- Add .admin-form styles: .form-row grid, .form-group labels/inputs,
  focus rings, is-invalid states, placeholder styling
- Style .add-admin-section heading with $font-heading, divider underline
- Use button-style mixin for .btn-save (was unstyled before)
- Add .form-actions bar layout
- Add responsive breakpoint: stack form rows on mobile (<768px)
- Consistent spacing via $spacing-* tokens throughout
- Consistent border-radius via $radius-* tokens throughout
2026-08-09 08:44:46 +00:00
robot db402f8192 Merge pull request 'fix(ssr): remove color defaults from :root in _themes.scss' (#12) from fix/remove-root-color-defaults into main
CI Pipeline / backend-test (push) Successful in 9m52s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
2026-08-09 00:17:03 -07:00
robot bb45e8d6ae fix(ssr): inject derived gradients and migrate component SCSS to CSS vars
CI Pipeline / backend-test (push) Successful in 9m52s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Successful in 9m59s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / quality-gate (push) Successful in 2s
The server-injected inline <style> was missing the three derived gradient
variables (--sky-gradient, --sunset-gradient, --mixed-gradient) that
components reference via var(). Without them, components fell back to
SCSS build-time defaults on the first paint, causing a visible flash
before ThemeService.apply() corrected the values.

Additionally, migrate three component SCSS files from hardcoded SCSS
color variables to CSS custom properties so the first paint uses the
server-injected runtime colors:
- login.component.scss: backgrounds, text colors, borders, focus rings
- events.component.scss: event date card background gradient
- about.component.scss: donation CTA background gradient
2026-08-09 06:49:24 +00:00
robot e78a725839 fix(ssr): remove color defaults from :root in _themes.scss
CI Pipeline / backend-test (push) Successful in 9m55s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-test (pull_request) Successful in 9m56s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 1s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / frontend-test (pull_request) Successful in 23s
The SSR server injects fresh color CSS custom properties as an inline
<style> block before </head>, but _themes.scss also set hardcoded
color defaults on :root at build time. The two :root blocks compete
in the cascade — the compiled stylesheet (loaded after the inline
<style>) overwrites the server-injected colors, causing a visible
flash where wrong colors render briefly before ThemeService.apply()
corrects them.

Removing the color properties from :root in _themes.scss makes the
server-injected <style> the sole source of truth for colors. Non-color
properties (fonts, radii, shadows) remain as build-time defaults since
they don't change between deploys.
2026-08-09 05:55:28 +00:00
kfj001 849856aecb Merge pull request 'feat(ssr): spider-only SSR serve CSR shell for regular users' (#11) from bugfix/spider-ssr-gate into main
CI Pipeline / backend-test (push) Successful in 20m11s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / backend-lint (push) Successful in 10s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/11
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-08 21:11:31 -07:00
robot 1b387700d3 fix(ssr): prefer index.csr.html over prerendered index.html for CSR users
CI Pipeline / backend-test (push) Successful in 20m24s
CI Pipeline / frontend-test (push) Successful in 25s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 11s
CI Pipeline / backend-test (pull_request) Successful in 20m14s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 11s
CI Pipeline / quality-gate (pull_request) Successful in 4s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / quality-gate (push) Successful in 3s
The browserIndexPath() function was resolving index.html first, which is
the prerendered output with stale build-time content baked into <app-root>.
Even with window.__STATION_CONFIG injection, the prerendered innerHTML
still shows old content on the first paint until Angular re-renders.

By preferring index.csr.html (the pure CSR shell with empty <app-root>),
regular users get a clean first paint — no stale prerendered DOM to flash.
2026-08-09 01:13:50 +00:00
robot b20fefc432 chore: add placeholder angular-app-engine-manifest.mjs for tests
CI Pipeline / backend-test (push) Successful in 19m58s
CI Pipeline / frontend-test (push) Successful in 25s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 20m7s
CI Pipeline / frontend-test (pull_request) Successful in 25s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 11s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
2026-08-09 00:56:29 +00:00
robot e44f27d1cd fix(ssr): inject fresh station config into CSR shell to eliminate flash
CI Pipeline / backend-test (push) Successful in 18m48s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 4s
CI Pipeline / backend-test (pull_request) Successful in 20m7s
CI Pipeline / frontend-build (pull_request) Successful in 32s
The CSR shell previously rendered with hardcoded DEFAULT_CONFIG values,
then the client-side API call replaced them 50-500ms later, causing a
visible 'flash of stale content' (wrong colors, wrong station name).

Fix: SSR server fetches live station config from the backend API before
serving the CSR shell, injecting it as:
- Inline <script> seeding window.__STATION_CONFIG (client reads sync)
- Inline <style> setting CSS custom properties (correct colors first paint)

Also adds no-cache Cache-Control headers on HTML page responses to
prevent browsers from caching stale content.

StationConfigService now reads window.__STATION_CONFIG on construction
instead of always starting with DEFAULT_CONFIG.
2026-08-09 00:53:50 +00:00
robot 9561339cf5 fix(ssr): resolve index.html from production path for CSR shell
CI Pipeline / backend-test (push) Successful in 20m50s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 15s
CI Pipeline / frontend-test (pull_request) Successful in 45s
CI Pipeline / frontend-build (pull_request) Successful in 39s
CI Pipeline / quality-gate (push) Successful in 5s
CI Pipeline / quality-gate (pull_request) Successful in 4s
CI Pipeline / backend-test (pull_request) Successful in 37m43s
CI Pipeline / backend-lint (pull_request) Successful in 16s
The CSR shell path was hardcoded to a local dev directory
(app/server/browser/index.html) which does not exist in the
Docker container. Browser files are copied to /usr/share/nginx/html
by the Dockerfile. browserIndexPath() now checks both production
and development paths, falling back to the first that exists.
2026-08-08 20:25:29 +00:00
Hermes Robot c5d80f4caa feat(ssr): spider-only SSR — serve CSR shell for regular users
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
CI Pipeline / backend-test (push) Successful in 12m23s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-test (pull_request) Successful in 17m40s
CI Pipeline / backend-lint (pull_request) Successful in 10s
Add spider detection gate to SSR server so that search engine
crawlers get full server-side rendering with fresh API data,
while regular users receive the static CSR app shell from disk.

- backend/app/spider_detection.py: Python spider detection module
- spider_detection.ts: TypeScript mirror of spider detection patterns
- server.ts: SSR gate — isSpider() check before dispatching to
  Angular SSR engine; non-spiders get static index.html
- server.spec.ts: Added spider detection unit tests
- tsconfig.app.json: Include spider_detection.ts in compilation
- backend/app/models.py: Fix SQLAlchemy FK resolution (user_models import)
2026-08-08 17:48:37 +00:00
kfj001 96011ef200 Merge pull request 'fix(ssr): fetch station config during server-side rendering' (#10) from bugfix/ssr-fetch-config-on-server into main
CI Pipeline / backend-test (push) Successful in 5m3s
CI Pipeline / frontend-test (push) Successful in 22s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / quality-gate (push) Successful in 2s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/10
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-08 08:03:52 -07:00
robot 54bd869042 fix(ssr): fetch station config during server-side rendering
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 7s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (pull_request) Successful in 5m14s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / backend-test (push) Successful in 5m18s
CI Pipeline / frontend-test (pull_request) Successful in 22s
The isPlatformBrowser guard in App.ngOnInit() was blocking
stationConfig.load() on the server, so SSR always rendered with
DEFAULT_CONFIG (hardcoded colors, assets, content).

Two-part fix:
- main.server.ts: Add APP_INITIALIZER that blocks server rendering
  until station config is fetched from the API. Only runs on the
  server — is a no-op in the browser to avoid blocking the client.
- app.ts: Move stationConfig.load() before the isPlatformBrowser
  guard so ngOnInit also calls it on the server as a safety net.

This ensures SSR HTML contains real DB values (colors, hero icon,
station name, etc.) instead of build-time defaults — eliminating
the stale-asset flash reported by users.
2026-08-08 12:10:05 +00:00
kfj001 5b1006d321 Merge pull request 'fix: ensure SSR always fetches fresh assets from database' (#9) from bugfix/ssr-asset-freshness into main
CI Pipeline / backend-test (push) Successful in 10m25s
CI Pipeline / frontend-test (push) Successful in 25s
CI Pipeline / backend-lint (push) Successful in 14s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / frontend-build (push) Successful in 30s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/9
2026-08-07 16:43:16 -07:00
DevOps QA 1db84a384f fix(ssr): manually register engine manifest to fix 404 on all routes
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / backend-test (pull_request) Successful in 9m38s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 3s
CI Pipeline / backend-test (push) Successful in 9m49s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 8s
The Angular 21 builder generates angular-app-engine-manifest.mjs at build
time but does NOT inject the import into server.mjs. Without manually
importing and registering the manifest via ɵsetAngularAppEngineManifest,
AngularNodeAppEngine fails with 'Angular app engine manifest is not set'
and every request returns 404.

This matches the exact issue reported in PR #14 on staging where nginx
starts successfully but returns 'Not Found' for all paths.
2026-08-07 19:02:10 +00:00
DevOps QA 89cac854ae fix: use full nginx.conf instead of conf.d snippet to avoid directive nesting error
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (push) Successful in 5m1s
CI Pipeline / frontend-test (push) Successful in 29s
CI Pipeline / backend-test (pull_request) Successful in 4m48s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / quality-gate (push) Successful in 2s
Alpine nginx includes conf.d/*.conf inside the http{} block, but our
template contained map/log_format directives and a server{} block that
conflicted with the include structure. Switched to a complete standalone
nginx.conf with proper user/events/http nesting.
2026-08-07 14:47:48 +00:00
DevOps QA 8f0adf9299 fix: add gettext to Alpine image for envsubst in entrypoint
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 12s
CI Pipeline / backend-test (pull_request) Successful in 5m48s
CI Pipeline / frontend-test (pull_request) Successful in 22s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (push) Successful in 5m46s
CI Pipeline / backend-lint (pull_request) Successful in 8s
2026-08-07 06:38:28 +00:00
DevOps QA c4e852684e fix: use bracket notation for process.env access (TS5.9 strict)
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-test (pull_request) Successful in 5m42s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / backend-lint (pull_request) Successful in 9s
- src/main.server.ts: process.env['SSR_API_BASE_URL'] instead of
  process.env.SSR_API_BASE_URL to satisfy TS4111 index signature rule.
- server.spec.ts: update corresponding test assertions to match new syntax.
2026-08-07 06:03:02 +00:00
robot 328d067d97 fix: ensure SSR always fetches fresh assets from database
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Failing after 27s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 5m24s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Failing after 27s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Root cause: Dockerfile copied only browser/ to nginx; server/server.mjs
was built but never deployed or executed. Prerender baked DEFAULT_CONFIG
into static HTML with no API at build time, causing stale content on
every initial paint.

Changes:
- Dockerfile: serve stage uses node:22-alpine, installs nginx via apk,
  copies both browser/ and server/ bundles from build stage
- docker-entrypoint.sh: starts Angular SSR Node process on port 4200,
  waits for readiness, exports SSR_API_BASE_URL, then exec nginx
- nginx.conf.template: adds upstream ssr_backend block (127.0.0.1:4200),
  proxies / to SSR backend, static assets served directly with 1y cache
- app.server.routes.ts: all routes switched from RenderMode.Prerender
  to RenderMode.Server for zero stale content
- server.ts: removed dead prerender code (PRERENDERED_ROUTES,
  isPrerenderedRoute, allowStaticRouteRender)
- main.server.ts: injects SSR-aware apiBaseUrl via APP_CONFIG
  (reads SSR_API_BASE_URL env var)
- server.spec.ts: updated 5 test suites + added 5 new suites (31 tests)
  covering server.ts cleanup, RenderMode.Server, entrypoint lifecycle,
  Dockerfile build, nginx upstream proxy, and API URL injection
- vitest.config.ts: added server.spec.ts to include pattern

Request flow: Browser -> nginx (port 80) -> SSR server (port 4200)
-> Angular SSR -> HttpClient -> localhost:80/api/station-config
-> nginx proxies to api:8000 -> fresh data rendered in HTML
2026-08-07 05:33:40 +00:00
kfj001 2ece38e52d Merge pull request 'feat(seo): Phase 3 — preload LCP image and reduce APP_INITIALIZER blocking' (#8) from feat/seo-phase3-optimizations into main
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / backend-lint (push) Successful in 11s
CI Pipeline / backend-test (push) Successful in 9m10s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / frontend-build (push) Successful in 31s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/8
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-06 21:08:51 -07:00
kfj001 c4e2dc5b9f Merge pull request 'feat: add SSR and prerendering support' (#7) from feat/ssr-prerendering into main
CI Pipeline / backend-test (push) Successful in 9m9s
CI Pipeline / frontend-test (push) Successful in 26s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 11s
CI Pipeline / quality-gate (push) Successful in 3s
Reviewed-on: kfj001/kmtnflower#7
2026-08-06 21:06:48 -07:00
robot e2356954ef feat(seo): Phase 3 — preload LCP image and reduce APP_INITIALIZER blocking
CI Pipeline / backend-test (push) Successful in 10m9s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / frontend-test (push) Successful in 30s
CI Pipeline / frontend-test (pull_request) Successful in 25s
CI Pipeline / frontend-build (pull_request) Successful in 41s
CI Pipeline / backend-lint (pull_request) Successful in 9s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / backend-test (pull_request) Successful in 10m30s
CI Pipeline / quality-gate (pull_request) Successful in 3s
- Add <link rel="preload"> for hero background image (LCP element) in index.html
- Remove blocking APP_INITIALIZER for config.json and station config loading
- Config loads now fire asynchronously in App.ngOnInit() (browser only)
- SSR/prerender renders instantly with default config — no network wait
- Improves LCP, Time to First Byte, and initial paint for SEO
2026-08-06 21:29:30 +00:00
Hermes Agent a8b6e65831 fix(ssr): enable static route rendering and fix self-import in server.ts
CI Pipeline / backend-test (push) Successful in 9m12s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 9s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (pull_request) Successful in 10m0s
- Add AngularAppEngine.ɵallowStaticRouteRender flag so the engine serves
  prerendered static HTML from disk instead of falling back to SSR
- Rename default export to named const (requestHandler) to avoid self-import
  loop when the module is the entry point
- Use requestHandler directly in the main-module listener instead of
  importing './server.js' which caused TS2307 resolution failure
2026-08-06 18:10:39 +00:00
Hermes Agent 63bc2fb22d fix(ssr): remove broken dynamic import and lazy-init engine
CI Pipeline / backend-test (push) Successful in 5m39s
CI Pipeline / frontend-test (push) Successful in 27s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Successful in 5m24s
CI Pipeline / frontend-test (pull_request) Successful in 22s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
- Replace await import('./main.server.js') with lazy getEngine()
  (original path was wrong — main.server.ts lives in src/, not root)
- AngularNodeAppEngine handles manifest registration internally;
  no manual bootstrap import needed
- Add npm scripts: build:ssr, build:prerender, serve:ssr:radio-station
- Add PRERENDER.md build documentation

Verified: clean build prerenders all 5 routes, 54 tests pass
2026-08-06 16:38:27 +00:00
Hermes Agent e95a158d57 fix(ssr): apply theme CSS vars during SSR and enable client hydration
CI Pipeline / backend-test (push) Successful in 9m4s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / backend-lint (push) Successful in 14s
CI Pipeline / backend-test (pull_request) Successful in 9m17s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / frontend-test (pull_request) Successful in 24s
- ThemeService: remove isPlatformBrowser guard so CSS custom properties
  are applied during SSR/prerender — server-rendered HTML now carries the
  correct color scheme and gradients (fixes missing colors on test site).

- main.ts: add provideClientHydration() so Angular preserves the server-
  rendered DOM on the client instead of destroying and rebuilding it
  (fixes jarring full-page reload between static and dynamic versions).

- Tests: update theme.service.spec.ts to match new behaviour, expand
  coverage with gradient tests and empty-value handling (9 tests).
  Fix structured-data.component.spec.ts import paths. All 54 tests pass.
2026-08-06 03:24:57 +00:00
robot 2d26269c1a fix: align @angular/platform-server and @angular/ssr versions with Angular core
CI Pipeline / backend-test (push) Successful in 11m37s
CI Pipeline / frontend-test (push) Successful in 37s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / frontend-build (pull_request) Successful in 33s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 11m59s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / backend-lint (pull_request) Successful in 12s
The SSR packages were pinned to ^21.2.19 and ^21.2.20 which had
strict peer dependency requirements conflicting with @angular/common
at ^21.2.0. This caused npm ci (used in CI) to fail with peer
dependency resolution errors.

Aligned both packages to ^21.2.0 to match the rest of the Angular
core packages, and regenerated package-lock.json.
2026-08-05 22:11:05 +00:00
robot 0bde1cfc75 fix(tests): load JIT compiler before router imports in seo.service.spec
CI Pipeline / frontend-test (push) Failing after 14s
CI Pipeline / frontend-build (push) Failing after 10s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 11m56s
CI Pipeline / frontend-build (pull_request) Failing after 19s
CI Pipeline / quality-gate (pull_request) Successful in 3s
CI Pipeline / backend-lint (pull_request) Successful in 11s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / backend-test (push) Successful in 14m20s
CI Pipeline / frontend-test (pull_request) Failing after 22s
The PlatformLocation static initializer in @angular/common/router
requires @angular/compiler to be loaded first. Move the compiler
import before any Angular router/common imports so the JIT facade
is available when the module loads.
2026-08-05 21:24:52 +00:00
robot a783626f34 feat: add SSR and prerendering support
CI Pipeline / backend-test (push) Successful in 10m24s
CI Pipeline / frontend-test (push) Failing after 19s
CI Pipeline / frontend-build (push) Failing after 23s
CI Pipeline / backend-lint (push) Successful in 12s
CI Pipeline / backend-test (pull_request) Successful in 10m45s
CI Pipeline / frontend-build (pull_request) Failing after 12s
CI Pipeline / backend-lint (pull_request) Successful in 13s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
CI Pipeline / frontend-test (pull_request) Failing after 20s
- Install @angular/ssr and @angular/platform-server (v21.2.x)
- Create server entry point (server.ts) and server bootstrap (main.server.ts)
- Add server routes config (app.server.routes.ts) with 5 prerendered routes:
  /, /about, /schedule, /events, /contact
- Update angular.json with server/ssr/prerender build configuration
- Add prerender-routes.txt for build-time static route generation
- Fix SSR compatibility:
  - App component: guard DOM access with isPlatformBrowser
  - StructuredDataComponent: skip head manipulation on server
  - ThemeService: skip CSS variable application on server
  - ShowService/EventService: return empty arrays on API failure
    (graceful fallback during prerender with no backend)
- Prerendered output verified: 5 static HTML routes in dist/
2026-08-05 20:22:43 +00:00
40 changed files with 2835 additions and 686 deletions

No files matched your search

+3 -1
View File
@@ -7,8 +7,10 @@ COPY . .
RUN npx ng build --configuration production
# ── Serve stage ────────────────────────────────────────────────────────
FROM docker.io/library/nginx:alpine
FROM docker.io/library/node:22-alpine
RUN apk add --no-cache nginx gettext
COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html
COPY --from=build /app/dist/radio-station/server /app/server
COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template
COPY config.json.template /usr/share/nginx/html/config.json.template
COPY docker-entrypoint.sh /docker-entrypoint.sh
+77
View File
@@ -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
```
+1
View File
@@ -0,0 +1 @@
export default {};
+7
View File
@@ -44,6 +44,7 @@
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"server": "src/main.server.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
@@ -55,6 +56,12 @@
"styles": ["src/styles.scss"],
"stylePreprocessorOptions": {
"includePaths": ["src/styles"]
},
"ssr": {
"entry": "server.ts"
},
"prerender": {
"routesFile": "prerender-routes.txt"
}
},
"configurations": {
+5
View File
@@ -283,3 +283,8 @@ class MobileBuildArtifact(Base):
sha256 = Column(String(64), nullable=False)
size = Column(Integer, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Import user_models so that User, Role, UserRole are registered on Base.metadata
# before SQLAlchemy validates cross-table foreign keys (e.g. mobile_build_requests.created_by_id -> users.id).
from app.user_models import * # noqa: F401,F403
+234
View File
@@ -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 ""
+289
View File
@@ -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
View File
@@ -1,11 +1,36 @@
#!/bin/sh
# Render config templates with environment variables, then start nginx.
# Start the Angular SSR Node process, then nginx.
set -e
# Ensure nginx can write to the shared logs directory
mkdir -p /logs && chown nginx:nginx /logs
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf
# Render nginx config from template to /etc/nginx/nginx.conf
# (full config, not conf.d/ snippet — avoids Alpine include nesting issues)
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/nginx.conf
# Render client-side config.json for fallback use
envsubst '${API_PUBLIC_URL}' < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json
# Start the Angular SSR server (Node process) in the background
# Point at nginx (port 80) so SSR API calls go through the nginx proxy chain
# to the upstream backend. Nginx is started after SSR so the chain is complete
# by the time the first SSR request actually needs API data.
export SSR_API_BASE_URL="http://localhost:80"
echo "[entrypoint] Starting Angular SSR server on port 4200..."
node /app/server/server.mjs &
SSR_PID=$!
# Wait for the SSR server to be ready (up to 10 seconds)
echo "[entrypoint] Waiting for SSR server to be ready..."
for i in $(seq 1 20); do
if node -e "require('http').get('http://localhost:4200/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" 2>/dev/null; then
echo "[entrypoint] SSR server is ready."
break
fi
sleep 0.5
done
# Start nginx in the foreground (primary process)
echo "[entrypoint] Starting nginx..."
exec nginx -g 'daemon off;'
+50 -14
View File
@@ -1,30 +1,47 @@
# Map ISO timestamp to date-only string for daily log rotation
map $time_iso8601 $log_date {
"~^(?<date>\d{4}-\d{2}-\d{2})" $date;
default "unknown";
# Full nginx configuration — rendered to /etc/nginx/nginx.conf
# (NOT placed in conf.d/ to avoid include nesting issues with Alpine nginx)
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
log_format kmtn_json escape=json
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format kmtn_json escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for"'
'}';
access_log /logs/access-$log_date.log kmtn_json;
access_log /logs/access.log kmtn_json;
sendfile on;
server {
upstream ssr_backend {
server 127.0.0.1:4200;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — Angular router handles its own paths
location / {
try_files $uri $uri/ /index.html;
# Serve static assets directly from disk (hashed filenames are immutable)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Dynamic sitemap.xml + robots.txt — proxied to the backend so each
# Dynamic sitemap.xml + robots.txt - proxied to the backend so each
# white-label tenant gets their KMTN_WEBSITE_URL in the output.
location = /sitemap.xml {
proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml;
@@ -53,12 +70,31 @@ server {
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
# Disable caching through the double-proxy chain (NPM → frontend nginx → API)
# Without this, browsers cache responses keyed by the HTTPS origin, then send
# conditional requests (If-None-Match) that get lost or mismatched in the proxy chain.
# Disable caching through the double-proxy chain
proxy_cache off;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Vary "*" always;
}
# Proxy all page requests to the Angular SSR server.
# The SSR server handles both spider SSR rendering and serves CSR shells
# to regular users, with fresh station config injected at runtime.
location / {
proxy_pass http://ssr_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
# SSR responses should not be cached by intermediate proxies
proxy_cache off;
add_header X-Powered-By "Angular SSR" always;
# Prevent browsers from caching HTML pages — content changes frequently
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
}
}
}
+622 -577
View File
File diff suppressed because it is too large. Load diff
+10 -4
View File
@@ -5,23 +5,28 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:ssr": "ng build",
"build:prerender": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage"
"test:unit:coverage": "vitest run --coverage",
"serve:ssr:radio-station": "node dist/radio-station/server/server.mjs"
},
"private": true,
"packageManager": "npm@11.11.0",
"dependencies": {
"@angular/cdk": "^21.2.0",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/platform-server": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/ssr": "^21.2.0",
"@types/leaflet": "^1.9.17",
"@angular/cdk": "^21.2.0",
"chart.js": "^4.4.0",
"leaflet": "^1.9.4",
"ng2-charts": "^10.0.0",
@@ -34,11 +39,12 @@
"@angular/build": "^21.2.14",
"@angular/cli": "^21.2.14",
"@angular/compiler-cli": "^21.2.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^26.1.2",
"@vitest/coverage-v8": "^4.0.8",
"jsdom": "^26.1.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8",
"@types/jsdom": "^21.1.7"
"vitest": "^4.0.8"
}
}
+5
View File
@@ -0,0 +1,5 @@
/
/about
/schedule
/events
/contact
+341
View File
@@ -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");
});
});
+292
View File
@@ -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 };
+147
View File
@@ -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 '';
}
+1 -1
View File
@@ -213,7 +213,7 @@
.about-cta {
text-align: center;
padding: $spacing-2xl $spacing-lg;
background: $mixed-gradient;
background: var(--mixed-gradient);
border-radius: $radius-xl;
color: var(--color-white);
+129 -11
View File
@@ -1,23 +1,141 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.add-admin-section {
margin-top: 2rem;
padding: 1.5rem;
border: 1px solid var(--color-light, #e0e0e0);
border-radius: 8px;
// ── Alert banners ──────────────────────────────────────────────
h3 {
margin: 0 0 1rem 0;
}
.form-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.form-success {
background: rgba($success-green, 0.1);
color: $success-green;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
// ── Admin users list ───────────────────────────────────────────
.admin-users-section {
margin-bottom: 1rem;
margin-bottom: $spacing-lg;
}
.loading-text {
text-align: center;
padding: 2rem;
color: var(--color-medium, #888);
padding: $spacing-xl;
color: $neutral-medium;
}
// ── Add admin form ─────────────────────────────────────────────
.add-admin-section {
margin-top: $spacing-lg;
padding: $spacing-lg;
border: 1px solid $neutral-light;
border-radius: $radius-lg;
background: $neutral-white;
h3 {
font-family: $font-heading;
color: $primary-blue;
font-size: 1.1rem;
margin: 0 0 $spacing-md 0;
padding-bottom: $spacing-xs;
border-bottom: 1px solid $neutral-light;
}
}
.admin-form {
// ── Layout ──────────────────────────────────────────
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
// ── Field group ─────────────────────────────────────
.form-group {
margin-bottom: $spacing-md;
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
margin-bottom: $spacing-xs;
}
input {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.95rem;
font-family: $font-primary;
color: $neutral-dark;
background: $neutral-white;
transition: border-color $transition-fast, box-shadow $transition-fast;
box-sizing: border-box;
&::placeholder {
color: $neutral-medium;
opacity: 1;
}
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
&.is-invalid {
border-color: $danger-red;
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
}
}
}
// ── Actions bar ─────────────────────────────────────
.form-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
margin-top: $spacing-md;
}
.btn-save {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
// ── Responsive ─────────────────────────────────────────────────
@include responsive(md) {
.admin-form {
.form-row {
grid-template-columns: 1fr;
gap: 0;
}
}
.add-admin-section {
padding: $spacing-md;
}
}
+2 -15
View File
@@ -1,31 +1,18 @@
import { APP_INITIALIZER, ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
import {
appConfigInitProvider,
appConfigProvider,
} from './services/app-config.service';
import { StationConfigService } from './services/station-config.service';
export const stationConfigInitProvider = {
provide: APP_INITIALIZER,
useFactory: (stationConfigService: StationConfigService) => {
return () => stationConfigService.load();
},
deps: [StationConfigService],
multi: true,
};
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes),
appConfigProvider,
appConfigInitProvider,
stationConfigInitProvider,
],
};
+70
View File
@@ -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);
});
});
+21
View File
@@ -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 },
];
+23 -1
View File
@@ -1,4 +1,6 @@
import { Component, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
import { Component, computed, effect, inject, OnInit, OnDestroy, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { RouterOutlet } from '@angular/router';
import { NavbarComponent } from './navbar/navbar.component';
import { MediaPlayerComponent } from './media-player/media-player.component';
@@ -6,6 +8,7 @@ import { FooterComponent } from './footer/footer.component';
import { StructuredDataComponent } from './structured-data/structured-data.component';
import { StationConfigService } from './services/station-config.service';
import { SeoService } from './services/seo.service';
import { loadAppConfigAsync } from './services/app-config.service';
@Component({
selector: 'app-root',
@@ -17,6 +20,8 @@ import { SeoService } from './services/seo.service';
export class App implements OnInit, OnDestroy {
private stationConfig = inject(StationConfigService);
private seoService = inject(SeoService);
private platformId = inject(PLATFORM_ID);
private http = inject(HttpClient);
readonly hasStream = computed(() => {
const cfg = this.stationConfig.config();
@@ -27,6 +32,9 @@ export class App implements OnInit, OnDestroy {
private resizeObserver: ResizeObserver | null = null;
constructor() {
// These DOM operations only run in the browser.
if (!isPlatformBrowser(this.platformId)) return;
// Update favicon when station config changes.
effect(() => {
const config = this.stationConfig.config();
@@ -46,6 +54,18 @@ export class App implements OnInit, OnDestroy {
}
ngOnInit(): void {
// Load station config on BOTH server and browser. On the server this
// fetches fresh data from the API so SSR renders with real colors,
// assets, and content — not DEFAULT_CONFIG baked at build time.
this.stationConfig.load().catch(() => {});
// These DOM operations only run in the browser.
if (!isPlatformBrowser(this.platformId)) return;
// Load runtime config.json asynchronously in the background — do not
// block rendering.
loadAppConfigAsync(this.http).catch(() => {});
// Start watching router for automatic SEO metadata updates per route.
this.seoService.watchRouter();
// Handle the initial route (NavigationEnd already fired before ngOnInit).
@@ -64,8 +84,10 @@ export class App implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
if (isPlatformBrowser(this.platformId)) {
this.resizeObserver?.disconnect();
}
}
private updateNavbarHeight(): void {
if (!this.navbarEl) return;
+1 -1
View File
@@ -34,7 +34,7 @@
.event-date {
flex-shrink: 0;
background: $mixed-gradient;
background: var(--mixed-gradient);
color: var(--color-white);
display: flex;
flex-direction: column;
+8 -8
View File
@@ -4,7 +4,7 @@
.login-section {
@include flex-center;
min-height: calc(100vh - 80px);
background: $sky-gradient;
background: var(--sky-gradient);
padding: $spacing-xl;
}
@@ -21,13 +21,13 @@
h1 {
font-family: $font-heading;
color: $primary-blue;
color: var(--color-primary);
margin: $spacing-sm 0 $spacing-xs;
font-size: 1.75rem;
}
p {
color: $neutral-medium;
color: var(--color-medium);
font-size: 0.9rem;
}
}
@@ -49,14 +49,14 @@
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
color: var(--color-dark);
margin-bottom: $spacing-xs;
}
input {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border: 1px solid var(--color-light);
border-radius: $radius-md;
font-size: 1rem;
transition: border-color $transition-fast;
@@ -64,7 +64,7 @@
&:focus {
outline: none;
border-color: $primary-blue;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
}
@@ -72,7 +72,7 @@
.login-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
color: var(--color-danger);
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
@@ -81,7 +81,7 @@
}
.btn-login {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
@include button-style(var(--color-primary), var(--color-white), var(--color-primary-light));
width: 100%;
padding: $spacing-sm $spacing-lg;
margin-top: $spacing-sm;
+14 -10
View File
@@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { APP_INITIALIZER, InjectionToken } from '@angular/core';
import { InjectionToken } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { environment } from '../../environments/environment';
@@ -16,15 +16,19 @@ const _appConfig: AppConfig = {
apiBaseUrl: environment.apiBaseUrl,
};
export function appConfigFactory(http: HttpClient) {
return (): Promise<void> => {
/**
* Load runtime config.json asynchronously AFTER the app boots.
* Replaces the former APP_INITIALIZER that blocked the initial render.
* This way the page renders immediately with environment defaults, then
* patches in the runtime API URL once it arrives.
*/
export function loadAppConfigAsync(http: HttpClient): Promise<void> {
return lastValueFrom(http.get<AppConfig>('config.json')).then(
(config) => { Object.assign(_appConfig, config); },
() => {
// Keep defaults if config.json is not available (dev mode).
},
);
};
}
export const appConfigProvider = {
@@ -32,12 +36,12 @@ export const appConfigProvider = {
useValue: _appConfig,
};
export const appConfigInitProvider = {
provide: APP_INITIALIZER,
useFactory: appConfigFactory,
deps: [HttpClient],
multi: true,
};
/**
* @deprecated The APP_INITIALIZER is no longer registered by default.
* The `loadAppConfigAsync` function is called directly from `app.config.ts`
* in a non-blocking way. This export is kept for backward compatibility only.
*/
export const appConfigInitProvider = null;
export function getAppConfig(): AppConfig {
return _appConfig;
+59
View File
@@ -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();
});
});
+8 -2
View File
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Event } from '../interfaces/event';
import { getAppConfig } from './app-config.service';
@@ -16,7 +17,12 @@ export class EventService {
getEvents(active?: boolean): Observable<Event[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<Event[]>(this.baseUrl, { params });
return this.http.get<Event[]>(this.baseUrl, { params }).pipe(
catchError((err) => {
console.warn('EventService: Failed to load events, returning empty array.', err);
return of<Event[]>([]);
}),
);
}
getEvent(id: number): Observable<Event> {
+4
View File
@@ -1,3 +1,7 @@
// 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';
+51
View File
@@ -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');
});
});
+8 -2
View File
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Show } from '../interfaces/show';
import { getAppConfig } from './app-config.service';
@@ -41,7 +42,12 @@ export class ShowService {
private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`;
getShows(): Observable<Show[]> {
return this.http.get<Show[]>(this.baseUrl);
return this.http.get<Show[]>(this.baseUrl).pipe(
catchError((err) => {
console.warn('ShowService: Failed to load shows, returning empty array.', err);
return of<Show[]>([]);
}),
);
}
getShow(id: number): Observable<Show> {
+16 -2
View File
@@ -6,7 +6,10 @@ import { StationConfig } from '../interfaces/station-config';
import { getAppConfig } from './app-config.service';
import { ThemeService } from './theme.service';
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
/**
* Hardcoded defaults — used when the API is unavailable (dev fallback).
* Rendered immediately on the initial paint so the page is never blank.
*/
const DEFAULT_CONFIG: StationConfig = {
id: 0,
key: 'default',
@@ -71,7 +74,18 @@ export class StationConfigService {
private themeService = inject(ThemeService);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
/**
* Read pre-seeded config injected by the server at runtime.
* The SSR server fetches fresh station config and injects it as
* `window.__STATION_CONFIG` before the Angular bundle loads.
* This eliminates the DEFAULT_CONFIG flash: the client bootstraps
* with real data instead of hardcoded defaults.
*/
readonly config = signal<StationConfig>(
typeof window !== 'undefined' && (window as any).__STATION_CONFIG
? (window as any).__STATION_CONFIG as StationConfig
: DEFAULT_CONFIG,
);
/** Load station config from the API. Falls back to defaults on error. */
async load(): Promise<void> {
+170
View File
@@ -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', '');
});
});
+5
View File
@@ -35,6 +35,11 @@ export class ThemeService {
/**
* Apply color theme from StationConfig as CSS custom properties on `:root`.
* Also generates derived gradient variables.
*
* Works on both browser and server platforms. During SSR the style properties
* are written to the virtual DOM and serialised into the HTML output so the
* prerendered / server-rendered page carries the correct colour scheme —
* eliminating the "white flash" before hydration.
*/
apply(config: StationConfig): void {
const root = document.documentElement.style;
@@ -0,0 +1,49 @@
// Load the JIT compiler BEFORE any Angular router/common imports
import '@angular/compiler';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Injector, PLATFORM_ID } from '@angular/core';
import { StructuredDataComponent } from './structured-data.component';
import { StructuredDataService } from '../services/structured-data.service';
describe('StructuredDataComponent — SSR compatibility', () => {
let serviceMock: any;
beforeEach(() => {
serviceMock = {
structuredData: vi.fn(() => []),
};
});
it('should NOT throw when instantiated on the server platform', () => {
expect(() => {
Injector.create({
providers: [
{ provide: PLATFORM_ID, useValue: 'server' },
{ provide: StructuredDataService, useValue: serviceMock },
{
provide: StructuredDataComponent,
useFactory: () => new StructuredDataComponent(),
deps: [],
},
],
});
}).not.toThrow();
});
it('should instantiate without errors on the browser platform', () => {
expect(() => {
Injector.create({
providers: [
{ provide: PLATFORM_ID, useValue: 'browser' },
{ provide: StructuredDataService, useValue: serviceMock },
{
provide: StructuredDataComponent,
useFactory: () => new StructuredDataComponent(),
deps: [],
},
],
});
}).not.toThrow();
});
});
@@ -1,4 +1,5 @@
import { Component, effect, inject } from '@angular/core';
import { Component, effect, inject, PLATFORM_ID, OnDestroy } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { StructuredDataService } from '../services/structured-data.service';
/**
@@ -16,10 +17,14 @@ import { StructuredDataService } from '../services/structured-data.service';
standalone: true,
template: '', // renders nothing in the body — writes to <head>
})
export class StructuredDataComponent {
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);
@@ -27,6 +32,7 @@ export class StructuredDataComponent {
}
ngOnDestroy(): void {
if (!isPlatformBrowser(this.platformId)) return;
this.clearTags();
}
+4
View File
@@ -6,6 +6,10 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Preload hero background image (LCP element). The default SVG is inlined
here as a fallback; the runtime value from station config replaces it
dynamically after hydration. -->
<link rel="preload" as="image" href="svg/mountain-landscape.svg" />
</head>
<body>
<app-root></app-root>
+48
View File
@@ -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
View File
@@ -1,6 +1,10 @@
import 'zone.js';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideClientHydration } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
bootstrapApplication(App, {
...appConfig,
providers: [...appConfig.providers, provideClientHydration()],
}).catch((err) => console.error(err));
+7 -25
View File
@@ -3,33 +3,15 @@
// ========== CSS Custom Properties (exposed to the browser) ==========
// These map SCSS variables to CSS custom properties so Angular components
// can consume them via var() at runtime.
//
// COLOR variables (color_*, gradient_*) are NOT set here anymore.
// They are injected at runtime by the SSR server as an inline <style> block
// so the first paint always carries the correct station colors.
// Client-side ThemeService.apply() keeps them in sync after Angular boots.
//
// Non-color variables (fonts, radii, shadows) remain below as build-time defaults.
:root {
// Primary
--color-primary: #{$primary-blue};
--color-primary-light: #{$primary-blue-light};
--color-primary-dark: #{$primary-blue-dark};
--color-primary-muted: #{$primary-blue-muted};
// Accent
--color-accent: #{$accent-orange};
--color-accent-light: #{$accent-orange-light};
--color-accent-dark: #{$accent-orange-dark};
--color-accent-muted: #{$accent-orange-muted};
// Neutrals
--color-white: #{$neutral-white};
--color-cream: #{$neutral-cream};
--color-light: #{$neutral-light};
--color-medium: #{$neutral-medium};
--color-dark: #{$neutral-dark};
--color-black: #{$neutral-black};
// Semantic
--color-success: #{$success-green};
--color-danger: #{$danger-red};
--color-info: #{$info-blue};
// Typography
--font-primary: #{$font-primary};
--font-heading: #{$font-heading};
+13 -6
View File
@@ -1,11 +1,18 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
/* To learn more about Typescript configuration file: https://typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
"types": [
"node"
]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
"include": [
"src/**/*.ts",
"server.ts",
"spider_detection.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+1 -1
View File
@@ -3,7 +3,7 @@ export default {
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
include: ['src/**/*.spec.ts', 'server.spec.ts'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {