create-momentum-app 0.5.1 → 0.5.3

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 (43) hide show
  1. package/index.cjs +23 -10
  2. package/package.json +2 -1
  3. package/templates/analog/src/momentum.config.ts.tmpl +6 -6
  4. package/templates/angular/src/momentum.config.ts.tmpl +6 -6
  5. package/templates/nestjs/angular.json.tmpl +73 -0
  6. package/templates/nestjs/eslint.config.mjs +31 -0
  7. package/templates/nestjs/package.json.tmpl +69 -0
  8. package/templates/nestjs/postcss.config.js +6 -0
  9. package/templates/nestjs/src/app/app.config.server.ts +10 -0
  10. package/templates/nestjs/src/app/app.config.ts +18 -0
  11. package/templates/nestjs/src/app/app.routes.server.ts +8 -0
  12. package/templates/nestjs/src/app/app.routes.ts +22 -0
  13. package/templates/nestjs/src/app/app.ts +9 -0
  14. package/templates/nestjs/src/app/pages/blocks/hero-block.component.ts +41 -0
  15. package/templates/nestjs/src/app/pages/blocks/image-text-block.component.ts +56 -0
  16. package/templates/nestjs/src/app/pages/blocks/text-block.component.ts +28 -0
  17. package/templates/nestjs/src/app/pages/post-block-providers.ts +17 -0
  18. package/templates/nestjs/src/app/pages/post-detail.resolver.ts +31 -0
  19. package/templates/nestjs/src/app/pages/post-detail.ts +139 -0
  20. package/templates/nestjs/src/app/pages/posts.ts +130 -0
  21. package/templates/nestjs/src/app/pages/welcome.ts +151 -0
  22. package/templates/nestjs/src/collections/posts.collection.ts +89 -0
  23. package/templates/nestjs/src/generated/.gitkeep +0 -0
  24. package/templates/nestjs/src/index.html.tmpl +20 -0
  25. package/templates/nestjs/src/main.server.ts +8 -0
  26. package/templates/nestjs/src/main.ts +5 -0
  27. package/templates/nestjs/src/momentum.config.ts.tmpl +73 -0
  28. package/templates/nestjs/src/server.ts.tmpl +84 -0
  29. package/templates/nestjs/src/styles.css +140 -0
  30. package/templates/nestjs/src/types/.gitkeep +0 -0
  31. package/templates/nestjs/tailwind.config.js +11 -0
  32. package/templates/nestjs/tsconfig.app.json +9 -0
  33. package/templates/nestjs/tsconfig.json +26 -0
  34. package/templates/shared/.claude/agents.md +10 -5
  35. package/templates/shared/.claude/skills/add-plugin/SKILL.md +38 -1
  36. package/templates/shared/.claude/skills/admin-config/SKILL.md +76 -0
  37. package/templates/shared/.claude/skills/api-route/SKILL.md +98 -0
  38. package/templates/shared/.claude/skills/collection/SKILL.md +2 -2
  39. package/templates/shared/.claude/skills/component/SKILL.md +136 -0
  40. package/templates/shared/.claude/skills/e2e-test/SKILL.md +164 -0
  41. package/templates/shared/.claude/skills/migrations/SKILL.md +55 -0
  42. package/templates/shared/.claude/skills/momentum-api/SKILL.md +96 -16
  43. package/templates/shared/docker-compose.yml.tmpl +1 -1
package/index.cjs CHANGED
@@ -34,7 +34,10 @@ var import_fs_extra = __toESM(require("fs-extra"));
34
34
  var import_picocolors = __toESM(require("picocolors"));
35
35
  var TEMPLATE_EXT = ".tmpl";
36
36
  function getTemplatesDir() {
37
- return import_node_path.default.resolve(__dirname, "templates");
37
+ const distPath = import_node_path.default.resolve(__dirname, "templates");
38
+ if (import_fs_extra.default.existsSync(distPath))
39
+ return distPath;
40
+ return import_node_path.default.resolve(__dirname, "..", "templates");
38
41
  }
39
42
  function interpolate(content, vars) {
40
43
  let result = content;
@@ -163,7 +166,8 @@ Directory "${projectName}" already exists.`));
163
166
  process.exit(1);
164
167
  }
165
168
  const templatesDir = getTemplatesDir();
166
- const pkgJson = import_fs_extra.default.readJsonSync(import_node_path.default.resolve(__dirname, "package.json"));
169
+ const pkgJsonPath = import_fs_extra.default.existsSync(import_node_path.default.resolve(__dirname, "package.json")) ? import_node_path.default.resolve(__dirname, "package.json") : import_node_path.default.resolve(__dirname, "..", "package.json");
170
+ const pkgJson = import_fs_extra.default.readJsonSync(pkgJsonPath);
167
171
  const packageVersion = pkgJson.version ?? "0.0.1";
168
172
  const vars = {
169
173
  projectName,
@@ -171,9 +175,9 @@ Directory "${projectName}" already exists.`));
171
175
  databaseType: database,
172
176
  dbImport: database === "postgres" ? "import { postgresAdapter } from '@momentumcms/db-drizzle';" : "import { sqliteAdapter } from '@momentumcms/db-drizzle';",
173
177
  dbAdapter: database === "postgres" ? `postgresAdapter({
174
- connectionString: process.env['DATABASE_URL'] ?? 'postgresql://postgres:postgres@localhost:5432/momentum',
178
+ connectionString: process.env['DATABASE_URL'] ?? 'postgresql://postgres:postgres@localhost:5432/${projectName}',
175
179
  })` : `sqliteAdapter({
176
- filename: process.env['DATABASE_PATH'] ?? './data/momentum.db',
180
+ filename: process.env['DATABASE_PATH'] ?? './data/${projectName}.db',
177
181
  })`,
178
182
  dbPoolSetup: database === "postgres" ? `import type { PostgresAdapterWithRaw } from '@momentumcms/db-drizzle';
179
183
 
@@ -182,9 +186,17 @@ const pool = (dbAdapter as PostgresAdapterWithRaw).getPool();` : "",
182
186
  authDbConfig: database === "postgres" ? "db: { type: 'postgres', pool }," : "db: { type: 'sqlite', database: dbAdapter.getRawDatabase() },",
183
187
  dbPackage: database === "postgres" ? '"pg": "^8.18.0"' : '"better-sqlite3": "^12.6.0"',
184
188
  dbDevPackage: database === "postgres" ? "" : '"@types/better-sqlite3": "^7.6.13",',
185
- envDbVar: database === "postgres" ? "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/momentum" : "DATABASE_PATH=./data/momentum.db",
189
+ envDbVar: database === "postgres" ? `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/${projectName}` : `DATABASE_PATH=./data/${projectName}.db`,
186
190
  defaultPort: "4200",
187
- externalDependencies: database === "postgres" ? '"pg", "pg-native"' : '"better-sqlite3"',
191
+ externalDependencies: [
192
+ database === "postgres" ? '"pg", "pg-native"' : '"better-sqlite3"',
193
+ ...flavor === "nestjs" ? [
194
+ '"@nestjs/microservices"',
195
+ '"@nestjs/websockets"',
196
+ '"class-validator"',
197
+ '"class-transformer"'
198
+ ] : []
199
+ ].join(", "),
188
200
  prerequisitesDocker: database === "postgres" ? `- **Docker** (for PostgreSQL database)
189
201
  - [macOS](https://www.docker.com/products/docker-desktop/)
190
202
  - [Linux](https://docs.docker.com/engine/install/)
@@ -196,7 +208,7 @@ This project uses PostgreSQL via Docker. The database is configured in \`docker-
196
208
  **Connection Details:**
197
209
  - Host: \`localhost\`
198
210
  - Port: \`5432\`
199
- - Database: \`momentum\`
211
+ - Database: \`${projectName}\`
200
212
  - Username: \`postgres\`
201
213
  - Password: \`postgres\`
202
214
 
@@ -218,7 +230,7 @@ docker compose logs -f postgres
218
230
 
219
231
  You can also use an external PostgreSQL instance by updating \`DATABASE_URL\` in \`.env\`.` : `### SQLite
220
232
 
221
- This project uses SQLite with the database file at \`./data/momentum.db\`.
233
+ This project uses SQLite with the database file at \`./data/${projectName}.db\`.
222
234
  The database is automatically created on first run - no setup required.`
223
235
  };
224
236
  console.log();
@@ -325,7 +337,7 @@ function parseArgs(argv) {
325
337
  const arg = args[i];
326
338
  if (arg === "--flavor" && args[i + 1]) {
327
339
  const val = args[++i];
328
- if (val === "angular" || val === "analog") {
340
+ if (val === "angular" || val === "analog" || val === "nestjs") {
329
341
  opts.flavor = val;
330
342
  }
331
343
  } else if (arg === "--database" && args[i + 1]) {
@@ -369,7 +381,8 @@ async function runCLI() {
369
381
  message: "Which framework?",
370
382
  choices: [
371
383
  { title: "Angular (Express SSR)", value: "angular" },
372
- { title: "Analog (Nitro)", value: "analog" }
384
+ { title: "Analog (Nitro)", value: "analog" },
385
+ { title: "NestJS (Express)", value: "nestjs" }
373
386
  ]
374
387
  },
375
388
  {
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.3",
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"
@@ -11,13 +11,13 @@ const dbAdapter = {{dbAdapter}};
11
11
 
12
12
  {{dbPoolSetup}}
13
13
 
14
- const authBaseURL =
15
- process.env['BETTER_AUTH_URL'] || `http://localhost:${process.env['PORT'] || {{defaultPort}}}`;
14
+ const PORT = Number(process.env['PORT']) || {{defaultPort}};
15
+ const BASE_URL = process.env['BETTER_AUTH_URL'] || `http://localhost:${PORT}`;
16
16
 
17
17
  export const authPlugin = momentumAuth({
18
18
  {{authDbConfig}}
19
- baseURL: authBaseURL,
20
- trustedOrigins: [authBaseURL],
19
+ baseURL: BASE_URL,
20
+ trustedOrigins: [BASE_URL],
21
21
  email: {
22
22
  appName: '{{projectName}}',
23
23
  },
@@ -28,7 +28,7 @@ export const authPlugin = momentumAuth({
28
28
  */
29
29
  export const seo = seoPlugin({
30
30
  collections: ['posts'],
31
- siteUrl: `http://localhost:${process.env['PORT'] || {{defaultPort}}}`,
31
+ siteUrl: BASE_URL,
32
32
  analysis: true,
33
33
  sitemap: true,
34
34
  robots: true,
@@ -51,7 +51,7 @@ const config = defineMomentumConfig({
51
51
  },
52
52
  },
53
53
  server: {
54
- port: Number(process.env['PORT']) || {{defaultPort}},
54
+ port: PORT,
55
55
  cors: {
56
56
  origin: process.env['CORS_ORIGIN'] || '*',
57
57
  methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
@@ -13,16 +13,16 @@ const dbAdapter = {{dbAdapter}};
13
13
 
14
14
  {{dbPoolSetup}}
15
15
 
16
- const authBaseURL =
17
- process.env['BETTER_AUTH_URL'] || `http://localhost:${process.env['PORT'] || {{defaultPort}}}`;
16
+ const PORT = Number(process.env['PORT']) || {{defaultPort}};
17
+ const BASE_URL = process.env['BETTER_AUTH_URL'] || `http://localhost:${PORT}`;
18
18
 
19
19
  /**
20
20
  * Auth plugin - manages Better Auth integration, user tables, and middleware.
21
21
  */
22
22
  export const authPlugin = momentumAuth({
23
23
  {{authDbConfig}}
24
- baseURL: authBaseURL,
25
- trustedOrigins: [authBaseURL],
24
+ baseURL: BASE_URL,
25
+ trustedOrigins: [BASE_URL],
26
26
  });
27
27
 
28
28
  /**
@@ -30,7 +30,7 @@ export const authPlugin = momentumAuth({
30
30
  */
31
31
  export const seo = seoPlugin({
32
32
  collections: ['posts'],
33
- siteUrl: `http://localhost:${process.env['PORT'] || {{defaultPort}}}`,
33
+ siteUrl: BASE_URL,
34
34
  analysis: true,
35
35
  sitemap: true,
36
36
  robots: true,
@@ -56,7 +56,7 @@ const config = defineMomentumConfig({
56
56
  },
57
57
  },
58
58
  server: {
59
- port: Number(process.env['PORT']) || {{defaultPort}},
59
+ port: PORT,
60
60
  cors: {
61
61
  origin: '*',
62
62
  methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
@@ -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
+ };