create-momentum-app 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/index.cjs +12 -3
  2. package/package.json +2 -1
  3. package/templates/nestjs/angular.json.tmpl +73 -0
  4. package/templates/nestjs/eslint.config.mjs +31 -0
  5. package/templates/nestjs/package.json.tmpl +69 -0
  6. package/templates/nestjs/postcss.config.js +6 -0
  7. package/templates/nestjs/src/app/app.config.server.ts +10 -0
  8. package/templates/nestjs/src/app/app.config.ts +18 -0
  9. package/templates/nestjs/src/app/app.routes.server.ts +8 -0
  10. package/templates/nestjs/src/app/app.routes.ts +22 -0
  11. package/templates/nestjs/src/app/app.ts +9 -0
  12. package/templates/nestjs/src/app/pages/blocks/hero-block.component.ts +41 -0
  13. package/templates/nestjs/src/app/pages/blocks/image-text-block.component.ts +56 -0
  14. package/templates/nestjs/src/app/pages/blocks/text-block.component.ts +28 -0
  15. package/templates/nestjs/src/app/pages/post-block-providers.ts +17 -0
  16. package/templates/nestjs/src/app/pages/post-detail.resolver.ts +31 -0
  17. package/templates/nestjs/src/app/pages/post-detail.ts +139 -0
  18. package/templates/nestjs/src/app/pages/posts.ts +130 -0
  19. package/templates/nestjs/src/app/pages/welcome.ts +151 -0
  20. package/templates/nestjs/src/collections/posts.collection.ts +89 -0
  21. package/templates/nestjs/src/generated/.gitkeep +0 -0
  22. package/templates/nestjs/src/index.html.tmpl +20 -0
  23. package/templates/nestjs/src/main.server.ts +8 -0
  24. package/templates/nestjs/src/main.ts +5 -0
  25. package/templates/nestjs/src/momentum.config.ts.tmpl +73 -0
  26. package/templates/nestjs/src/server.ts.tmpl +84 -0
  27. package/templates/nestjs/src/styles.css +140 -0
  28. package/templates/nestjs/src/types/.gitkeep +0 -0
  29. package/templates/nestjs/tailwind.config.js +11 -0
  30. package/templates/nestjs/tsconfig.app.json +9 -0
  31. package/templates/nestjs/tsconfig.json +26 -0
package/index.cjs CHANGED
@@ -184,7 +184,15 @@ const pool = (dbAdapter as PostgresAdapterWithRaw).getPool();` : "",
184
184
  dbDevPackage: database === "postgres" ? "" : '"@types/better-sqlite3": "^7.6.13",',
185
185
  envDbVar: database === "postgres" ? "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/momentum" : "DATABASE_PATH=./data/momentum.db",
186
186
  defaultPort: "4200",
187
- externalDependencies: database === "postgres" ? '"pg", "pg-native"' : '"better-sqlite3"',
187
+ externalDependencies: [
188
+ database === "postgres" ? '"pg", "pg-native"' : '"better-sqlite3"',
189
+ ...flavor === "nestjs" ? [
190
+ '"@nestjs/microservices"',
191
+ '"@nestjs/websockets"',
192
+ '"class-validator"',
193
+ '"class-transformer"'
194
+ ] : []
195
+ ].join(", "),
188
196
  prerequisitesDocker: database === "postgres" ? `- **Docker** (for PostgreSQL database)
189
197
  - [macOS](https://www.docker.com/products/docker-desktop/)
190
198
  - [Linux](https://docs.docker.com/engine/install/)
@@ -325,7 +333,7 @@ function parseArgs(argv) {
325
333
  const arg = args[i];
326
334
  if (arg === "--flavor" && args[i + 1]) {
327
335
  const val = args[++i];
328
- if (val === "angular" || val === "analog") {
336
+ if (val === "angular" || val === "analog" || val === "nestjs") {
329
337
  opts.flavor = val;
330
338
  }
331
339
  } else if (arg === "--database" && args[i + 1]) {
@@ -369,7 +377,8 @@ async function runCLI() {
369
377
  message: "Which framework?",
370
378
  choices: [
371
379
  { title: "Angular (Express SSR)", value: "angular" },
372
- { title: "Analog (Nitro)", value: "analog" }
380
+ { title: "Analog (Nitro)", value: "analog" },
381
+ { title: "NestJS (Express)", value: "nestjs" }
373
382
  ]
374
383
  },
375
384
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-momentum-app",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Create a new Momentum CMS application",
5
5
  "license": "MIT",
6
6
  "author": "Momentum CMS Contributors",
@@ -57,6 +57,7 @@
57
57
  "fs-extra": "^11.2.0",
58
58
  "graphql": "16.13.0",
59
59
  "h3": "1.15.5",
60
+ "juice": "11.1.1",
60
61
  "picocolors": "^1.1.0",
61
62
  "prompts": "^2.4.2",
62
63
  "rxjs": "7.8.2"
@@ -0,0 +1,73 @@
1
+ {
2
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3
+ "version": 1,
4
+ "newProjectRoot": "projects",
5
+ "projects": {
6
+ "{{projectName}}": {
7
+ "projectType": "application",
8
+ "root": "",
9
+ "sourceRoot": "src",
10
+ "prefix": "app",
11
+ "architect": {
12
+ "build": {
13
+ "builder": "@angular/build:application",
14
+ "options": {
15
+ "outputPath": "dist/{{projectName}}",
16
+ "index": "src/index.html",
17
+ "browser": "src/main.ts",
18
+ "server": "src/main.server.ts",
19
+ "tsConfig": "tsconfig.app.json",
20
+ "assets": [
21
+ {
22
+ "glob": "**/*",
23
+ "input": "public"
24
+ }
25
+ ],
26
+ "styles": ["src/styles.css"],
27
+ "scripts": [],
28
+ "ssr": {
29
+ "entry": "src/server.ts"
30
+ },
31
+ "outputMode": "server",
32
+ "externalDependencies": [{{externalDependencies}}]
33
+ },
34
+ "configurations": {
35
+ "production": {
36
+ "budgets": [
37
+ {
38
+ "type": "initial",
39
+ "maximumWarning": "1MB",
40
+ "maximumError": "2MB"
41
+ },
42
+ {
43
+ "type": "anyComponentStyle",
44
+ "maximumWarning": "4kB",
45
+ "maximumError": "8kB"
46
+ }
47
+ ],
48
+ "outputHashing": "all"
49
+ },
50
+ "development": {
51
+ "optimization": false,
52
+ "extractLicenses": false,
53
+ "sourceMap": true
54
+ }
55
+ },
56
+ "defaultConfiguration": "production"
57
+ },
58
+ "serve": {
59
+ "builder": "@angular/build:dev-server",
60
+ "configurations": {
61
+ "production": {
62
+ "buildTarget": "{{projectName}}:build:production"
63
+ },
64
+ "development": {
65
+ "buildTarget": "{{projectName}}:build:development"
66
+ }
67
+ },
68
+ "defaultConfiguration": "development"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,31 @@
1
+ import tseslint from 'typescript-eslint';
2
+ import angular from 'angular-eslint';
3
+
4
+ export default tseslint.config(
5
+ {
6
+ files: ['**/*.ts'],
7
+ extends: [...tseslint.configs.recommended, ...angular.configs.tsRecommended],
8
+ rules: {
9
+ '@angular-eslint/directive-selector': [
10
+ 'error',
11
+ {
12
+ type: 'attribute',
13
+ prefix: 'app',
14
+ style: 'camelCase',
15
+ },
16
+ ],
17
+ '@angular-eslint/component-selector': [
18
+ 'error',
19
+ {
20
+ type: 'element',
21
+ prefix: 'app',
22
+ style: 'kebab-case',
23
+ },
24
+ ],
25
+ },
26
+ },
27
+ {
28
+ files: ['**/*.html'],
29
+ extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
30
+ },
31
+ );
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "ng serve",
7
+ "build": "ng build",
8
+ "start": "node dist/{{projectName}}/server/server.mjs",
9
+ "lint": "ng lint",
10
+ "generate": "tsx node_modules/@momentumcms/core/src/generators/generator.cjs src/momentum.config.ts --types src/generated/momentum.types.ts --config src/generated/momentum.config.ts",
11
+ "migrate:generate": "ng generate @momentumcms/migrations:generate",
12
+ "migrate:run": "ng generate @momentumcms/migrations:run",
13
+ "migrate:status": "ng generate @momentumcms/migrations:status",
14
+ "migrate:rollback": "ng generate @momentumcms/migrations:rollback"
15
+ },
16
+ "dependencies": {
17
+ "@angular/animations": "^21.1.0",
18
+ "@angular/aria": "^21.1.0",
19
+ "@angular/cdk": "^21.1.0",
20
+ "@angular/common": "^21.1.0",
21
+ "@angular/compiler": "^21.1.0",
22
+ "@angular/core": "^21.1.0",
23
+ "@angular/forms": "^21.1.0",
24
+ "@angular/platform-browser": "^21.1.0",
25
+ "@angular/platform-server": "^21.1.0",
26
+ "@angular/router": "^21.1.0",
27
+ "@angular/ssr": "^21.1.0",
28
+ "@momentumcms/admin": "^{{packageVersion}}",
29
+ "@momentumcms/auth": "^{{packageVersion}}",
30
+ "@momentumcms/core": "^{{packageVersion}}",
31
+ "@momentumcms/db-drizzle": "^{{packageVersion}}",
32
+ "@momentumcms/server-core": "^{{packageVersion}}",
33
+ "@momentumcms/server-nestjs": "^{{packageVersion}}",
34
+ "@momentumcms/storage": "^{{packageVersion}}",
35
+ "@momentumcms/logger": "^{{packageVersion}}",
36
+ "@momentumcms/migrations": "^{{packageVersion}}",
37
+ "@momentumcms/plugins-core": "^{{packageVersion}}",
38
+ "@momentumcms/plugins-seo": "^{{packageVersion}}",
39
+ "@nestjs/common": "^11.1.0",
40
+ "@nestjs/core": "^11.1.0",
41
+ "@nestjs/platform-express": "^11.1.0",
42
+ "@ng-icons/core": "^33.0.0",
43
+ "@ng-icons/heroicons": "^33.0.0",
44
+ "@tiptap/core": "^3.0.0",
45
+ "@tiptap/extension-link": "^3.0.0",
46
+ "@tiptap/extension-placeholder": "^3.0.0",
47
+ "@tiptap/extension-underline": "^3.0.0",
48
+ "@tiptap/starter-kit": "^3.0.0",
49
+ "dotenv": "^16.5.0",
50
+ "express": "^4.21.0",
51
+ {{dbPackage}},
52
+ "reflect-metadata": "^0.2.0",
53
+ "rxjs": "^7.8.0",
54
+ "tslib": "^2.8.0"
55
+ },
56
+ "devDependencies": {
57
+ "@angular/build": "^21.1.0",
58
+ "@angular/cli": "^21.1.0",
59
+ "@angular/compiler-cli": "^21.1.0",
60
+ {{dbDevPackage}}
61
+ "@types/express": "^4.17.0",
62
+ "@types/node": "^22.15.0",
63
+ "autoprefixer": "^10.4.21",
64
+ "postcss": "^8.5.4",
65
+ "tailwindcss": "^3.4.17",
66
+ "tsx": "^4.0.0",
67
+ "typescript": "~5.9.2"
68
+ }
69
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
@@ -0,0 +1,10 @@
1
+ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
2
+ import { provideServerRendering, withRoutes } from '@angular/ssr';
3
+ import { appConfig } from './app.config';
4
+ import { serverRoutes } from './app.routes.server';
5
+
6
+ const serverConfig: ApplicationConfig = {
7
+ providers: [provideServerRendering(withRoutes(serverRoutes))],
8
+ };
9
+
10
+ export const config = mergeApplicationConfig(appConfig, serverConfig);
@@ -0,0 +1,18 @@
1
+ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
2
+ import { provideRouter, withViewTransitions } from '@angular/router';
3
+ import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
4
+ import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
5
+ import { crudToastInterceptor, provideMomentumFieldRenderers } from '@momentumcms/admin';
6
+ import { routes } from './app.routes';
7
+ import { providePostBlocks } from './pages/post-block-providers';
8
+
9
+ export const appConfig: ApplicationConfig = {
10
+ providers: [
11
+ provideHttpClient(withFetch(), withInterceptors([crudToastInterceptor])),
12
+ provideClientHydration(withEventReplay()),
13
+ provideBrowserGlobalErrorListeners(),
14
+ provideRouter(routes, withViewTransitions()),
15
+ provideMomentumFieldRenderers(),
16
+ ...providePostBlocks(),
17
+ ],
18
+ };
@@ -0,0 +1,8 @@
1
+ import { RenderMode, ServerRoute } from '@angular/ssr';
2
+
3
+ export const serverRoutes: ServerRoute[] = [
4
+ // Admin routes render client-side — guards need to run without SSR hydration conflict
5
+ { path: 'admin/**', renderMode: RenderMode.Client },
6
+ // All other routes use server-side rendering
7
+ { path: '**', renderMode: RenderMode.Server },
8
+ ];
@@ -0,0 +1,22 @@
1
+ import { Route } from '@angular/router';
2
+ import { momentumAdminRoutes } from '@momentumcms/admin';
3
+ import { adminConfig } from '../generated/momentum.config';
4
+
5
+ export const routes: Route[] = [
6
+ {
7
+ path: '',
8
+ loadComponent: () => import('./pages/welcome').then((m) => m.WelcomePage),
9
+ },
10
+ {
11
+ path: 'posts',
12
+ loadComponent: () => import('./pages/posts').then((m) => m.PostsPageComponent),
13
+ },
14
+ {
15
+ path: 'posts/:slug',
16
+ loadComponent: () => import('./pages/post-detail').then((m) => m.PostDetailComponent),
17
+ resolve: {
18
+ postData: () => import('./pages/post-detail.resolver').then((m) => m.postDetailResolver),
19
+ },
20
+ },
21
+ ...momentumAdminRoutes(adminConfig),
22
+ ];
@@ -0,0 +1,9 @@
1
+ import { Component } from '@angular/core';
2
+ import { RouterOutlet } from '@angular/router';
3
+
4
+ @Component({
5
+ selector: 'app-root',
6
+ imports: [RouterOutlet],
7
+ template: '<router-outlet />',
8
+ })
9
+ export class App {}
@@ -0,0 +1,41 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'app-hero-block',
5
+ changeDetection: ChangeDetectionStrategy.OnPush,
6
+ host: {
7
+ class: 'block',
8
+ '[attr.data-testid]': '"block-hero"',
9
+ },
10
+ template: `
11
+ <section class="bg-primary text-primary-foreground py-12 px-4 md:py-20 md:px-8 text-center">
12
+ <div class="mx-auto max-w-4xl">
13
+ <h1 class="text-3xl md:text-4xl lg:text-5xl font-bold mb-4" data-testid="hero-heading">
14
+ {{ heading() }}
15
+ </h1>
16
+ @if (subheading()) {
17
+ <p class="text-lg md:text-xl text-primary-foreground mb-8" data-testid="hero-subheading">
18
+ {{ subheading() }}
19
+ </p>
20
+ }
21
+ @if (ctaText()) {
22
+ <a
23
+ class="inline-block bg-primary-foreground text-primary font-semibold px-6 py-3 rounded-lg hover:bg-primary-foreground/90 transition-colors"
24
+ [href]="ctaLink() || '#'"
25
+ data-testid="hero-cta"
26
+ >
27
+ {{ ctaText() }}
28
+ </a>
29
+ }
30
+ </div>
31
+ </section>
32
+ `,
33
+ })
34
+ export class HeroBlockComponent {
35
+ readonly data = input.required<Record<string, unknown>>();
36
+
37
+ readonly heading = computed((): string => String(this.data()['heading'] ?? ''));
38
+ readonly subheading = computed((): string => String(this.data()['subheading'] ?? ''));
39
+ readonly ctaText = computed((): string => String(this.data()['ctaText'] ?? ''));
40
+ readonly ctaLink = computed((): string => String(this.data()['ctaLink'] ?? ''));
41
+ }
@@ -0,0 +1,56 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'app-image-text-block',
5
+ changeDetection: ChangeDetectionStrategy.OnPush,
6
+ host: {
7
+ class: 'block',
8
+ '[attr.data-testid]': '"block-imageText"',
9
+ },
10
+ template: `
11
+ <section class="py-8 px-4 md:py-16 md:px-8">
12
+ <div
13
+ class="mx-auto max-w-6xl flex flex-col gap-8 md:gap-12 items-center"
14
+ [class.md:flex-row]="!reversed()"
15
+ [class.md:flex-row-reverse]="reversed()"
16
+ >
17
+ <!-- Image -->
18
+ <div class="w-full md:w-1/2" data-testid="image-text-image">
19
+ @if (imageUrl()) {
20
+ <img
21
+ [src]="imageUrl()"
22
+ [alt]="imageAlt() || heading()"
23
+ class="w-full h-auto rounded-lg object-cover bg-muted"
24
+ />
25
+ } @else {
26
+ <div class="w-full aspect-video rounded-lg bg-muted flex items-center justify-center">
27
+ <span class="text-muted-foreground text-sm">No image</span>
28
+ </div>
29
+ }
30
+ </div>
31
+
32
+ <!-- Text -->
33
+ <div class="w-full md:w-1/2">
34
+ <h2
35
+ class="text-2xl md:text-3xl font-bold text-foreground mb-4"
36
+ data-testid="image-text-heading"
37
+ >
38
+ {{ heading() }}
39
+ </h2>
40
+ <p class="text-lg text-muted-foreground leading-relaxed" data-testid="image-text-body">
41
+ {{ body() }}
42
+ </p>
43
+ </div>
44
+ </div>
45
+ </section>
46
+ `,
47
+ })
48
+ export class ImageTextBlockComponent {
49
+ readonly data = input.required<Record<string, unknown>>();
50
+
51
+ readonly heading = computed((): string => String(this.data()['heading'] ?? ''));
52
+ readonly body = computed((): string => String(this.data()['body'] ?? ''));
53
+ readonly imageUrl = computed((): string => String(this.data()['imageUrl'] ?? ''));
54
+ readonly imageAlt = computed((): string => String(this.data()['imageAlt'] ?? ''));
55
+ readonly reversed = computed((): boolean => this.data()['imagePosition'] === 'right');
56
+ }
@@ -0,0 +1,28 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'app-text-block',
5
+ changeDetection: ChangeDetectionStrategy.OnPush,
6
+ host: {
7
+ class: 'block',
8
+ '[attr.data-testid]': '"block-textBlock"',
9
+ },
10
+ template: `
11
+ <section class="py-8 px-4 md:py-12 md:px-8 max-w-3xl mx-auto">
12
+ @if (heading()) {
13
+ <h2 class="text-2xl font-bold text-foreground mb-4" data-testid="text-heading">
14
+ {{ heading() }}
15
+ </h2>
16
+ }
17
+ <p class="text-lg text-muted-foreground leading-relaxed" data-testid="text-body">
18
+ {{ body() }}
19
+ </p>
20
+ </section>
21
+ `,
22
+ })
23
+ export class TextBlockComponent {
24
+ readonly data = input.required<Record<string, unknown>>();
25
+
26
+ readonly heading = computed((): string => String(this.data()['heading'] ?? ''));
27
+ readonly body = computed((): string => String(this.data()['body'] ?? ''));
28
+ }
@@ -0,0 +1,17 @@
1
+ import type { Provider } from '@angular/core';
2
+ import { provideBlockComponents } from '@momentumcms/ui';
3
+ import { HeroBlockComponent } from './blocks/hero-block.component';
4
+ import { TextBlockComponent } from './blocks/text-block.component';
5
+ import { ImageTextBlockComponent } from './blocks/image-text-block.component';
6
+
7
+ /**
8
+ * Provide all post block components for the block renderer.
9
+ * Add to the app's root providers.
10
+ */
11
+ export function providePostBlocks(): Provider[] {
12
+ return provideBlockComponents({
13
+ hero: HeroBlockComponent,
14
+ textBlock: TextBlockComponent,
15
+ imageText: ImageTextBlockComponent,
16
+ });
17
+ }
@@ -0,0 +1,31 @@
1
+ import type { ResolveFn } from '@angular/router';
2
+ import { injectMomentumAPI, type FindResult } from '@momentumcms/admin';
3
+
4
+ /**
5
+ * Resolver for the post detail route.
6
+ *
7
+ * Fetches the post by slug before the component renders. This ensures:
8
+ * - SSR renders the full post content (not just "Loading...")
9
+ * - The admin preview iframe (with scripts disabled) shows the real post
10
+ */
11
+ export const postDetailResolver: ResolveFn<FindResult<Record<string, unknown>>> = (route) => {
12
+ const api = injectMomentumAPI();
13
+ const slug = typeof route.params['slug'] === 'string' ? route.params['slug'] : undefined;
14
+
15
+ if (!slug) {
16
+ return {
17
+ docs: [],
18
+ totalDocs: 0,
19
+ page: 1,
20
+ totalPages: 0,
21
+ limit: 1,
22
+ hasNextPage: false,
23
+ hasPrevPage: false,
24
+ };
25
+ }
26
+
27
+ return api.collection('posts').find({
28
+ where: { slug: { equals: slug } },
29
+ limit: 1,
30
+ });
31
+ };
@@ -0,0 +1,139 @@
1
+ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
2
+ import { Meta, Title } from '@angular/platform-browser';
3
+ import { ActivatedRoute, RouterLink } from '@angular/router';
4
+ import { toSignal } from '@angular/core/rxjs-interop';
5
+ import { map } from 'rxjs';
6
+ import { injectMomentumAPI, type FindResult } from '@momentumcms/admin';
7
+ import { BlockRendererComponent } from '@momentumcms/ui';
8
+
9
+ interface PostDetail {
10
+ id: string;
11
+ title: string;
12
+ blocks: Array<Record<string, unknown>>;
13
+ }
14
+
15
+ @Component({
16
+ selector: 'app-post-detail',
17
+ imports: [RouterLink, BlockRendererComponent],
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ host: {
20
+ class: 'block mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 md:py-12',
21
+ },
22
+ template: `
23
+ <a
24
+ routerLink="/posts"
25
+ class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors"
26
+ data-testid="post-back-link"
27
+ >
28
+ &larr; Back to Posts
29
+ </a>
30
+
31
+ @if (loading()) {
32
+ <p class="text-muted-foreground" data-testid="post-loading">Loading post...</p>
33
+ } @else if (error()) {
34
+ <div class="text-center py-12" data-testid="post-error">
35
+ <h1 class="text-2xl font-bold text-foreground mb-2">Post not found</h1>
36
+ <p class="text-muted-foreground">The post you are looking for does not exist.</p>
37
+ </div>
38
+ } @else if (post(); as p) {
39
+ <article data-testid="post-detail">
40
+ <h1
41
+ class="text-3xl md:text-4xl font-bold text-foreground mb-6"
42
+ data-testid="post-detail-title"
43
+ >
44
+ {{ p.title }}
45
+ </h1>
46
+
47
+ <div data-testid="post-blocks">
48
+ <mcms-block-renderer [blocks]="p.blocks" />
49
+ </div>
50
+ </article>
51
+ }
52
+ `,
53
+ })
54
+ export class PostDetailComponent {
55
+ private readonly route = inject(ActivatedRoute);
56
+ private readonly api = injectMomentumAPI();
57
+ private readonly titleService = inject(Title);
58
+ private readonly metaService = inject(Meta);
59
+
60
+ readonly post = signal<PostDetail | null>(null);
61
+ readonly loading = signal(true);
62
+ readonly error = signal(false);
63
+
64
+ readonly blocks = computed((): Array<Record<string, unknown>> => this.post()?.blocks ?? []);
65
+
66
+ /**
67
+ * Resolved post data from the route resolver (SSR-safe).
68
+ */
69
+ private readonly resolvedPost = toSignal(
70
+ this.route.data.pipe(
71
+ map((data): Record<string, unknown> | null => {
72
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- route resolver returns FindResult
73
+ const result = data['postData'] as FindResult<Record<string, unknown>> | undefined;
74
+ if (!result?.docs?.[0]) return null;
75
+ return result.docs[0];
76
+ }),
77
+ ),
78
+ );
79
+
80
+ constructor() {
81
+ const resolvedDoc = this.resolvedPost();
82
+
83
+ if (resolvedDoc) {
84
+ this.populateFromDoc(resolvedDoc);
85
+ } else {
86
+ const slug: unknown = this.route.snapshot.params['slug'];
87
+ if (typeof slug === 'string') {
88
+ void this.loadPost(slug);
89
+ } else {
90
+ this.loading.set(false);
91
+ this.error.set(true);
92
+ }
93
+ }
94
+ }
95
+
96
+ private populateFromDoc(doc: Record<string, unknown>): void {
97
+ const title = String(doc['title'] ?? '');
98
+ const rawBlocks = doc['pageContent'];
99
+ const blockList = Array.isArray(rawBlocks)
100
+ ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- blocks from API are Record arrays
101
+ (rawBlocks as Array<Record<string, unknown>>)
102
+ : [];
103
+
104
+ this.post.set({
105
+ id: String(doc['id'] ?? ''),
106
+ title,
107
+ blocks: blockList,
108
+ });
109
+
110
+ this.titleService.setTitle(`${title} | Momentum CMS`);
111
+ this.metaService.updateTag({
112
+ name: 'description',
113
+ content: `Read "${title}" on Momentum CMS.`,
114
+ });
115
+ this.metaService.updateTag({ property: 'og:title', content: title });
116
+ this.metaService.updateTag({ property: 'og:type', content: 'article' });
117
+ this.loading.set(false);
118
+ }
119
+
120
+ private async loadPost(slug: string): Promise<void> {
121
+ try {
122
+ const result = await this.api
123
+ .collection<Record<string, unknown>>('posts')
124
+ .find({ where: { slug: { equals: slug } }, limit: 1 });
125
+
126
+ const doc = result.docs[0];
127
+ if (!doc) {
128
+ this.error.set(true);
129
+ this.loading.set(false);
130
+ return;
131
+ }
132
+
133
+ this.populateFromDoc(doc);
134
+ } catch {
135
+ this.error.set(true);
136
+ this.loading.set(false);
137
+ }
138
+ }
139
+ }