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

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.14",
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}>
@@ -60,6 +60,7 @@ const { articleId } = Astro.props;
60
60
  try {
61
61
  const response = await fetch(`/api/articles/${articleId}`, {
62
62
  method: 'DELETE',
63
+ credentials: 'same-origin',
63
64
  });
64
65
  response.ok
65
66
  ? removeCard()
@@ -0,0 +1,217 @@
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('uses fallback prompt when settings not configured', async () => {
99
+ const promptService = makeMockPromptService({
100
+ branche: '',
101
+ zielgruppe: '',
102
+ tonalitaet: '',
103
+ ctaUrl: '',
104
+ ctaStyle: '',
105
+ ctaPrompt: '',
106
+ });
107
+ (promptService.validateContentSettings as ReturnType<typeof vi.fn>).mockReturnValue({
108
+ valid: false,
109
+ missing: ['branche', 'zielgruppe'],
110
+ });
111
+
112
+ const generator = new ContentGenerator({
113
+ apiKey: 'test-key',
114
+ promptService,
115
+ });
116
+
117
+ const article = await generator.generateFromKeywords('PHP Testing');
118
+
119
+ expect(article.title).toBe('Test Titel');
120
+ expect(article.content).toBe('# Test\n\nInhalt hier');
121
+ // Verify system prompt used generic fallback values
122
+ const systemInstruction = mockGenerateContent.mock.calls[0]?.[0] ||
123
+ mockGenerateContent.mock.results[0];
124
+ expect(mockGenerateContent).toHaveBeenCalled();
125
+ });
126
+
127
+ it('omits CTA section when CTA settings are empty', async () => {
128
+ const promptService = makeMockPromptService({
129
+ ctaUrl: '',
130
+ ctaStyle: '',
131
+ ctaPrompt: '',
132
+ });
133
+ (promptService.validateContentSettings as ReturnType<typeof vi.fn>).mockReturnValue({
134
+ valid: true,
135
+ missing: [],
136
+ });
137
+
138
+ const generator = new ContentGenerator({
139
+ apiKey: 'test-key',
140
+ promptService,
141
+ });
142
+
143
+ const article = await generator.generateFromKeywords('PHP Testing');
144
+
145
+ expect(article.title).toBe('Test Titel');
146
+ expect(mockGenerateContent).toHaveBeenCalled();
147
+ });
148
+
149
+ it('generates content when settings configured', async () => {
150
+ const promptService = makeMockPromptService();
151
+ const generator = new ContentGenerator({
152
+ apiKey: 'test-key',
153
+ promptService,
154
+ });
155
+
156
+ const article = await generator.generateFromKeywords('PHP Testing');
157
+
158
+ expect(article.title).toBe('Test Titel');
159
+ expect(article.content).toBe('# Test\n\nInhalt hier');
160
+ });
161
+
162
+ it('getCoreTags returns empty array (no hardcoded fallback)', async () => {
163
+ const promptService = makeMockPromptService();
164
+ (promptService.getCoreTags as ReturnType<typeof vi.fn>).mockResolvedValue([]);
165
+
166
+ const generator = new ContentGenerator({
167
+ apiKey: 'test-key',
168
+ promptService,
169
+ });
170
+
171
+ const article = await generator.generateFromKeywords('test');
172
+
173
+ expect(article.tags).toEqual(['test']);
174
+ expect(article.tags).not.toContain('Web-Entwicklung');
175
+ expect(article.tags).not.toContain('Best Practices');
176
+ });
177
+ });
178
+
179
+ describe('blacklist check', () => {
180
+ it('checkBlacklist returns empty when content is clean', () => {
181
+ const generator = new ContentGenerator({
182
+ apiKey: 'test-key',
183
+ promptService: makeMockPromptService(),
184
+ });
185
+
186
+ // Access private method for testing
187
+ const result = (generator as any).checkBlacklist('Dieser Text ist sauber.', 'gratis,kostenlos');
188
+
189
+ expect(result).toEqual([]);
190
+ });
191
+
192
+ it('checkBlacklist detects blacklisted terms', () => {
193
+ const generator = new ContentGenerator({
194
+ apiKey: 'test-key',
195
+ promptService: makeMockPromptService(),
196
+ });
197
+
198
+ const result = (generator as any).checkBlacklist(
199
+ 'Dieses gratis Tool ist kostenlos.',
200
+ 'gratis,kostenlos'
201
+ );
202
+
203
+ expect(result).toEqual(['gratis', 'kostenlos']);
204
+ });
205
+
206
+ it('checkBlacklist returns empty with empty blacklist', () => {
207
+ const generator = new ContentGenerator({
208
+ apiKey: 'test-key',
209
+ promptService: makeMockPromptService(),
210
+ });
211
+
212
+ const result = (generator as any).checkBlacklist('Irgendein Text', '');
213
+
214
+ expect(result).toEqual([]);
215
+ });
216
+ });
217
+ });
@@ -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,110 @@ 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
+ // Try custom system prompt from DB first
254
+ const basePrompt = await this.promptService.getPrompt('system_prompt');
255
+
256
+ let systemPrompt: string;
257
+
258
+ if (!validation.valid) {
259
+ // Fallback: use available settings where possible, skip what's missing
260
+ const branche = settings.branche || '';
261
+ const zielgruppe = settings.zielgruppe || '';
262
+ const tonalitaet = settings.tonalitaet || '';
263
+ const minWords = settings.minWortanzahl || '800';
264
+ const maxWords = settings.maxWortanzahl || '1200';
265
+
266
+ systemPrompt = basePrompt
267
+ ? basePrompt
268
+ : `Du bist ein erfahrener technischer Content-Writer.${branche ? ` Dein Fachgebiet ist ${branche}.` : ''}
269
+ Deine Aufgabe ist es, eine KOMPLETT NEUE Version eines bestehenden Artikels zu erstellen.
270
+ Der neue Artikel soll das gleiche Thema behandeln, aber mit völlig neuer Struktur, neuen Formulierungen und frischen Perspektiven.
271
+ ${zielgruppe ? `\nZielgruppe: ${zielgruppe}` : ''}${tonalitaet ? `\nTonalität: ${tonalitaet}` : ''}
272
+
273
+ KRITISCH - 100% Originalität:
274
+ - Schreibe einen KOMPLETT EIGENSTÄNDIGEN Artikel — eine völlig neue Version
275
+ - KEINE Sätze, Formulierungen oder Strukturen aus der vorherigen Version übernehmen
276
+ - KEINE Hinweise auf Quellen, Referenzen oder Inspiration im Text
277
+ - Nutze ausschließlich DEIN Expertenwissen zum jeweiligen Thema
278
+ - Jeder Satz muss NEU formuliert sein - wie von einem Experten geschrieben
279
+ - Der Artikel muss wirken als käme er aus eigener Fachkenntnis
280
+ - Wähle eine andere Gliederung und andere Schwerpunkte als ein typischer Artikel zum Thema
281
+
282
+ Regeln:
283
+ - Schreibe auf Deutsch
284
+ - Mindestens ${minWords} Wörter, maximal ${maxWords} Wörter
285
+ - Verwende praktische Codebeispiele (eigene Beispiele, nicht kopiert)
286
+ - WICHTIG: Content MUSS mit einer H1-Überschrift (# Titel) beginnen
287
+ - Danach H2 (##) und H3 (###) Hierarchie ohne Sprünge
288
+ - WICHTIG: Nur Markdown, KEINE HTML-Tags wie <p>, <div>, <span> etc.
289
+ ${settings.stilRegeln ? `\nZusätzliche Stilregeln:\n${settings.stilRegeln}` : ''}
290
+
291
+ Titel-Regeln:
292
+ - Das Hauptthema/Keyword MUSS im Titel vorkommen
293
+ - Nutze Zahlen wenn möglich (z.B. "5 Tipps", "3 Fehler")
294
+ - Zeige den Nutzen/Benefit (z.B. "So vermeidest du...", "Warum X wichtig ist")
295
+ - Wecke Neugier oder löse ein Problem`;
296
+ } else {
297
+ systemPrompt = basePrompt
298
+ ? basePrompt
299
+ : `Du bist ein erfahrener technischer Content-Writer für ${settings.branche}.
300
+ Deine Aufgabe ist es, hochwertige deutsche Fachartikel zu erstellen.
301
+
302
+ Zielgruppe: ${settings.zielgruppe}
303
+ Tonalität: ${settings.tonalitaet}
304
+
305
+ KRITISCH - 100% Originalität:
306
+ - Schreibe einen KOMPLETT EIGENSTÄNDIGEN Artikel
307
+ - KEINE Sätze, Formulierungen oder Strukturen aus externen Quellen übernehmen
308
+ - KEINE Hinweise auf Quellen, Referenzen oder Inspiration im Text
309
+ - Nutze ausschließlich DEIN Expertenwissen zum jeweiligen Thema
310
+ - Jeder Satz muss NEU formuliert sein - wie von einem Experten geschrieben
311
+ - Der Artikel muss wirken als käme er aus eigener Fachkenntnis
312
+
313
+ Regeln:
314
+ - Schreibe auf Deutsch
315
+ - Mindestens ${settings.minWortanzahl} Wörter, maximal ${settings.maxWortanzahl} Wörter
316
+ - Verwende praktische Codebeispiele (eigene Beispiele, nicht kopiert)
317
+ - WICHTIG: Content MUSS mit einer H1-Überschrift (# Titel) beginnen
318
+ - Danach H2 (##) und H3 (###) Hierarchie ohne Sprünge
319
+ - WICHTIG: Nur Markdown, KEINE HTML-Tags wie <p>, <div>, <span> etc.
320
+ ${settings.stilRegeln ? `\nZusätzliche Stilregeln:\n${settings.stilRegeln}` : ''}
321
+
322
+ Titel-Regeln:
323
+ - Das Hauptthema/Keyword MUSS im Titel vorkommen
324
+ - Nutze Zahlen wenn möglich (z.B. "5 Tipps", "3 Fehler")
325
+ - Zeige den Nutzen/Benefit (z.B. "So vermeidest du...", "Warum X wichtig ist")
326
+ - Wecke Neugier oder löse ein Problem`;
290
327
  }
291
328
 
292
- // Fallback to default with static CTA
293
- return `${DEFAULT_SYSTEM_PROMPT}
329
+ // Only add CTA section when CTA settings are configured
330
+ const hasCta = settings.ctaUrl && settings.ctaStyle && settings.ctaPrompt;
331
+ if (hasCta) {
332
+ return `${systemPrompt}
333
+
334
+ - WICHTIG: Beende den Artikel mit einem einzigartigen Call-to-Action:
335
+ - Link: ${settings.ctaUrl}
336
+ - Stil: ${settings.ctaStyle}
337
+ ${settings.ctaPrompt}`;
338
+ }
294
339
 
295
- - WICHTIG: Beende den Artikel mit einem Call-to-Action, der zum Thema passt.
296
- Verwende diesen Link: [Kontakt aufnehmen](${DEFAULT_CONTACT_URL})`;
340
+ return systemPrompt;
297
341
  }
298
342
 
299
343
  private buildUserPrompt(analysis: SourceAnalysis): string {
@@ -309,13 +353,12 @@ Wichtig: Schreibe komplett eigenständig aus deiner Expertise heraus.`;
309
353
  }
310
354
 
311
355
  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;
356
+ return await this.promptService.getCoreTags();
357
+ }
358
+
359
+ private checkBlacklist(content: string, blacklist: string): string[] {
360
+ if (!blacklist.trim()) return [];
361
+ const terms = blacklist.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
362
+ return terms.filter(term => content.toLowerCase().includes(term));
320
363
  }
321
364
  }
@@ -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,33 @@
1
+ # v1.0.14
2
+
3
+ ## Bugfix: Regenerate text works without content-ai settings
4
+ - `buildSystemPrompt()` no longer throws when content-ai settings are missing
5
+ - Uses fallback prompt that works with whatever settings are available — fills in configured values, skips empty ones
6
+ - Fallback prompt explicitly instructs AI to create a completely new version of the existing article with fresh structure and perspectives
7
+ - CTA section is only appended when all three CTA fields (url, style, prompt) are configured — omitted entirely otherwise
8
+ - Updated tests: replaced "throws when not configured" with fallback behavior verification
9
+
10
+ ## Fix: Delete article fetch credentials
11
+ - Added `credentials: 'same-origin'` to DELETE fetch call in `DeleteAction.astro`
12
+
13
+ ---
14
+
15
+ # v1.0.13
16
+
17
+ ## ContentGenerator: settings-driven, zero hardcoding
18
+ - Removed all hardcoded prompt strings from `ContentGenerator.ts` (DEFAULT_SYSTEM_PROMPT, DEFAULT_CONTACT_URL, DEFAULT_CORE_TAGS)
19
+ - All content generation parameters now read from `SiteSettings` at runtime via `PromptService.getContentSettings()`
20
+ - 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`
21
+ - Added `validateContentSettings()` — blocks generation with clear error message when settings are missing
22
+ - Added blacklist post-generation check with warnings in API response
23
+ - `promptService` is now required (was optional with hardcoded fallbacks)
24
+ - Removed hardcoded fallbacks from `getCTAConfig()` and `getCoreTags()`
25
+ - Settings UI: new Content-KI tab with all content settings fields, CTA fields moved from Website tab
26
+ - Content-KI tab shows both settings form and prompt cards (dual rendering)
27
+ - 17 new tests for ContentGenerator and PromptService content settings
28
+
29
+ ---
30
+
1
31
  # v1.0.12
2
32
 
3
33
  ## ImageGenerator: settings-driven, zero hardcoding