Compare commits
7
Commits
fdbc1d78da
...
c4e2dc5b9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e2dc5b9f | ||
|
|
a8b6e65831 | ||
|
|
63bc2fb22d | ||
|
|
e95a158d57 | ||
|
|
2d26269c1a | ||
|
|
0bde1cfc75 | ||
|
|
a783626f34 |
No files matched your search
@@ -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
|
||||||
|
```
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
|
"server": "src/main.server.ts",
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
@@ -55,6 +56,12 @@
|
|||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
"stylePreprocessorOptions": {
|
"stylePreprocessorOptions": {
|
||||||
"includePaths": ["src/styles"]
|
"includePaths": ["src/styles"]
|
||||||
|
},
|
||||||
|
"ssr": {
|
||||||
|
"entry": "server.ts"
|
||||||
|
},
|
||||||
|
"prerender": {
|
||||||
|
"routesFile": "prerender-routes.txt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
Generated
+622
-577
File diff suppressed because it is too large.
Load diff
+10
-4
@@ -5,23 +5,28 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"build:ssr": "ng build",
|
||||||
|
"build:prerender": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:unit:watch": "vitest",
|
"test:unit:watch": "vitest",
|
||||||
"test:unit:coverage": "vitest run --coverage"
|
"test:unit:coverage": "vitest run --coverage",
|
||||||
|
"serve:ssr:radio-station": "node dist/radio-station/server/server.mjs"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.11.0",
|
"packageManager": "npm@11.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^21.2.0",
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.0",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.0",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.0",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.0",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
|
"@angular/platform-server": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
|
"@angular/ssr": "^21.2.0",
|
||||||
"@types/leaflet": "^1.9.17",
|
"@types/leaflet": "^1.9.17",
|
||||||
"@angular/cdk": "^21.2.0",
|
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"ng2-charts": "^10.0.0",
|
"ng2-charts": "^10.0.0",
|
||||||
@@ -34,11 +39,12 @@
|
|||||||
"@angular/build": "^21.2.14",
|
"@angular/build": "^21.2.14",
|
||||||
"@angular/cli": "^21.2.14",
|
"@angular/cli": "^21.2.14",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
"@vitest/coverage-v8": "^4.0.8",
|
"@vitest/coverage-v8": "^4.0.8",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^4.0.8",
|
"vitest": "^4.0.8"
|
||||||
"@types/jsdom": "^21.1.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/
|
||||||
|
/about
|
||||||
|
/schedule
|
||||||
|
/events
|
||||||
|
/contact
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// 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 PRERENDERED_ROUTES and isPrerenderedRoute 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() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { PRERENDERED_ROUTES, isPrerenderedRoute } from './server';
|
||||||
|
|
||||||
|
describe('SSR server — server.ts', () => {
|
||||||
|
describe('PRERENDERED_ROUTES', () => {
|
||||||
|
it('should contain exactly 5 routes', () => {
|
||||||
|
expect(PRERENDERED_ROUTES).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include the home route', () => {
|
||||||
|
expect(PRERENDERED_ROUTES).toContain('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include /about', () => {
|
||||||
|
expect(PRERENDERED_ROUTES).toContain('/about');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include /schedule', () => {
|
||||||
|
expect(PRERENDERED_ROUTES).toContain('/schedule');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include /events', () => {
|
||||||
|
expect(PRERENDERED_ROUTES).toContain('/events');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include /contact', () => {
|
||||||
|
expect(PRERENDERED_ROUTES).toContain('/contact');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPrerenderedRoute()', () => {
|
||||||
|
it('should match exact prerendered paths', () => {
|
||||||
|
expect(isPrerenderedRoute('/')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/about')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/schedule')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/events')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/contact')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match paths with trailing slashes', () => {
|
||||||
|
expect(isPrerenderedRoute('/about/')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/schedule/')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/events/')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/contact/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match paths with query strings', () => {
|
||||||
|
expect(isPrerenderedRoute('/about?foo=bar')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('/schedule?day=monday')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match paths with fragments', () => {
|
||||||
|
expect(isPrerenderedRoute('/events#upcoming')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-prerendered routes', () => {
|
||||||
|
expect(isPrerenderedRoute('/login')).toBe(false);
|
||||||
|
expect(isPrerenderedRoute('/admin')).toBe(false);
|
||||||
|
expect(isPrerenderedRoute('/nonexistent')).toBe(false);
|
||||||
|
expect(isPrerenderedRoute('/admin/dashboard')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle full URLs', () => {
|
||||||
|
expect(isPrerenderedRoute('https://kmtnflower.org/about')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('http://localhost:4200/events')).toBe(true);
|
||||||
|
expect(isPrerenderedRoute('https://kmtnflower.org/login')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalise root path — "/" stays "/"', () => {
|
||||||
|
expect(isPrerenderedRoute('/')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
AngularNodeAppEngine,
|
||||||
|
createNodeRequestHandler,
|
||||||
|
isMainModule,
|
||||||
|
writeResponseToNodeResponse,
|
||||||
|
} from '@angular/ssr/node';
|
||||||
|
import { AngularAppEngine } from '@angular/ssr';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
|
||||||
|
// Enable serving prerendered static HTML from disk. Without this flag the
|
||||||
|
// engine falls back to SSR for every route, ignoring the prerendered output.
|
||||||
|
AngularAppEngine.ɵallowStaticRouteRender = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public routes prerendered at build time (SSG — Static Site Generation).
|
||||||
|
* These five routes are compiled to static HTML during `ng build` and
|
||||||
|
* served directly from disk at runtime for optimal performance and SEO.
|
||||||
|
* The list here mirrors `prerender-routes.txt` and the Prerender entries
|
||||||
|
* in `src/app/app.server.routes.ts`.
|
||||||
|
*/
|
||||||
|
export const PRERENDERED_ROUTES: readonly string[] = [
|
||||||
|
'/',
|
||||||
|
'/about',
|
||||||
|
'/schedule',
|
||||||
|
'/events',
|
||||||
|
'/contact',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a request pathname and check against prerendered routes.
|
||||||
|
* Handles both bare paths (`/about`) and trailing-slash variants (`/about/`).
|
||||||
|
*/
|
||||||
|
export function isPrerenderedRoute(url: string): boolean {
|
||||||
|
const pathname = new URL(url, 'http://localhost').pathname;
|
||||||
|
const normalised = pathname.endsWith('/') && pathname !== '/'
|
||||||
|
? pathname.slice(0, -1)
|
||||||
|
: pathname;
|
||||||
|
return PRERENDERED_ROUTES.includes(normalised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily instantiate AngularNodeAppEngine so the engine boots only when
|
||||||
|
* the first request arrives. The engine handles both prerendered route
|
||||||
|
* lookups and on-demand SSR bootstrapping internally.
|
||||||
|
*/
|
||||||
|
let appEngine: AngularNodeAppEngine | null = null;
|
||||||
|
function getEngine(): AngularNodeAppEngine {
|
||||||
|
if (!appEngine) {
|
||||||
|
appEngine = new AngularNodeAppEngine();
|
||||||
|
}
|
||||||
|
return appEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming requests using the Angular SSR engine.
|
||||||
|
*
|
||||||
|
* Prerendered routes (/ , /about, /schedule, /events, /contact) are served
|
||||||
|
* as pre-generated static HTML from the build output. All other routes
|
||||||
|
* fall back to server-side rendering (SSR) or the fallback 404 handler.
|
||||||
|
*/
|
||||||
|
const requestHandler = createNodeRequestHandler(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const prerendered = isPrerenderedRoute(req.url || '/');
|
||||||
|
if (prerendered) {
|
||||||
|
console.log(`[SSR] Prerendered route: ${req.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AngularNodeAppEngine handles manifest registration internally —
|
||||||
|
// no manual bootstrap import needed. For prerendered routes it
|
||||||
|
// returns pre-generated static HTML; for SSR routes it bootstraps
|
||||||
|
// the Angular app on demand.
|
||||||
|
const response = await getEngine().handle(req);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
await writeResponseToNodeResponse(response, res);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default requestHandler;
|
||||||
|
|
||||||
|
if (isMainModule(import.meta.url)) {
|
||||||
|
const httpServer = http.createServer();
|
||||||
|
httpServer.on('request', async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||||
|
requestHandler(req, res, () => {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Not Found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
httpServer.listen(4200, () => {
|
||||||
|
console.log(`Node SSR server listening on http://localhost:4200`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 prerender the home route', () => {
|
||||||
|
const home = serverRoutes.find((r) => r.path === '');
|
||||||
|
expect(home).toBeDefined();
|
||||||
|
expect(home!.renderMode).toBe(RenderMode.Prerender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prerender /about', () => {
|
||||||
|
const about = serverRoutes.find((r) => r.path === 'about');
|
||||||
|
expect(about).toBeDefined();
|
||||||
|
expect(about!.renderMode).toBe(RenderMode.Prerender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prerender /schedule', () => {
|
||||||
|
const schedule = serverRoutes.find((r) => r.path === 'schedule');
|
||||||
|
expect(schedule).toBeDefined();
|
||||||
|
expect(schedule!.renderMode).toBe(RenderMode.Prerender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prerender /events', () => {
|
||||||
|
const events = serverRoutes.find((r) => r.path === 'events');
|
||||||
|
expect(events).toBeDefined();
|
||||||
|
expect(events!.renderMode).toBe(RenderMode.Prerender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prerender /contact', () => {
|
||||||
|
const contact = serverRoutes.find((r) => r.path === 'contact');
|
||||||
|
expect(contact).toBeDefined();
|
||||||
|
expect(contact!.renderMode).toBe(RenderMode.Prerender);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 exactly 5 prerendered routes', () => {
|
||||||
|
const prerendered = serverRoutes.filter((r) => r.renderMode === RenderMode.Prerender);
|
||||||
|
expect(prerendered).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have exactly 3 SSR routes', () => {
|
||||||
|
const ssr = serverRoutes.filter((r) => r.renderMode === RenderMode.Server);
|
||||||
|
expect(ssr).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server routes configuration for Angular SSR.
|
||||||
|
*
|
||||||
|
* The five public routes are prerendered at build time (SSG) for optimal SEO.
|
||||||
|
* Authenticated routes (/login, /admin) use SSR for dynamic rendering.
|
||||||
|
* All other routes fall back to SSR.
|
||||||
|
*/
|
||||||
|
export const serverRoutes: ServerRoute[] = [
|
||||||
|
{ path: '', renderMode: RenderMode.Prerender },
|
||||||
|
{ path: 'about', renderMode: RenderMode.Prerender },
|
||||||
|
{ path: 'schedule', renderMode: RenderMode.Prerender },
|
||||||
|
{ path: 'events', renderMode: RenderMode.Prerender },
|
||||||
|
{ path: 'contact', renderMode: RenderMode.Prerender },
|
||||||
|
{ path: 'login', renderMode: RenderMode.Server },
|
||||||
|
{ path: 'admin', renderMode: RenderMode.Server },
|
||||||
|
{ path: '**', renderMode: RenderMode.Server },
|
||||||
|
];
|
||||||
+11
-1
@@ -1,4 +1,5 @@
|
|||||||
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 { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { NavbarComponent } from './navbar/navbar.component';
|
import { NavbarComponent } from './navbar/navbar.component';
|
||||||
import { MediaPlayerComponent } from './media-player/media-player.component';
|
import { MediaPlayerComponent } from './media-player/media-player.component';
|
||||||
@@ -17,6 +18,7 @@ import { SeoService } from './services/seo.service';
|
|||||||
export class App implements OnInit, OnDestroy {
|
export class App implements OnInit, OnDestroy {
|
||||||
private stationConfig = inject(StationConfigService);
|
private stationConfig = inject(StationConfigService);
|
||||||
private seoService = inject(SeoService);
|
private seoService = inject(SeoService);
|
||||||
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
readonly hasStream = computed(() => {
|
readonly hasStream = computed(() => {
|
||||||
const cfg = this.stationConfig.config();
|
const cfg = this.stationConfig.config();
|
||||||
@@ -27,6 +29,9 @@ export class App implements OnInit, OnDestroy {
|
|||||||
private resizeObserver: ResizeObserver | null = null;
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// These DOM operations only run in the browser.
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
// Update favicon when station config changes.
|
// Update favicon when station config changes.
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const config = this.stationConfig.config();
|
const config = this.stationConfig.config();
|
||||||
@@ -46,6 +51,9 @@ export class App implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// These DOM operations only run in the browser.
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
// Start watching router for automatic SEO metadata updates per route.
|
// Start watching router for automatic SEO metadata updates per route.
|
||||||
this.seoService.watchRouter();
|
this.seoService.watchRouter();
|
||||||
// Handle the initial route (NavigationEnd already fired before ngOnInit).
|
// Handle the initial route (NavigationEnd already fired before ngOnInit).
|
||||||
@@ -64,8 +72,10 @@ export class App implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateNavbarHeight(): void {
|
private updateNavbarHeight(): void {
|
||||||
if (!this.navbarEl) return;
|
if (!this.navbarEl) return;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||||
|
import '@angular/compiler';
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { Injector } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { EventService } from './event.service';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
// Mock the app-config service
|
||||||
|
vi.mock('./app-config.service', () => ({
|
||||||
|
getAppConfig: () => ({ apiBaseUrl: 'http://localhost:8000' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('EventService — SSR error handling', () => {
|
||||||
|
let httpMock: any;
|
||||||
|
let service: EventService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
httpMock = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
{ provide: HttpClient, useValue: httpMock },
|
||||||
|
EventService,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
service = injector.get(EventService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when API fails (SSR fallback)', async () => {
|
||||||
|
const { throwError } = await import('rxjs');
|
||||||
|
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
|
||||||
|
|
||||||
|
const result = await firstValueFrom(service.getEvents());
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when API fails with active filter', async () => {
|
||||||
|
const { throwError } = await import('rxjs');
|
||||||
|
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
|
||||||
|
|
||||||
|
const result = await firstValueFrom(service.getEvents(true));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the API endpoint', async () => {
|
||||||
|
const { of } = await import('rxjs');
|
||||||
|
httpMock.get.mockReturnValue(of([{ id: 1, name: 'Test Event' }]));
|
||||||
|
|
||||||
|
await firstValueFrom(service.getEvents());
|
||||||
|
expect(httpMock.get).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
import { Event } from '../interfaces/event';
|
import { Event } from '../interfaces/event';
|
||||||
import { getAppConfig } from './app-config.service';
|
import { getAppConfig } from './app-config.service';
|
||||||
@@ -16,7 +17,12 @@ export class EventService {
|
|||||||
getEvents(active?: boolean): Observable<Event[]> {
|
getEvents(active?: boolean): Observable<Event[]> {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
if (active !== undefined) params = params.set('active', String(active));
|
if (active !== undefined) params = params.set('active', String(active));
|
||||||
return this.http.get<Event[]>(this.baseUrl, { params });
|
return this.http.get<Event[]>(this.baseUrl, { params }).pipe(
|
||||||
|
catchError((err) => {
|
||||||
|
console.warn('EventService: Failed to load events, returning empty array.', err);
|
||||||
|
return of<Event[]>([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEvent(id: number): Observable<Event> {
|
getEvent(id: number): Observable<Event> {
|
||||||
|
|||||||
@@ -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 { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { NavigationEnd } from '@angular/router';
|
import { NavigationEnd } from '@angular/router';
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||||
|
import '@angular/compiler';
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { Injector } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { ShowService } from './show.service';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
// Mock the app-config service
|
||||||
|
vi.mock('./app-config.service', () => ({
|
||||||
|
getAppConfig: () => ({ apiBaseUrl: 'http://localhost:8000' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ShowService — SSR error handling', () => {
|
||||||
|
let httpMock: any;
|
||||||
|
let service: ShowService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
httpMock = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
{ provide: HttpClient, useValue: httpMock },
|
||||||
|
ShowService,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
service = injector.get(ShowService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when API fails (SSR fallback)', async () => {
|
||||||
|
const { throwError } = await import('rxjs');
|
||||||
|
httpMock.get.mockReturnValue(throwError(() => new Error('Network error')));
|
||||||
|
|
||||||
|
const result = await firstValueFrom(service.getShows());
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the API endpoint', async () => {
|
||||||
|
const { of } = await import('rxjs');
|
||||||
|
httpMock.get.mockReturnValue(of([{ id: 1, title: 'Test Show' }]));
|
||||||
|
|
||||||
|
await firstValueFrom(service.getShows());
|
||||||
|
expect(httpMock.get).toHaveBeenCalledWith('http://localhost:8000/api/shows');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { getAppConfig } from './app-config.service';
|
import { getAppConfig } from './app-config.service';
|
||||||
@@ -41,7 +42,12 @@ export class ShowService {
|
|||||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`;
|
private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`;
|
||||||
|
|
||||||
getShows(): Observable<Show[]> {
|
getShows(): Observable<Show[]> {
|
||||||
return this.http.get<Show[]>(this.baseUrl);
|
return this.http.get<Show[]>(this.baseUrl).pipe(
|
||||||
|
catchError((err) => {
|
||||||
|
console.warn('ShowService: Failed to load shows, returning empty array.', err);
|
||||||
|
return of<Show[]>([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getShow(id: number): Observable<Show> {
|
getShow(id: number): Observable<Show> {
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||||
|
import '@angular/compiler';
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { Injector } from '@angular/core';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
import { StationConfig } from '../interfaces/station-config';
|
||||||
|
|
||||||
|
describe('ThemeService', () => {
|
||||||
|
let service: ThemeService;
|
||||||
|
|
||||||
|
const mockConfig: StationConfig = {
|
||||||
|
id: 1,
|
||||||
|
key: 'default',
|
||||||
|
callsign: 'KMOF',
|
||||||
|
name_primary: 'KMountain',
|
||||||
|
name_secondary: 'Flower Radio',
|
||||||
|
tagline: 'Community Radio',
|
||||||
|
frequency: '91.7',
|
||||||
|
founded_year: 2020,
|
||||||
|
nonprofit_type: '501c3',
|
||||||
|
ein: '12-3456789',
|
||||||
|
logo_url: 'https://kmtnflower.org/logo.png',
|
||||||
|
hero_background_url: '',
|
||||||
|
hero_icon_url: '',
|
||||||
|
hero_divider_url: '',
|
||||||
|
address: '',
|
||||||
|
phone: '',
|
||||||
|
email: 'info@kmtnflower.org',
|
||||||
|
website: 'kmtnflower.org',
|
||||||
|
donation_url: '',
|
||||||
|
stream_url: '',
|
||||||
|
stream_metadata_url: '',
|
||||||
|
play_store_icon_url: '',
|
||||||
|
play_store_url: '',
|
||||||
|
app_store_embed_html: '',
|
||||||
|
social_facebook: '',
|
||||||
|
social_twitter: '',
|
||||||
|
social_instagram: '',
|
||||||
|
social_youtube: '',
|
||||||
|
hero_subtitle: 'Your community-powered radio station.',
|
||||||
|
about_intro: 'About KMountain Flower Radio.',
|
||||||
|
about_donation_text: '',
|
||||||
|
schedule_intro: 'Our weekly schedule.',
|
||||||
|
events_intro: 'Community events.',
|
||||||
|
color_primary: '#4a90d9',
|
||||||
|
color_primary_light: '#6ba3eb',
|
||||||
|
color_primary_dark: '#2e6faa',
|
||||||
|
color_primary_muted: '#7baee7',
|
||||||
|
color_accent: '#e85d3a',
|
||||||
|
color_accent_light: '#eb7d5e',
|
||||||
|
color_accent_dark: '#c94422',
|
||||||
|
color_accent_muted: '#e98a72',
|
||||||
|
color_background: '#faf6f0',
|
||||||
|
color_text: '#333333',
|
||||||
|
color_success: '#4caf50',
|
||||||
|
color_danger: '#f44336',
|
||||||
|
color_info: '#2196f3',
|
||||||
|
color_white: '#ffffff',
|
||||||
|
color_light: '#eeeeee',
|
||||||
|
color_medium: '#9e9e9e',
|
||||||
|
color_black: '#000000',
|
||||||
|
} as const as StationConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [ThemeService],
|
||||||
|
});
|
||||||
|
service = injector.get(ThemeService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clear all inline CSS custom properties after each test.
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
for (const { prop } of [
|
||||||
|
{ prop: '--color-primary' },
|
||||||
|
{ prop: '--color-primary-light' },
|
||||||
|
{ prop: '--color-primary-dark' },
|
||||||
|
{ prop: '--color-primary-muted' },
|
||||||
|
{ prop: '--color-accent' },
|
||||||
|
{ prop: '--color-accent-light' },
|
||||||
|
{ prop: '--color-accent-dark' },
|
||||||
|
{ prop: '--color-accent-muted' },
|
||||||
|
{ prop: '--color-cream' },
|
||||||
|
{ prop: '--color-dark' },
|
||||||
|
{ prop: '--color-success' },
|
||||||
|
{ prop: '--color-danger' },
|
||||||
|
{ prop: '--color-info' },
|
||||||
|
{ prop: '--color-white' },
|
||||||
|
{ prop: '--color-light' },
|
||||||
|
{ prop: '--color-medium' },
|
||||||
|
{ prop: '--color-black' },
|
||||||
|
{ prop: '--sky-gradient' },
|
||||||
|
{ prop: '--sunset-gradient' },
|
||||||
|
{ prop: '--mixed-gradient' },
|
||||||
|
]) {
|
||||||
|
root.removeProperty(prop);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply all CSS color variables', () => {
|
||||||
|
const setPropertySpy = vi.spyOn(document.documentElement.style, 'setProperty');
|
||||||
|
service.apply(mockConfig);
|
||||||
|
// 17 color fields + 3 gradient fields = 20 setProperty calls
|
||||||
|
expect(setPropertySpy).toHaveBeenCalledTimes(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set primary color CSS variable', () => {
|
||||||
|
service.apply(mockConfig);
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
expect(rootStyle.getPropertyValue('--color-primary')).toBe('#4a90d9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set accent color CSS variable', () => {
|
||||||
|
service.apply(mockConfig);
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
expect(rootStyle.getPropertyValue('--color-accent')).toBe('#e85d3a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set background color to --color-cream', () => {
|
||||||
|
service.apply(mockConfig);
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
expect(rootStyle.getPropertyValue('--color-cream')).toBe('#faf6f0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should derive --sky-gradient from primary colors', () => {
|
||||||
|
service.apply(mockConfig);
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
const sky = rootStyle.getPropertyValue('--sky-gradient');
|
||||||
|
expect(sky).toContain('linear-gradient(135deg');
|
||||||
|
expect(sky).toContain('#2e6faa');
|
||||||
|
expect(sky).toContain('#4a90d9');
|
||||||
|
expect(sky).toContain('#6ba3eb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should derive --sunset-gradient from accent colors', () => {
|
||||||
|
service.apply(mockConfig);
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
const sunset = rootStyle.getPropertyValue('--sunset-gradient');
|
||||||
|
expect(sunset).toContain('linear-gradient(135deg');
|
||||||
|
expect(sunset).toContain('#c94422');
|
||||||
|
expect(sunset).toContain('#e85d3a');
|
||||||
|
expect(sunset).toContain('#eb7d5e');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should derive --mixed-gradient from primary and accent', () => {
|
||||||
|
service.apply(mockConfig);
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
const mixed = rootStyle.getPropertyValue('--mixed-gradient');
|
||||||
|
expect(mixed).toContain('linear-gradient(135deg');
|
||||||
|
expect(mixed).toContain('#2e6faa');
|
||||||
|
expect(mixed).toContain('#4a90d9');
|
||||||
|
expect(mixed).toContain('#e85d3a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work on both browser and server platforms (no platform check)', () => {
|
||||||
|
// ThemeService no longer guards on isPlatformBrowser — it applies CSS vars
|
||||||
|
// during SSR so the server-rendered HTML carries the correct theme.
|
||||||
|
service.apply(mockConfig);
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
expect(rootStyle.getPropertyValue('--color-primary')).toBe('#4a90d9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip fields with empty string values', () => {
|
||||||
|
const emptyConfig = { ...mockConfig, color_primary: '' } as StationConfig;
|
||||||
|
const setPropertySpy = vi.spyOn(document.documentElement.style, 'setProperty');
|
||||||
|
service.apply(emptyConfig);
|
||||||
|
expect(setPropertySpy).not.toHaveBeenCalledWith('--color-primary', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,11 @@ export class ThemeService {
|
|||||||
/**
|
/**
|
||||||
* Apply color theme from StationConfig as CSS custom properties on `:root`.
|
* Apply color theme from StationConfig as CSS custom properties on `:root`.
|
||||||
* Also generates derived gradient variables.
|
* Also generates derived gradient variables.
|
||||||
|
*
|
||||||
|
* Works on both browser and server platforms. During SSR the style properties
|
||||||
|
* are written to the virtual DOM and serialised into the HTML output so the
|
||||||
|
* prerendered / server-rendered page carries the correct colour scheme —
|
||||||
|
* eliminating the "white flash" before hydration.
|
||||||
*/
|
*/
|
||||||
apply(config: StationConfig): void {
|
apply(config: StationConfig): void {
|
||||||
const root = document.documentElement.style;
|
const root = document.documentElement.style;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Load the JIT compiler BEFORE any Angular router/common imports
|
||||||
|
import '@angular/compiler';
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { Injector, PLATFORM_ID } from '@angular/core';
|
||||||
|
import { StructuredDataComponent } from './structured-data.component';
|
||||||
|
import { StructuredDataService } from '../services/structured-data.service';
|
||||||
|
|
||||||
|
describe('StructuredDataComponent — SSR compatibility', () => {
|
||||||
|
let serviceMock: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serviceMock = {
|
||||||
|
structuredData: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT throw when instantiated on the server platform', () => {
|
||||||
|
expect(() => {
|
||||||
|
Injector.create({
|
||||||
|
providers: [
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
{ provide: StructuredDataService, useValue: serviceMock },
|
||||||
|
{
|
||||||
|
provide: StructuredDataComponent,
|
||||||
|
useFactory: () => new StructuredDataComponent(),
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should instantiate without errors on the browser platform', () => {
|
||||||
|
expect(() => {
|
||||||
|
Injector.create({
|
||||||
|
providers: [
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'browser' },
|
||||||
|
{ provide: StructuredDataService, useValue: serviceMock },
|
||||||
|
{
|
||||||
|
provide: StructuredDataComponent,
|
||||||
|
useFactory: () => new StructuredDataComponent(),
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
import { StructuredDataService } from '../services/structured-data.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,10 +17,14 @@ import { StructuredDataService } from '../services/structured-data.service';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
template: '', // renders nothing in the body — writes to <head>
|
template: '', // renders nothing in the body — writes to <head>
|
||||||
})
|
})
|
||||||
export class StructuredDataComponent {
|
export class StructuredDataComponent implements OnDestroy {
|
||||||
private service = inject(StructuredDataService);
|
private service = inject(StructuredDataService);
|
||||||
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Only manipulate DOM in the browser; skip on the server.
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const entries = this.service.structuredData();
|
const entries = this.service.structuredData();
|
||||||
this.renderEntries(entries);
|
this.renderEntries(entries);
|
||||||
@@ -27,6 +32,7 @@ export class StructuredDataComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
this.clearTags();
|
this.clearTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { APP_ID } from '@angular/core';
|
||||||
|
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 { serverRoutes } from './app/app.server.routes';
|
||||||
|
|
||||||
|
const serverAppConfig = {
|
||||||
|
...appConfig,
|
||||||
|
providers: [
|
||||||
|
...appConfig.providers,
|
||||||
|
{ provide: APP_ID, useValue: 'server' },
|
||||||
|
provideServerRendering(withRoutes(serverRoutes)),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap function for server-side rendering.
|
||||||
|
* Exports a function that accepts BootstrapContext and returns ApplicationRef,
|
||||||
|
* as required by @angular/ssr's CommonEngine bootstrap signature.
|
||||||
|
*/
|
||||||
|
export default (ctx: BootstrapContext) =>
|
||||||
|
bootstrapApplication(App, serverAppConfig, ctx);
|
||||||
+5
-1
@@ -1,6 +1,10 @@
|
|||||||
import 'zone.js';
|
import 'zone.js';
|
||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { provideClientHydration } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
bootstrapApplication(App, {
|
||||||
|
...appConfig,
|
||||||
|
providers: [...appConfig.providers, provideClientHydration()],
|
||||||
|
}).catch((err) => console.error(err));
|
||||||
+12
-6
@@ -1,11 +1,17 @@
|
|||||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
/* To learn more about Typescript configuration file: https://typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */{
|
||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["src/**/*.spec.ts"]
|
"src/**/*.ts",
|
||||||
|
"server.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user