nca-ai-cms-astro-plugin 1.0.12 → 1.0.13

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nca-ai-cms-astro-plugin",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -33,6 +33,7 @@ export const POST: APIRoute = async ({ request }) => {
33
33
  filepath: article.filepath,
34
34
  tags: article.tags,
35
35
  date: article.date.toISOString(),
36
+ ...(generator.warnings.length > 0 ? { warnings: generator.warnings } : {}),
36
37
  });
37
38
  } catch (error) {
38
39
  console.error('Generation error:', error);
@@ -10,7 +10,7 @@ const SUB_TABS: { key: SettingsSubTab; label: string }[] = [
10
10
  { key: 'website', label: 'Website' },
11
11
  ];
12
12
 
13
- const SETTINGS_TABS: SettingsSubTab[] = ['homepage', 'website', 'image-ai'];
13
+ const SETTINGS_TABS: SettingsSubTab[] = ['homepage', 'website', 'image-ai', 'content-ai'];
14
14
 
15
15
  const SETTINGS_FIELDS: Record<string, { key: string; label: string; type: 'input' | 'textarea' }[]> = {
16
16
  homepage: [
@@ -23,12 +23,21 @@ const SETTINGS_FIELDS: Record<string, { key: string; label: string; type: 'input
23
23
  { key: 'core_message', label: 'Kernbotschaft', type: 'textarea' },
24
24
  ],
25
25
  website: [
26
- { key: 'cta_url', label: 'CTA Link', type: 'input' },
27
- { key: 'cta_style', label: 'CTA Stil', type: 'input' },
28
- { key: 'cta_prompt', label: 'CTA Prompt', type: 'textarea' },
29
26
  { key: 'core_tags', label: 'Core Tags (kommagetrennt)', type: 'input' },
30
27
  { key: 'brand_guidelines', label: 'Markenrichtlinien', type: 'textarea' },
31
28
  ],
29
+ 'content-ai': [
30
+ { key: 'content.branche', label: 'Branche / Fachgebiet', type: 'input' },
31
+ { key: 'content.zielgruppe', label: 'Zielgruppe', type: 'textarea' },
32
+ { key: 'content.tonalitaet', label: 'Tonalitaet / Stil', type: 'textarea' },
33
+ { key: 'content.blacklist', label: 'Blacklist (kommagetrennt)', type: 'textarea' },
34
+ { key: 'content.min_wortanzahl', label: 'Minimale Wortanzahl', type: 'input' },
35
+ { key: 'content.max_wortanzahl', label: 'Maximale Wortanzahl', type: 'input' },
36
+ { key: 'content.stil_regeln', label: 'Zusaetzliche Stilregeln', type: 'textarea' },
37
+ { key: 'content.cta_url', label: 'CTA Link-Ziel', type: 'input' },
38
+ { key: 'content.cta_style', label: 'CTA Stil', type: 'input' },
39
+ { key: 'content.cta_prompt', label: 'CTA Anweisung', type: 'textarea' },
40
+ ],
32
41
  'image-ai': [
33
42
  { key: 'image.baseStylePrompt', label: 'Bildstil-Prompt', type: 'textarea' },
34
43
  { key: 'image.constraints', label: 'Bild-Einschraenkungen', type: 'textarea' },
@@ -46,9 +55,9 @@ const CATEGORY_GUIDES: Record<SettingsSubTab, { title: string; description: stri
46
55
  example: '',
47
56
  },
48
57
  'content-ai': {
49
- title: 'Content-KI Prompts',
50
- description: 'Steuere hier, wie die KI Blogartikel und Texte generiert. Je praeziser der Prompt, desto besser das Ergebnis. Definiere Schreibstil, SEO-Keywords und inhaltliche Schwerpunkte.',
51
- example: 'Beispiel: "Schreibe einen Fachartikel mit 800-1200 Woertern. Verwende die Keywords [KEYWORD] natuerlich im Text. Struktur: Einleitung mit Hook, 3-4 Abschnitte mit H2, Fazit mit CTA. Ton: fachlich aber verstaendlich."',
58
+ title: 'Content-KI Einstellungen & Prompts',
59
+ description: 'Konfiguriere Branche, Zielgruppe, Tonalitaet und CTA. Darunter verwaltest du Prompts fuer die Content-Generierung.',
60
+ example: '',
52
61
  },
53
62
  'analysis-ai': {
54
63
  title: 'Analyse-KI Prompts',
@@ -333,7 +342,7 @@ export function SettingsTab() {
333
342
  )}
334
343
 
335
344
  {/* Prompt tabs: Content-KI, Analyse-KI, Bild-KI — prompt cards */}
336
- {!loading && !isSettingsTab(activeSubTab) && (
345
+ {!loading && (!isSettingsTab(activeSubTab) || activeSubTab === 'content-ai') && (
337
346
  <>
338
347
  {filteredPrompts.length === 0 && !showAddForm && (
339
348
  <div style={styles.emptyGuide}>
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ let callCount = 0;
4
+ const mockGenerateContent = vi.fn().mockImplementation(() => {
5
+ callCount++;
6
+ if (callCount % 2 === 1) {
7
+ // First call: researchKeywords → SourceAnalysis
8
+ return {
9
+ response: {
10
+ text: () =>
11
+ JSON.stringify({
12
+ topic: 'PHP Testing',
13
+ keyPoints: ['Unit Tests sind wichtig'],
14
+ uniqueInsights: ['PHPUnit 11 hat neue Features'],
15
+ codeExamples: ['assertEquals()'],
16
+ }),
17
+ },
18
+ };
19
+ }
20
+ // Second call: generateContent → article
21
+ return {
22
+ response: {
23
+ text: () =>
24
+ JSON.stringify({
25
+ title: 'Test Titel',
26
+ description: 'Test Beschreibung',
27
+ content: '# Test\n\nInhalt hier',
28
+ tags: ['test'],
29
+ }),
30
+ },
31
+ };
32
+ });
33
+
34
+ vi.mock('@google/generative-ai', () => {
35
+ return {
36
+ GoogleGenerativeAI: class {
37
+ getGenerativeModel() {
38
+ return { generateContent: mockGenerateContent };
39
+ }
40
+ },
41
+ SchemaType: { OBJECT: 'OBJECT', STRING: 'STRING', ARRAY: 'ARRAY' },
42
+ };
43
+ });
44
+
45
+ vi.mock('./ContentFetcher', () => ({
46
+ ContentFetcher: class {
47
+ async fetch() {
48
+ return {
49
+ title: 'Source Title',
50
+ url: 'https://example.com',
51
+ content: 'Source content here',
52
+ };
53
+ }
54
+ },
55
+ }));
56
+
57
+ import type { ContentSettings } from './PromptService';
58
+ import type { IPromptService } from './ContentGenerator';
59
+ import { ContentGenerator } from './ContentGenerator';
60
+
61
+ function makeValidContentSettings(): ContentSettings {
62
+ return {
63
+ branche: 'Web-Entwicklung',
64
+ zielgruppe: 'Entwickler und CTOs',
65
+ tonalitaet: 'Professionell',
66
+ blacklist: '',
67
+ minWortanzahl: '800',
68
+ maxWortanzahl: '1200',
69
+ stilRegeln: '',
70
+ ctaUrl: 'https://example.com/kontakt',
71
+ ctaStyle: 'Einladend',
72
+ ctaPrompt: 'Generiere einen CTA',
73
+ };
74
+ }
75
+
76
+ function makeMockPromptService(overrides: Partial<ContentSettings> = {}): IPromptService {
77
+ const settings = { ...makeValidContentSettings(), ...overrides };
78
+ return {
79
+ getPrompt: vi.fn().mockResolvedValue(null),
80
+ getCTAConfig: vi.fn().mockResolvedValue({
81
+ url: settings.ctaUrl,
82
+ style: settings.ctaStyle,
83
+ prompt: settings.ctaPrompt,
84
+ }),
85
+ getCoreTags: vi.fn().mockResolvedValue([]),
86
+ getContentSettings: vi.fn().mockResolvedValue(settings),
87
+ validateContentSettings: vi.fn().mockReturnValue({ valid: true, missing: [] }),
88
+ };
89
+ }
90
+
91
+ describe('ContentGenerator', () => {
92
+ beforeEach(() => {
93
+ callCount = 0;
94
+ mockGenerateContent.mockClear();
95
+ });
96
+
97
+ describe('buildSystemPrompt via generateFromKeywords', () => {
98
+ it('throws when settings not configured', async () => {
99
+ const promptService = makeMockPromptService();
100
+ (promptService.validateContentSettings as ReturnType<typeof vi.fn>).mockReturnValue({
101
+ valid: false,
102
+ missing: ['branche', 'zielgruppe'],
103
+ });
104
+
105
+ const generator = new ContentGenerator({
106
+ apiKey: 'test-key',
107
+ promptService,
108
+ });
109
+
110
+ await expect(generator.generateFromKeywords('test')).rejects.toThrow(
111
+ 'Content-Generierung nicht konfiguriert. Fehlende Settings: branche, zielgruppe'
112
+ );
113
+ });
114
+
115
+ it('error message lists missing fields', async () => {
116
+ const promptService = makeMockPromptService();
117
+ (promptService.validateContentSettings as ReturnType<typeof vi.fn>).mockReturnValue({
118
+ valid: false,
119
+ missing: ['ctaUrl', 'ctaStyle', 'ctaPrompt'],
120
+ });
121
+
122
+ const generator = new ContentGenerator({
123
+ apiKey: 'test-key',
124
+ promptService,
125
+ });
126
+
127
+ await expect(generator.generateFromKeywords('test')).rejects.toThrow(
128
+ 'ctaUrl, ctaStyle, ctaPrompt'
129
+ );
130
+ });
131
+
132
+ it('generates content when settings configured', async () => {
133
+ const promptService = makeMockPromptService();
134
+ const generator = new ContentGenerator({
135
+ apiKey: 'test-key',
136
+ promptService,
137
+ });
138
+
139
+ const article = await generator.generateFromKeywords('PHP Testing');
140
+
141
+ expect(article.title).toBe('Test Titel');
142
+ expect(article.content).toBe('# Test\n\nInhalt hier');
143
+ });
144
+
145
+ it('getCoreTags returns empty array (no hardcoded fallback)', async () => {
146
+ const promptService = makeMockPromptService();
147
+ (promptService.getCoreTags as ReturnType<typeof vi.fn>).mockResolvedValue([]);
148
+
149
+ const generator = new ContentGenerator({
150
+ apiKey: 'test-key',
151
+ promptService,
152
+ });
153
+
154
+ const article = await generator.generateFromKeywords('test');
155
+
156
+ expect(article.tags).toEqual(['test']);
157
+ expect(article.tags).not.toContain('Web-Entwicklung');
158
+ expect(article.tags).not.toContain('Best Practices');
159
+ });
160
+ });
161
+
162
+ describe('blacklist check', () => {
163
+ it('checkBlacklist returns empty when content is clean', () => {
164
+ const generator = new ContentGenerator({
165
+ apiKey: 'test-key',
166
+ promptService: makeMockPromptService(),
167
+ });
168
+
169
+ // Access private method for testing
170
+ const result = (generator as any).checkBlacklist('Dieser Text ist sauber.', 'gratis,kostenlos');
171
+
172
+ expect(result).toEqual([]);
173
+ });
174
+
175
+ it('checkBlacklist detects blacklisted terms', () => {
176
+ const generator = new ContentGenerator({
177
+ apiKey: 'test-key',
178
+ promptService: makeMockPromptService(),
179
+ });
180
+
181
+ const result = (generator as any).checkBlacklist(
182
+ 'Dieses gratis Tool ist kostenlos.',
183
+ 'gratis,kostenlos'
184
+ );
185
+
186
+ expect(result).toEqual(['gratis', 'kostenlos']);
187
+ });
188
+
189
+ it('checkBlacklist returns empty with empty blacklist', () => {
190
+ const generator = new ContentGenerator({
191
+ apiKey: 'test-key',
192
+ promptService: makeMockPromptService(),
193
+ });
194
+
195
+ const result = (generator as any).checkBlacklist('Irgendein Text', '');
196
+
197
+ expect(result).toEqual([]);
198
+ });
199
+ });
200
+ });
@@ -8,6 +8,8 @@ export interface IPromptService {
8
8
  getPrompt(id: string): Promise<string | null>;
9
9
  getCTAConfig(): Promise<{ url: string; style: string; prompt: string }>;
10
10
  getCoreTags(): Promise<string[]>;
11
+ getContentSettings(): Promise<import('./PromptService').ContentSettings>;
12
+ validateContentSettings(settings: import('./PromptService').ContentSettings): { valid: boolean; missing: string[] };
11
13
  }
12
14
 
13
15
  export interface GeneratedContent {
@@ -15,12 +17,13 @@ export interface GeneratedContent {
15
17
  description: string;
16
18
  content: string;
17
19
  tags: string[];
20
+ warnings?: string[];
18
21
  }
19
22
 
20
23
  export interface ContentGeneratorConfig {
21
24
  apiKey: string;
22
25
  model?: string;
23
- promptService?: IPromptService;
26
+ promptService: IPromptService;
24
27
  }
25
28
 
26
29
  interface SourceAnalysis {
@@ -30,40 +33,6 @@ interface SourceAnalysis {
30
33
  codeExamples: string[];
31
34
  }
32
35
 
33
- // Fallback values when database is not available
34
- const DEFAULT_CONTACT_URL =
35
- 'https://nevercodealone.de/de/kontakt';
36
-
37
- const DEFAULT_CORE_TAGS = ['Web-Entwicklung', 'Best Practices'];
38
-
39
- const DEFAULT_SYSTEM_PROMPT = `Du bist ein erfahrener technischer Content-Writer für Web-Entwicklung.
40
- Deine Aufgabe ist es, hochwertige deutsche Fachartikel zu erstellen.
41
-
42
- Zielgruppe: Content-Marketing-Professionals und Entwickler
43
- Tonalität: Professionell, aber zugänglich. Technisch korrekt, nicht übermäßig akademisch.
44
-
45
- KRITISCH - 100% Originalität:
46
- - Schreibe einen KOMPLETT EIGENSTÄNDIGEN Artikel
47
- - KEINE Sätze, Formulierungen oder Strukturen aus externen Quellen übernehmen
48
- - KEINE Hinweise auf Quellen, Referenzen oder Inspiration im Text
49
- - Nutze ausschließlich DEIN Expertenwissen zum jeweiligen Thema
50
- - Jeder Satz muss NEU formuliert sein - wie von einem Experten geschrieben
51
- - Der Artikel muss wirken als käme er aus eigener Fachkenntnis
52
-
53
- Regeln:
54
- - Schreibe auf Deutsch
55
- - Mindestens 800 Wörter
56
- - Verwende praktische Codebeispiele (eigene Beispiele, nicht kopiert)
57
- - WICHTIG: Content MUSS mit einer H1-Überschrift (# Titel) beginnen
58
- - Danach H2 (##) und H3 (###) Hierarchie ohne Sprünge
59
- - WICHTIG: Nur Markdown, KEINE HTML-Tags wie <p>, <div>, <span> etc.
60
-
61
- Titel-Regeln:
62
- - Das Hauptthema/Keyword MUSS im Titel vorkommen
63
- - Nutze Zahlen wenn möglich (z.B. "5 Tipps", "3 Fehler")
64
- - Zeige den Nutzen/Benefit (z.B. "So vermeidest du...", "Warum X wichtig ist")
65
- - Wecke Neugier oder löse ein Problem`;
66
-
67
36
  export function buildSourceAnalysisSchema() {
68
37
  return {
69
38
  type: SchemaType.OBJECT as const,
@@ -96,7 +65,12 @@ export class ContentGenerator {
96
65
  private client: GoogleGenerativeAI;
97
66
  private model: string;
98
67
  private fetcher: ContentFetcher;
99
- private promptService: IPromptService | undefined;
68
+ private promptService: IPromptService;
69
+ private lastWarnings: string[] = [];
70
+
71
+ get warnings(): string[] {
72
+ return this.lastWarnings;
73
+ }
100
74
 
101
75
  constructor(config: ContentGeneratorConfig) {
102
76
  this.client = new GoogleGenerativeAI(config.apiKey);
@@ -114,6 +88,7 @@ export class ContentGenerator {
114
88
 
115
89
  // Step 2: Generate article based on analysis
116
90
  const generated = await this.generateContent(analysis);
91
+ this.lastWarnings = generated.warnings ?? [];
117
92
 
118
93
  const props: ArticleProps = {
119
94
  title: generated.title,
@@ -132,6 +107,7 @@ export class ContentGenerator {
132
107
 
133
108
  // Generate article based on research
134
109
  const generated = await this.generateContent(analysis);
110
+ this.lastWarnings = generated.warnings ?? [];
135
111
 
136
112
  const props: ArticleProps = {
137
113
  title: generated.title,
@@ -258,42 +234,68 @@ Fokussiere auf aktuelle Standards und praktische Anwendbarkeit.`;
258
234
  throw new Error(`Failed to parse generated content response: ${text.slice(0, 200)}`);
259
235
  }
260
236
 
237
+ const contentSettings = await this.promptService.getContentSettings();
238
+ const blacklistWarnings = this.checkBlacklist(data.content, contentSettings.blacklist);
239
+
261
240
  return {
262
241
  title: data.title,
263
242
  description: data.description,
264
243
  content: data.content,
265
244
  tags: [...new Set([...coreTags, ...data.tags])],
245
+ ...(blacklistWarnings.length > 0 ? { warnings: blacklistWarnings.map(term => `Blacklist-Begriff gefunden: "${term}"`) } : {}),
266
246
  };
267
247
  }
268
248
 
269
249
  private async buildSystemPrompt(): Promise<string> {
270
- // Try to load from database
271
- if (this.promptService) {
272
- try {
273
- const [basePrompt, ctaConfig] = await Promise.all([
274
- this.promptService.getPrompt('system_prompt'),
275
- this.promptService.getCTAConfig(),
276
- ]);
277
-
278
- if (basePrompt) {
279
- // Add CTA instructions to the system prompt
280
- return `${basePrompt}
250
+ const settings = await this.promptService.getContentSettings();
251
+ const validation = this.promptService.validateContentSettings(settings);
281
252
 
282
- - WICHTIG: Beende den Artikel mit einem einzigartigen Call-to-Action:
283
- - Link: ${ctaConfig.url}
284
- - Stil: ${ctaConfig.style}
285
- ${ctaConfig.prompt}`;
286
- }
287
- } catch (error) {
288
- console.warn('Failed to load prompts from database, using defaults');
289
- }
253
+ if (!validation.valid) {
254
+ throw new Error(
255
+ `Content-Generierung nicht konfiguriert. Fehlende Settings: ${validation.missing.join(', ')}. Bitte unter Einstellungen → Content-KI ausfüllen.`
256
+ );
290
257
  }
291
258
 
292
- // Fallback to default with static CTA
293
- return `${DEFAULT_SYSTEM_PROMPT}
259
+ // Try custom system prompt from DB first
260
+ const basePrompt = await this.promptService.getPrompt('system_prompt');
294
261
 
295
- - WICHTIG: Beende den Artikel mit einem Call-to-Action, der zum Thema passt.
296
- Verwende diesen Link: [Kontakt aufnehmen](${DEFAULT_CONTACT_URL})`;
262
+ const systemPrompt = basePrompt
263
+ ? basePrompt
264
+ : `Du bist ein erfahrener technischer Content-Writer für ${settings.branche}.
265
+ Deine Aufgabe ist es, hochwertige deutsche Fachartikel zu erstellen.
266
+
267
+ Zielgruppe: ${settings.zielgruppe}
268
+ Tonalität: ${settings.tonalitaet}
269
+
270
+ KRITISCH - 100% Originalität:
271
+ - Schreibe einen KOMPLETT EIGENSTÄNDIGEN Artikel
272
+ - KEINE Sätze, Formulierungen oder Strukturen aus externen Quellen übernehmen
273
+ - KEINE Hinweise auf Quellen, Referenzen oder Inspiration im Text
274
+ - Nutze ausschließlich DEIN Expertenwissen zum jeweiligen Thema
275
+ - Jeder Satz muss NEU formuliert sein - wie von einem Experten geschrieben
276
+ - Der Artikel muss wirken als käme er aus eigener Fachkenntnis
277
+
278
+ Regeln:
279
+ - Schreibe auf Deutsch
280
+ - Mindestens ${settings.minWortanzahl} Wörter, maximal ${settings.maxWortanzahl} Wörter
281
+ - Verwende praktische Codebeispiele (eigene Beispiele, nicht kopiert)
282
+ - WICHTIG: Content MUSS mit einer H1-Überschrift (# Titel) beginnen
283
+ - Danach H2 (##) und H3 (###) Hierarchie ohne Sprünge
284
+ - WICHTIG: Nur Markdown, KEINE HTML-Tags wie <p>, <div>, <span> etc.
285
+ ${settings.stilRegeln ? `\nZusätzliche Stilregeln:\n${settings.stilRegeln}` : ''}
286
+
287
+ Titel-Regeln:
288
+ - Das Hauptthema/Keyword MUSS im Titel vorkommen
289
+ - Nutze Zahlen wenn möglich (z.B. "5 Tipps", "3 Fehler")
290
+ - Zeige den Nutzen/Benefit (z.B. "So vermeidest du...", "Warum X wichtig ist")
291
+ - Wecke Neugier oder löse ein Problem`;
292
+
293
+ return `${systemPrompt}
294
+
295
+ - WICHTIG: Beende den Artikel mit einem einzigartigen Call-to-Action:
296
+ - Link: ${settings.ctaUrl}
297
+ - Stil: ${settings.ctaStyle}
298
+ ${settings.ctaPrompt}`;
297
299
  }
298
300
 
299
301
  private buildUserPrompt(analysis: SourceAnalysis): string {
@@ -309,13 +311,12 @@ Wichtig: Schreibe komplett eigenständig aus deiner Expertise heraus.`;
309
311
  }
310
312
 
311
313
  private async getCoreTags(): Promise<string[]> {
312
- if (this.promptService) {
313
- try {
314
- return await this.promptService.getCoreTags();
315
- } catch {
316
- // Fall through to default
317
- }
318
- }
319
- return DEFAULT_CORE_TAGS;
314
+ return await this.promptService.getCoreTags();
315
+ }
316
+
317
+ private checkBlacklist(content: string, blacklist: string): string[] {
318
+ if (!blacklist.trim()) return [];
319
+ const terms = blacklist.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
320
+ return terms.filter(term => content.toLowerCase().includes(term));
320
321
  }
321
322
  }
@@ -7,7 +7,7 @@ vi.mock('astro:db', () => ({
7
7
  eq: vi.fn(),
8
8
  }));
9
9
 
10
- import { PromptService, ImageSettings } from './PromptService';
10
+ import { PromptService, ImageSettings, ContentSettings } from './PromptService';
11
11
 
12
12
  describe('PromptService', () => {
13
13
  let service: PromptService;
@@ -139,4 +139,155 @@ describe('PromptService', () => {
139
139
  expect(result.missing).toEqual([]);
140
140
  });
141
141
  });
142
+
143
+ describe('getContentSettings', () => {
144
+ it('returns correct structure when settings exist', async () => {
145
+ vi.spyOn(service, 'getSetting').mockImplementation(async (key: string) => {
146
+ const map: Record<string, string> = {
147
+ 'content.branche': 'Web-Entwicklung',
148
+ 'content.zielgruppe': 'Entwickler und CTOs',
149
+ 'content.tonalitaet': 'Professionell',
150
+ 'content.blacklist': 'gratis,kostenlos',
151
+ 'content.min_wortanzahl': '800',
152
+ 'content.max_wortanzahl': '1200',
153
+ 'content.stil_regeln': 'Keine Emojis',
154
+ 'content.cta_url': 'https://example.com/kontakt',
155
+ 'content.cta_style': 'Einladend',
156
+ 'content.cta_prompt': 'Generiere einen CTA',
157
+ };
158
+ return map[key] ?? null;
159
+ });
160
+
161
+ const settings = await service.getContentSettings();
162
+
163
+ expect(settings.branche).toBe('Web-Entwicklung');
164
+ expect(settings.zielgruppe).toBe('Entwickler und CTOs');
165
+ expect(settings.tonalitaet).toBe('Professionell');
166
+ expect(settings.blacklist).toBe('gratis,kostenlos');
167
+ expect(settings.minWortanzahl).toBe('800');
168
+ expect(settings.maxWortanzahl).toBe('1200');
169
+ expect(settings.stilRegeln).toBe('Keine Emojis');
170
+ expect(settings.ctaUrl).toBe('https://example.com/kontakt');
171
+ expect(settings.ctaStyle).toBe('Einladend');
172
+ expect(settings.ctaPrompt).toBe('Generiere einen CTA');
173
+ });
174
+
175
+ it('returns empty strings when settings do not exist', async () => {
176
+ vi.spyOn(service, 'getSetting').mockResolvedValue(null);
177
+
178
+ const settings = await service.getContentSettings();
179
+
180
+ expect(settings.branche).toBe('');
181
+ expect(settings.zielgruppe).toBe('');
182
+ expect(settings.tonalitaet).toBe('');
183
+ expect(settings.blacklist).toBe('');
184
+ expect(settings.minWortanzahl).toBe('');
185
+ expect(settings.maxWortanzahl).toBe('');
186
+ expect(settings.stilRegeln).toBe('');
187
+ expect(settings.ctaUrl).toBe('');
188
+ expect(settings.ctaStyle).toBe('');
189
+ expect(settings.ctaPrompt).toBe('');
190
+ });
191
+ });
192
+
193
+ describe('validateContentSettings', () => {
194
+ function makeValidContentSettings(): ContentSettings {
195
+ return {
196
+ branche: 'Web-Entwicklung',
197
+ zielgruppe: 'Entwickler',
198
+ tonalitaet: 'Professionell',
199
+ blacklist: '',
200
+ minWortanzahl: '800',
201
+ maxWortanzahl: '1200',
202
+ stilRegeln: '',
203
+ ctaUrl: 'https://example.com',
204
+ ctaStyle: 'Einladend',
205
+ ctaPrompt: 'Generiere einen CTA',
206
+ };
207
+ }
208
+
209
+ it('returns valid:true when all required fields are filled', () => {
210
+ const result = service.validateContentSettings(makeValidContentSettings());
211
+
212
+ expect(result.valid).toBe(true);
213
+ expect(result.missing).toEqual([]);
214
+ });
215
+
216
+ it('returns valid:false with missing field names when empty', () => {
217
+ const settings: ContentSettings = {
218
+ branche: '',
219
+ zielgruppe: '',
220
+ tonalitaet: '',
221
+ blacklist: '',
222
+ minWortanzahl: '',
223
+ maxWortanzahl: '',
224
+ stilRegeln: '',
225
+ ctaUrl: '',
226
+ ctaStyle: '',
227
+ ctaPrompt: '',
228
+ };
229
+
230
+ const result = service.validateContentSettings(settings);
231
+
232
+ expect(result.valid).toBe(false);
233
+ expect(result.missing).toHaveLength(8);
234
+ expect(result.missing).toContain('branche');
235
+ expect(result.missing).toContain('zielgruppe');
236
+ expect(result.missing).toContain('tonalitaet');
237
+ expect(result.missing).toContain('minWortanzahl');
238
+ expect(result.missing).toContain('maxWortanzahl');
239
+ expect(result.missing).toContain('ctaUrl');
240
+ expect(result.missing).toContain('ctaStyle');
241
+ expect(result.missing).toContain('ctaPrompt');
242
+ });
243
+
244
+ it('ignores optional fields (blacklist, stilRegeln)', () => {
245
+ const settings = makeValidContentSettings();
246
+ settings.blacklist = '';
247
+ settings.stilRegeln = '';
248
+
249
+ const result = service.validateContentSettings(settings);
250
+
251
+ expect(result.valid).toBe(true);
252
+ expect(result.missing).toEqual([]);
253
+ });
254
+ });
255
+
256
+ describe('getCTAConfig', () => {
257
+ it('returns empty strings when no settings exist', async () => {
258
+ vi.spyOn(service, 'getSetting').mockResolvedValue(null);
259
+
260
+ const config = await service.getCTAConfig();
261
+
262
+ expect(config.url).toBe('');
263
+ expect(config.style).toBe('');
264
+ expect(config.prompt).toBe('');
265
+ });
266
+ });
267
+
268
+ describe('getCoreTags', () => {
269
+ it('returns empty array when no setting exists', async () => {
270
+ vi.spyOn(service, 'getSetting').mockResolvedValue(null);
271
+
272
+ const tags = await service.getCoreTags();
273
+
274
+ expect(tags).toEqual([]);
275
+ });
276
+
277
+ it('parses valid JSON tags', async () => {
278
+ vi.spyOn(service, 'getSetting').mockResolvedValue('["PHP","Testing"]');
279
+
280
+ const tags = await service.getCoreTags();
281
+
282
+ expect(tags).toEqual(['PHP', 'Testing']);
283
+ });
284
+
285
+ it('returns empty array for invalid JSON', async () => {
286
+ vi.spyOn(service, 'getSetting').mockResolvedValue('invalid');
287
+
288
+ const tags = await service.getCoreTags();
289
+
290
+ expect(tags).toEqual([]);
291
+ });
292
+ });
142
293
  });
@@ -33,6 +33,43 @@ export const REQUIRED_IMAGE_SETTINGS = [
33
33
  'image.filenamePrompt',
34
34
  ] as const;
35
35
 
36
+ export interface ContentSettings {
37
+ branche: string;
38
+ zielgruppe: string;
39
+ tonalitaet: string;
40
+ blacklist: string;
41
+ minWortanzahl: string;
42
+ maxWortanzahl: string;
43
+ stilRegeln: string;
44
+ ctaUrl: string;
45
+ ctaStyle: string;
46
+ ctaPrompt: string;
47
+ }
48
+
49
+ export const CONTENT_SETTING_KEYS = [
50
+ 'content.branche',
51
+ 'content.zielgruppe',
52
+ 'content.tonalitaet',
53
+ 'content.blacklist',
54
+ 'content.min_wortanzahl',
55
+ 'content.max_wortanzahl',
56
+ 'content.stil_regeln',
57
+ 'content.cta_url',
58
+ 'content.cta_style',
59
+ 'content.cta_prompt',
60
+ ] as const;
61
+
62
+ export const REQUIRED_CONTENT_SETTINGS = [
63
+ 'content.branche',
64
+ 'content.zielgruppe',
65
+ 'content.tonalitaet',
66
+ 'content.min_wortanzahl',
67
+ 'content.max_wortanzahl',
68
+ 'content.cta_url',
69
+ 'content.cta_style',
70
+ 'content.cta_prompt',
71
+ ] as const;
72
+
36
73
  export class PromptService {
37
74
  async getPrompt(id: string): Promise<string | null> {
38
75
  const result = await db
@@ -109,22 +146,44 @@ export class PromptService {
109
146
  return await db.select().from(SiteSettings);
110
147
  }
111
148
 
149
+ async getContentSettings(): Promise<ContentSettings> {
150
+ const results = await Promise.all(
151
+ CONTENT_SETTING_KEYS.map((key) => this.getSetting(key))
152
+ );
153
+ return {
154
+ branche: results[0] ?? '',
155
+ zielgruppe: results[1] ?? '',
156
+ tonalitaet: results[2] ?? '',
157
+ blacklist: results[3] ?? '',
158
+ minWortanzahl: results[4] ?? '',
159
+ maxWortanzahl: results[5] ?? '',
160
+ stilRegeln: results[6] ?? '',
161
+ ctaUrl: results[7] ?? '',
162
+ ctaStyle: results[8] ?? '',
163
+ ctaPrompt: results[9] ?? '',
164
+ };
165
+ }
166
+
167
+ validateContentSettings(settings: ContentSettings): { valid: boolean; missing: string[] } {
168
+ const missing: string[] = [];
169
+ if (!settings.branche.trim()) missing.push('branche');
170
+ if (!settings.zielgruppe.trim()) missing.push('zielgruppe');
171
+ if (!settings.tonalitaet.trim()) missing.push('tonalitaet');
172
+ if (!settings.minWortanzahl.trim()) missing.push('minWortanzahl');
173
+ if (!settings.maxWortanzahl.trim()) missing.push('maxWortanzahl');
174
+ if (!settings.ctaUrl.trim()) missing.push('ctaUrl');
175
+ if (!settings.ctaStyle.trim()) missing.push('ctaStyle');
176
+ if (!settings.ctaPrompt.trim()) missing.push('ctaPrompt');
177
+ return { valid: missing.length === 0, missing };
178
+ }
179
+
112
180
  async getCTAConfig(): Promise<CTAConfig> {
113
181
  const [url, style, prompt] = await Promise.all([
114
- this.getSetting('cta_url'),
115
- this.getSetting('cta_style'),
116
- this.getPrompt('cta_prompt'),
182
+ this.getSetting('content.cta_url'),
183
+ this.getSetting('content.cta_style'),
184
+ this.getSetting('content.cta_prompt'),
117
185
  ]);
118
-
119
- return {
120
- url:
121
- url ??
122
- 'https://nevercodealone.de/de/landingpages/barrierefreies-webdesign',
123
- style:
124
- style ??
125
- 'Professionell, einladend, mit klarem Nutzenversprechen. Deutsche Sprache.',
126
- prompt: prompt ?? 'Generiere einen einzigartigen Call-to-Action.',
127
- };
186
+ return { url: url ?? '', style: style ?? '', prompt: prompt ?? '' };
128
187
  }
129
188
 
130
189
  async getImageSettings(): Promise<ImageSettings> {
@@ -175,11 +234,11 @@ export class PromptService {
175
234
 
176
235
  async getCoreTags(): Promise<string[]> {
177
236
  const tags = await this.getSetting('core_tags');
178
- if (!tags) return ['Web-Entwicklung', 'Best Practices'];
237
+ if (!tags) return [];
179
238
  try {
180
239
  return JSON.parse(tags);
181
240
  } catch {
182
- return ['Web-Entwicklung', 'Best Practices'];
241
+ return [];
183
242
  }
184
243
  }
185
244
  }
package/update.md CHANGED
@@ -1,3 +1,19 @@
1
+ # v1.0.13
2
+
3
+ ## ContentGenerator: settings-driven, zero hardcoding
4
+ - Removed all hardcoded prompt strings from `ContentGenerator.ts` (DEFAULT_SYSTEM_PROMPT, DEFAULT_CONTACT_URL, DEFAULT_CORE_TAGS)
5
+ - All content generation parameters now read from `SiteSettings` at runtime via `PromptService.getContentSettings()`
6
+ - New settings: `content.branche`, `content.zielgruppe`, `content.tonalitaet`, `content.blacklist`, `content.min_wortanzahl`, `content.max_wortanzahl`, `content.stil_regeln`, `content.cta_url`, `content.cta_style`, `content.cta_prompt`
7
+ - Added `validateContentSettings()` — blocks generation with clear error message when settings are missing
8
+ - Added blacklist post-generation check with warnings in API response
9
+ - `promptService` is now required (was optional with hardcoded fallbacks)
10
+ - Removed hardcoded fallbacks from `getCTAConfig()` and `getCoreTags()`
11
+ - Settings UI: new Content-KI tab with all content settings fields, CTA fields moved from Website tab
12
+ - Content-KI tab shows both settings form and prompt cards (dual rendering)
13
+ - 17 new tests for ContentGenerator and PromptService content settings
14
+
15
+ ---
16
+
1
17
  # v1.0.12
2
18
 
3
19
  ## ImageGenerator: settings-driven, zero hardcoding