nca-ai-cms-astro-plugin 1.0.0

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 (73) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/README.md +87 -0
  3. package/package.json +53 -0
  4. package/src/api/_utils.ts +20 -0
  5. package/src/api/articles/[id]/apply.ts +89 -0
  6. package/src/api/articles/[id]/regenerate-image.ts +49 -0
  7. package/src/api/articles/[id]/regenerate-text.ts +57 -0
  8. package/src/api/articles/[id].ts +53 -0
  9. package/src/api/auth/check.ts +6 -0
  10. package/src/api/auth/login.ts +43 -0
  11. package/src/api/auth/logout.ts +6 -0
  12. package/src/api/generate-content.ts +43 -0
  13. package/src/api/generate-image.ts +33 -0
  14. package/src/api/prompts.ts +45 -0
  15. package/src/api/save-image.ts +38 -0
  16. package/src/api/save.ts +49 -0
  17. package/src/api/scheduler/[id].ts +31 -0
  18. package/src/api/scheduler/generate.ts +94 -0
  19. package/src/api/scheduler/publish.ts +96 -0
  20. package/src/api/scheduler.ts +51 -0
  21. package/src/components/Editor.tsx +115 -0
  22. package/src/components/editor/GenerateTab.tsx +384 -0
  23. package/src/components/editor/PlannerTab.tsx +345 -0
  24. package/src/components/editor/SettingsTab.tsx +185 -0
  25. package/src/components/editor/styles.ts +597 -0
  26. package/src/components/editor/types.ts +49 -0
  27. package/src/components/editor/useTabNavigation.ts +69 -0
  28. package/src/config.d.ts +4 -0
  29. package/src/db/tables.ts +39 -0
  30. package/src/domain/entities/Article.test.ts +138 -0
  31. package/src/domain/entities/Article.ts +90 -0
  32. package/src/domain/entities/ScheduledPost.test.ts +228 -0
  33. package/src/domain/entities/ScheduledPost.ts +152 -0
  34. package/src/domain/entities/Source.test.ts +57 -0
  35. package/src/domain/entities/Source.ts +43 -0
  36. package/src/domain/entities/index.ts +9 -0
  37. package/src/domain/index.ts +16 -0
  38. package/src/domain/value-objects/ArticleFinder.test.ts +104 -0
  39. package/src/domain/value-objects/ArticleFinder.ts +61 -0
  40. package/src/domain/value-objects/SEOMetadata.test.ts +48 -0
  41. package/src/domain/value-objects/SEOMetadata.ts +19 -0
  42. package/src/domain/value-objects/Slug.test.ts +51 -0
  43. package/src/domain/value-objects/Slug.ts +33 -0
  44. package/src/domain/value-objects/index.ts +4 -0
  45. package/src/index.ts +146 -0
  46. package/src/middleware.ts +30 -0
  47. package/src/pages/editor.astro +22 -0
  48. package/src/pages/login.astro +117 -0
  49. package/src/services/ArticleService.test.ts +148 -0
  50. package/src/services/ArticleService.ts +150 -0
  51. package/src/services/AutoPublisher.ts +122 -0
  52. package/src/services/ContentFetcher.ts +89 -0
  53. package/src/services/ContentGenerator.ts +320 -0
  54. package/src/services/FileWriter.test.ts +80 -0
  55. package/src/services/FileWriter.ts +59 -0
  56. package/src/services/ImageConverter.ts +15 -0
  57. package/src/services/ImageGenerator.ts +108 -0
  58. package/src/services/PromptService.ts +84 -0
  59. package/src/services/SchedulerDBAdapter.ts +75 -0
  60. package/src/services/SchedulerService.test.ts +286 -0
  61. package/src/services/SchedulerService.ts +149 -0
  62. package/src/services/index.ts +27 -0
  63. package/src/utils/authUtils.test.ts +60 -0
  64. package/src/utils/authUtils.ts +25 -0
  65. package/src/utils/envUtils.test.ts +40 -0
  66. package/src/utils/envUtils.ts +26 -0
  67. package/src/utils/index.ts +7 -0
  68. package/src/utils/markdown.test.ts +65 -0
  69. package/src/utils/markdown.ts +13 -0
  70. package/src/utils/sanitize.test.ts +180 -0
  71. package/src/utils/sanitize.ts +98 -0
  72. package/tsconfig.json +22 -0
  73. package/vitest.config.ts +14 -0
@@ -0,0 +1,320 @@
1
+ import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai';
2
+ import { Source } from '../domain/entities/Source';
3
+ import { Article, type ArticleProps } from '../domain/entities/Article';
4
+ import { ContentFetcher, type FetchedContent } from './ContentFetcher';
5
+
6
+ // PromptService interface for dependency injection (avoids astro:db import in tests)
7
+ export interface IPromptService {
8
+ getPrompt(id: string): Promise<string | null>;
9
+ getCTAConfig(): Promise<{ url: string; style: string; prompt: string }>;
10
+ getCoreTags(): Promise<string[]>;
11
+ }
12
+
13
+ export interface GeneratedContent {
14
+ title: string;
15
+ description: string;
16
+ content: string;
17
+ tags: string[];
18
+ }
19
+
20
+ export interface ContentGeneratorConfig {
21
+ apiKey: string;
22
+ model?: string;
23
+ promptService?: IPromptService;
24
+ }
25
+
26
+ interface SourceAnalysis {
27
+ topic: string;
28
+ keyPoints: string[];
29
+ uniqueInsights: string[];
30
+ codeExamples: string[];
31
+ }
32
+
33
+ // Fallback values when database is not available
34
+ const DEFAULT_CONTACT_URL =
35
+ 'https://nevercodealone.de/de/landingpages/barrierefreies-webdesign';
36
+
37
+ const DEFAULT_CORE_TAGS = ['Semantik', 'HTML', 'Barrierefrei'];
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 Frontend-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 zur Barrierefreiheit
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
+ - WICHTIG: Integriere die Keywords "Semantik", "HTML" und "Barrierefrei" natürlich in den Text
61
+
62
+ Titel-Regeln:
63
+ - Das Hauptthema/Keyword MUSS im Titel vorkommen
64
+ - Nutze Zahlen wenn möglich (z.B. "5 Tipps", "3 Fehler")
65
+ - Zeige den Nutzen/Benefit (z.B. "So vermeidest du...", "Warum X wichtig ist")
66
+ - Wecke Neugier oder löse ein Problem`;
67
+
68
+ export function buildSourceAnalysisSchema() {
69
+ return {
70
+ type: SchemaType.OBJECT as const,
71
+ properties: {
72
+ topic: {
73
+ type: SchemaType.STRING,
74
+ description: 'Das Hauptthema in 2-5 Wörtern',
75
+ },
76
+ keyPoints: {
77
+ type: SchemaType.ARRAY,
78
+ items: { type: SchemaType.STRING },
79
+ description: 'Die wichtigsten Kernaussagen/Fakten',
80
+ },
81
+ uniqueInsights: {
82
+ type: SchemaType.ARRAY,
83
+ items: { type: SchemaType.STRING },
84
+ description: 'Besondere/einzigartige Erkenntnisse oder Tipps',
85
+ },
86
+ codeExamples: {
87
+ type: SchemaType.ARRAY,
88
+ items: { type: SchemaType.STRING },
89
+ description: 'Wichtige Code-Beispiele oder Patterns',
90
+ },
91
+ },
92
+ required: ['topic', 'keyPoints', 'uniqueInsights', 'codeExamples'],
93
+ } satisfies import('@google/generative-ai').Schema;
94
+ }
95
+
96
+ export class ContentGenerator {
97
+ private client: GoogleGenerativeAI;
98
+ private model: string;
99
+ private fetcher: ContentFetcher;
100
+ private promptService: IPromptService | undefined;
101
+
102
+ constructor(config: ContentGeneratorConfig) {
103
+ this.client = new GoogleGenerativeAI(config.apiKey);
104
+ this.model = config.model || 'gemini-2.5-flash';
105
+ this.fetcher = new ContentFetcher();
106
+ this.promptService = config.promptService;
107
+ }
108
+
109
+ async generateFromUrl(sourceUrl: string): Promise<Article> {
110
+ const source = new Source(sourceUrl);
111
+ const fetchedContent = await this.fetcher.fetch(source);
112
+
113
+ // Step 1: Analyze source to detect topic and extract insights
114
+ const analysis = await this.analyzeSource(fetchedContent);
115
+
116
+ // Step 2: Generate article based on analysis
117
+ const generated = await this.generateContent(analysis);
118
+
119
+ const props: ArticleProps = {
120
+ title: generated.title,
121
+ description: generated.description,
122
+ content: generated.content,
123
+ date: new Date(),
124
+ tags: generated.tags,
125
+ };
126
+
127
+ return new Article(props);
128
+ }
129
+
130
+ async generateFromKeywords(keywords: string): Promise<Article> {
131
+ // Research the keywords using AI
132
+ const analysis = await this.researchKeywords(keywords);
133
+
134
+ // Generate article based on research
135
+ const generated = await this.generateContent(analysis);
136
+
137
+ const props: ArticleProps = {
138
+ title: generated.title,
139
+ description: generated.description,
140
+ content: generated.content,
141
+ date: new Date(),
142
+ tags: generated.tags,
143
+ };
144
+
145
+ return new Article(props);
146
+ }
147
+
148
+ private async analyzeSource(
149
+ fetched: FetchedContent
150
+ ): Promise<SourceAnalysis> {
151
+ const model = this.client.getGenerativeModel({
152
+ model: this.model,
153
+ generationConfig: {
154
+ responseMimeType: 'application/json',
155
+ responseSchema: buildSourceAnalysisSchema(),
156
+ },
157
+ });
158
+
159
+ const prompt = `Analysiere diesen Web-Artikel und extrahiere die wichtigsten Informationen.
160
+
161
+ Titel: ${fetched.title}
162
+ URL: ${fetched.url}
163
+
164
+ Inhalt:
165
+ ${fetched.content.slice(0, 12000)}
166
+
167
+ Identifiziere:
168
+ 1. Das Hauptthema (fokussiert auf Web-Entwicklung/Barrierefreiheit)
169
+ 2. Die wichtigsten Kernaussagen
170
+ 3. Besondere Erkenntnisse oder einzigartige Tipps
171
+ 4. Relevante Code-Beispiele oder Patterns`;
172
+
173
+ const result = await model.generateContent(prompt);
174
+ const text = result.response.text();
175
+ try {
176
+ return JSON.parse(text);
177
+ } catch {
178
+ throw new Error(`Failed to parse source analysis response: ${text.slice(0, 200)}`);
179
+ }
180
+ }
181
+
182
+ private async researchKeywords(keywords: string): Promise<SourceAnalysis> {
183
+ const model = this.client.getGenerativeModel({
184
+ model: this.model,
185
+ generationConfig: {
186
+ responseMimeType: 'application/json',
187
+ responseSchema: buildSourceAnalysisSchema(),
188
+ },
189
+ });
190
+
191
+ const prompt = `Du bist ein Experte für Web-Accessibility und barrierefreie Webentwicklung.
192
+
193
+ Recherchiere zum Thema: "${keywords}"
194
+
195
+ Nutze dein Fachwissen um:
196
+ 1. Das Hauptthema klar zu definieren (Bezug zu Barrierefreiheit/Web-Accessibility)
197
+ 2. Die wichtigsten Fakten, Best Practices und WCAG-Richtlinien zusammenzufassen
198
+ 3. Weniger bekannte aber wichtige Tipps und Erkenntnisse zu identifizieren
199
+ 4. Praktische Code-Beispiele oder Patterns vorzuschlagen
200
+
201
+ Fokussiere auf aktuelle Standards und praktische Anwendbarkeit.`;
202
+
203
+ const result = await model.generateContent(prompt);
204
+ const text = result.response.text();
205
+ try {
206
+ return JSON.parse(text);
207
+ } catch {
208
+ throw new Error(`Failed to parse keyword research response: ${text.slice(0, 200)}`);
209
+ }
210
+ }
211
+
212
+ private async generateContent(
213
+ analysis: SourceAnalysis
214
+ ): Promise<GeneratedContent> {
215
+ const systemPrompt = await this.buildSystemPrompt();
216
+ const userPrompt = this.buildUserPrompt(analysis);
217
+ const coreTags = await this.getCoreTags();
218
+
219
+ const model = this.client.getGenerativeModel({
220
+ model: this.model,
221
+ systemInstruction: systemPrompt,
222
+ generationConfig: {
223
+ responseMimeType: 'application/json',
224
+ responseSchema: {
225
+ type: SchemaType.OBJECT,
226
+ properties: {
227
+ title: {
228
+ type: SchemaType.STRING,
229
+ description: 'SEO-optimierter Titel, max 60 Zeichen',
230
+ },
231
+ description: {
232
+ type: SchemaType.STRING,
233
+ description: 'Meta-Description, max 155 Zeichen',
234
+ },
235
+ tags: {
236
+ type: SchemaType.ARRAY,
237
+ items: { type: SchemaType.STRING },
238
+ description: 'Relevante Tags für den Artikel',
239
+ },
240
+ content: {
241
+ type: SchemaType.STRING,
242
+ description:
243
+ 'Vollständiger Markdown-Inhalt. MUSS mit H1 (# Titel) beginnen, dann H2/H3 Hierarchie. Keine HTML-Tags.',
244
+ },
245
+ },
246
+ required: ['title', 'description', 'tags', 'content'],
247
+ },
248
+ },
249
+ });
250
+
251
+ const result = await model.generateContent(userPrompt);
252
+ const text = result.response.text();
253
+ let data: { title: string; description: string; content: string; tags: string[] };
254
+ try {
255
+ data = JSON.parse(text);
256
+ } catch {
257
+ throw new Error(`Failed to parse generated content response: ${text.slice(0, 200)}`);
258
+ }
259
+
260
+ return {
261
+ title: data.title,
262
+ description: data.description,
263
+ content: data.content,
264
+ tags: [...new Set([...coreTags, ...data.tags])],
265
+ };
266
+ }
267
+
268
+ private async buildSystemPrompt(): Promise<string> {
269
+ // Try to load from database
270
+ if (this.promptService) {
271
+ try {
272
+ const [basePrompt, ctaConfig] = await Promise.all([
273
+ this.promptService.getPrompt('system_prompt'),
274
+ this.promptService.getCTAConfig(),
275
+ ]);
276
+
277
+ if (basePrompt) {
278
+ // Add CTA instructions to the system prompt
279
+ return `${basePrompt}
280
+
281
+ - WICHTIG: Beende den Artikel mit einem einzigartigen Call-to-Action:
282
+ - Link: ${ctaConfig.url}
283
+ - Stil: ${ctaConfig.style}
284
+ ${ctaConfig.prompt}`;
285
+ }
286
+ } catch (error) {
287
+ console.warn('Failed to load prompts from database, using defaults');
288
+ }
289
+ }
290
+
291
+ // Fallback to default with static CTA
292
+ return `${DEFAULT_SYSTEM_PROMPT}
293
+
294
+ - WICHTIG: Beende den Artikel mit einem Call-to-Action, der zum Thema passt.
295
+ Verwende diesen Link: [Kontakt aufnehmen](${DEFAULT_CONTACT_URL})`;
296
+ }
297
+
298
+ private buildUserPrompt(analysis: SourceAnalysis): string {
299
+ return `Schreibe als Accessibility-Experte einen deutschen Fachartikel zum Thema: ${analysis.topic}
300
+
301
+ Behandle diese Aspekte aus deinem Fachwissen:
302
+ ${analysis.keyPoints.map((p) => `- ${p}`).join('\n')}
303
+ ${analysis.uniqueInsights.map((p) => `- ${p}`).join('\n')}
304
+
305
+ ${analysis.codeExamples.length > 0 ? `Zeige praktische Code-Beispiele für:\n${analysis.codeExamples.map((c) => `- ${c}`).join('\n')}` : ''}
306
+
307
+ Wichtig: Schreibe komplett eigenständig aus deiner Expertise heraus.`;
308
+ }
309
+
310
+ private async getCoreTags(): Promise<string[]> {
311
+ if (this.promptService) {
312
+ try {
313
+ return await this.promptService.getCoreTags();
314
+ } catch {
315
+ // Fall through to default
316
+ }
317
+ }
318
+ return DEFAULT_CORE_TAGS;
319
+ }
320
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { FileWriter } from './FileWriter';
3
+ import { Article } from '../domain/entities/Article';
4
+ import * as fs from 'fs/promises';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+
8
+ describe('FileWriter', () => {
9
+ let tempDir: string;
10
+ let fileWriter: FileWriter;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filewriter-test-'));
14
+ fileWriter = new FileWriter(tempDir);
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await fs.rm(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ const createTestArticle = (overrides = {}) =>
22
+ new Article({
23
+ title: 'Test Article',
24
+ description: 'Test description',
25
+ content: '# Test\n\nContent here',
26
+ date: new Date('2025-12-07'),
27
+ tags: ['test'],
28
+ ...overrides,
29
+ });
30
+
31
+ it('creates article folder with index.md', async () => {
32
+ const article = createTestArticle();
33
+ const result = await fileWriter.write(article);
34
+
35
+ expect(result.created).toBe(true);
36
+ expect(result.filepath).toContain('/index.md');
37
+
38
+ const content = await fs.readFile(result.filepath, 'utf-8');
39
+ expect(content).toContain('title: "Test Article"');
40
+ });
41
+
42
+ it('creates nested year/month/slug folder structure', async () => {
43
+ const article = createTestArticle();
44
+ await fileWriter.write(article);
45
+
46
+ const expectedFolder = path.join(
47
+ tempDir,
48
+ 'nca-ai-cms-content/2025/12/test-article'
49
+ );
50
+ const stats = await fs.stat(expectedFolder);
51
+ expect(stats.isDirectory()).toBe(true);
52
+
53
+ const indexPath = path.join(expectedFolder, 'index.md');
54
+ const indexStats = await fs.stat(indexPath);
55
+ expect(indexStats.isFile()).toBe(true);
56
+ });
57
+
58
+ it('includes image path in frontmatter when provided', async () => {
59
+ const article = createTestArticle({
60
+ image: './hero.webp',
61
+ imageAlt: 'Test image alt',
62
+ });
63
+
64
+ const result = await fileWriter.write(article);
65
+ const content = await fs.readFile(result.filepath, 'utf-8');
66
+
67
+ expect(content).toContain('image: "./hero.webp"');
68
+ expect(content).toContain('imageAlt: "Test image alt"');
69
+ });
70
+
71
+ it('handles duplicate articles by appending counter', async () => {
72
+ const article = createTestArticle();
73
+
74
+ const result1 = await fileWriter.write(article);
75
+ const result2 = await fileWriter.write(article);
76
+
77
+ expect(result1.filepath).toContain('/index.md');
78
+ expect(result2.filepath).toContain('/index-2.md');
79
+ });
80
+ });
@@ -0,0 +1,59 @@
1
+ import { Article } from '../domain/entities/Article';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+
5
+ export interface WriteResult {
6
+ filepath: string;
7
+ created: boolean;
8
+ }
9
+
10
+ export class FileWriter {
11
+ private basePath: string;
12
+
13
+ constructor(basePath: string = process.cwd()) {
14
+ this.basePath = basePath;
15
+ }
16
+
17
+ async write(article: Article): Promise<WriteResult> {
18
+ const filepath = path.join(this.basePath, article.filepath);
19
+ const dir = path.dirname(filepath);
20
+
21
+ // Create directory if it doesn't exist
22
+ await fs.mkdir(dir, { recursive: true });
23
+
24
+ // Check if file already exists
25
+ const exists = await this.fileExists(filepath);
26
+ if (exists) {
27
+ const newPath = await this.getUniqueFilepath(filepath);
28
+ await fs.writeFile(newPath, article.toMarkdown(), 'utf-8');
29
+ return { filepath: newPath, created: true };
30
+ }
31
+
32
+ await fs.writeFile(filepath, article.toMarkdown(), 'utf-8');
33
+ return { filepath, created: true };
34
+ }
35
+
36
+ private async fileExists(filepath: string): Promise<boolean> {
37
+ try {
38
+ await fs.access(filepath);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ private async getUniqueFilepath(filepath: string): Promise<string> {
46
+ const ext = path.extname(filepath);
47
+ const base = filepath.slice(0, -ext.length);
48
+
49
+ let counter = 2;
50
+ let newPath = `${base}-${counter}${ext}`;
51
+
52
+ while (await this.fileExists(newPath)) {
53
+ counter++;
54
+ newPath = `${base}-${counter}${ext}`;
55
+ }
56
+
57
+ return newPath;
58
+ }
59
+ }
@@ -0,0 +1,15 @@
1
+ import sharp from 'sharp';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+
5
+ export async function convertToWebP(
6
+ base64: string,
7
+ outputPath: string
8
+ ): Promise<void> {
9
+ const buffer = Buffer.from(base64, 'base64');
10
+ const webpBuffer = await sharp(buffer)
11
+ .webp({ quality: 85, effort: 6 })
12
+ .toBuffer();
13
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
14
+ await fs.writeFile(outputPath, webpBuffer);
15
+ }
@@ -0,0 +1,108 @@
1
+ import { GoogleGenAI, PersonGeneration } from '@google/genai';
2
+ import { GoogleGenerativeAI } from '@google/generative-ai';
3
+ import { Slug } from '../domain/value-objects/Slug';
4
+
5
+ export interface GeneratedImage {
6
+ url: string;
7
+ alt: string;
8
+ filepath: string;
9
+ base64?: string;
10
+ }
11
+
12
+ export interface ImageGeneratorConfig {
13
+ apiKey: string;
14
+ model?: string;
15
+ }
16
+
17
+ export class ImageGenerator {
18
+ private client: GoogleGenAI;
19
+ private textClient: GoogleGenerativeAI;
20
+ private model: string;
21
+
22
+ constructor(config: ImageGeneratorConfig) {
23
+ this.client = new GoogleGenAI({ apiKey: config.apiKey });
24
+ this.textClient = new GoogleGenerativeAI(config.apiKey);
25
+ this.model = config.model || 'imagen-4.0-generate-001';
26
+ }
27
+
28
+ async generate(title: string): Promise<GeneratedImage> {
29
+ const prompt = this.buildPrompt(title);
30
+ const filename = await this.generateSeoFilename(title);
31
+ const filepath = `dist/client/images/${filename}.webp`;
32
+
33
+ try {
34
+ const response = await this.client.models.generateImages({
35
+ model: this.model,
36
+ prompt: prompt,
37
+ config: {
38
+ numberOfImages: 1,
39
+ aspectRatio: '16:9',
40
+ personGeneration: PersonGeneration.DONT_ALLOW,
41
+ },
42
+ });
43
+
44
+ if (!response.generatedImages || response.generatedImages.length === 0) {
45
+ throw new Error('No image generated');
46
+ }
47
+
48
+ const imageData = response.generatedImages[0];
49
+ if (!imageData) {
50
+ throw new Error('No image data in response');
51
+ }
52
+ const base64 = imageData.image?.imageBytes;
53
+
54
+ if (!base64) {
55
+ throw new Error('No image data received');
56
+ }
57
+
58
+ return {
59
+ url: `data:image/png;base64,${base64}`,
60
+ alt: this.generateAlt(title),
61
+ filepath,
62
+ base64,
63
+ };
64
+ } catch (error) {
65
+ console.error('Image generation error:', error);
66
+ throw new Error(
67
+ `Image generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
68
+ );
69
+ }
70
+ }
71
+
72
+ private buildPrompt(title: string): string {
73
+ return `Blog header image about "${title}" for a web accessibility article. Minimal Precisionism style inspired by Charles Sheeler: clean geometric shapes, sharp focus, smooth surfaces, no people. IMPORTANT: absolutely no text, no letters, no words, no typography, no labels, no captions anywhere in the image.`;
74
+ }
75
+
76
+ private generateAlt(title: string): string {
77
+ return `Illustration zum Thema ${title} - Barrierefreiheit im Web`;
78
+ }
79
+
80
+ private async generateSeoFilename(title: string): Promise<string> {
81
+ const model = this.textClient.getGenerativeModel({
82
+ model: 'gemini-2.0-flash',
83
+ });
84
+
85
+ const prompt = `Generate a single SEO-optimized filename for an image about web accessibility article titled "${title}".
86
+ Requirements:
87
+ - German and English keywords mixed
88
+ - Lowercase, words separated by hyphens
89
+ - Max 5-6 words
90
+ - No file extension
91
+ - Focus on: barrierefreiheit, accessibility, web, and the topic
92
+ - Return ONLY the filename, nothing else
93
+
94
+ Example for topic "Forms": barrierefreiheit-formulare-accessible-forms`;
95
+
96
+ try {
97
+ const result = await model.generateContent(prompt);
98
+ const filename = result.response
99
+ .text()
100
+ .trim()
101
+ .toLowerCase()
102
+ .replace(/[^a-z0-9-]/g, '');
103
+ return filename || Slug.generate(`barrierefreiheit-${title}`);
104
+ } catch {
105
+ return Slug.generate(`barrierefreiheit-${title}-accessibility`);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,84 @@
1
+ import { db, Prompts, SiteSettings, eq } from 'astro:db';
2
+
3
+ export interface CTAConfig {
4
+ url: string;
5
+ style: string;
6
+ prompt: string;
7
+ }
8
+
9
+ export class PromptService {
10
+ async getPrompt(id: string): Promise<string | null> {
11
+ const result = await db
12
+ .select()
13
+ .from(Prompts)
14
+ .where(eq(Prompts.id, id))
15
+ .get();
16
+ return result?.promptText ?? null;
17
+ }
18
+
19
+ async updatePrompt(id: string, text: string): Promise<void> {
20
+ await db
21
+ .update(Prompts)
22
+ .set({ promptText: text, updatedAt: new Date() })
23
+ .where(eq(Prompts.id, id));
24
+ }
25
+
26
+ async getAllPrompts(): Promise<
27
+ Array<{
28
+ id: string;
29
+ name: string;
30
+ category: string;
31
+ promptText: string;
32
+ }>
33
+ > {
34
+ return await db.select().from(Prompts);
35
+ }
36
+
37
+ async getSetting(key: string): Promise<string | null> {
38
+ const result = await db
39
+ .select()
40
+ .from(SiteSettings)
41
+ .where(eq(SiteSettings.key, key))
42
+ .get();
43
+ return result?.value ?? null;
44
+ }
45
+
46
+ async updateSetting(key: string, value: string): Promise<void> {
47
+ await db
48
+ .update(SiteSettings)
49
+ .set({ value, updatedAt: new Date() })
50
+ .where(eq(SiteSettings.key, key));
51
+ }
52
+
53
+ async getAllSettings(): Promise<Array<{ key: string; value: string }>> {
54
+ return await db.select().from(SiteSettings);
55
+ }
56
+
57
+ async getCTAConfig(): Promise<CTAConfig> {
58
+ const [url, style, prompt] = await Promise.all([
59
+ this.getSetting('cta_url'),
60
+ this.getSetting('cta_style'),
61
+ this.getPrompt('cta_prompt'),
62
+ ]);
63
+
64
+ return {
65
+ url:
66
+ url ??
67
+ 'https://nevercodealone.de/de/landingpages/barrierefreies-webdesign',
68
+ style:
69
+ style ??
70
+ 'Professionell, einladend, mit klarem Nutzenversprechen. Deutsche Sprache.',
71
+ prompt: prompt ?? 'Generiere einen einzigartigen Call-to-Action.',
72
+ };
73
+ }
74
+
75
+ async getCoreTags(): Promise<string[]> {
76
+ const tags = await this.getSetting('core_tags');
77
+ if (!tags) return ['Semantik', 'HTML', 'Barrierefrei'];
78
+ try {
79
+ return JSON.parse(tags);
80
+ } catch {
81
+ return ['Semantik', 'HTML', 'Barrierefrei'];
82
+ }
83
+ }
84
+ }