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.
- package/.claude/settings.local.json +9 -0
- package/README.md +87 -0
- package/package.json +53 -0
- package/src/api/_utils.ts +20 -0
- package/src/api/articles/[id]/apply.ts +89 -0
- package/src/api/articles/[id]/regenerate-image.ts +49 -0
- package/src/api/articles/[id]/regenerate-text.ts +57 -0
- package/src/api/articles/[id].ts +53 -0
- package/src/api/auth/check.ts +6 -0
- package/src/api/auth/login.ts +43 -0
- package/src/api/auth/logout.ts +6 -0
- package/src/api/generate-content.ts +43 -0
- package/src/api/generate-image.ts +33 -0
- package/src/api/prompts.ts +45 -0
- package/src/api/save-image.ts +38 -0
- package/src/api/save.ts +49 -0
- package/src/api/scheduler/[id].ts +31 -0
- package/src/api/scheduler/generate.ts +94 -0
- package/src/api/scheduler/publish.ts +96 -0
- package/src/api/scheduler.ts +51 -0
- package/src/components/Editor.tsx +115 -0
- package/src/components/editor/GenerateTab.tsx +384 -0
- package/src/components/editor/PlannerTab.tsx +345 -0
- package/src/components/editor/SettingsTab.tsx +185 -0
- package/src/components/editor/styles.ts +597 -0
- package/src/components/editor/types.ts +49 -0
- package/src/components/editor/useTabNavigation.ts +69 -0
- package/src/config.d.ts +4 -0
- package/src/db/tables.ts +39 -0
- package/src/domain/entities/Article.test.ts +138 -0
- package/src/domain/entities/Article.ts +90 -0
- package/src/domain/entities/ScheduledPost.test.ts +228 -0
- package/src/domain/entities/ScheduledPost.ts +152 -0
- package/src/domain/entities/Source.test.ts +57 -0
- package/src/domain/entities/Source.ts +43 -0
- package/src/domain/entities/index.ts +9 -0
- package/src/domain/index.ts +16 -0
- package/src/domain/value-objects/ArticleFinder.test.ts +104 -0
- package/src/domain/value-objects/ArticleFinder.ts +61 -0
- package/src/domain/value-objects/SEOMetadata.test.ts +48 -0
- package/src/domain/value-objects/SEOMetadata.ts +19 -0
- package/src/domain/value-objects/Slug.test.ts +51 -0
- package/src/domain/value-objects/Slug.ts +33 -0
- package/src/domain/value-objects/index.ts +4 -0
- package/src/index.ts +146 -0
- package/src/middleware.ts +30 -0
- package/src/pages/editor.astro +22 -0
- package/src/pages/login.astro +117 -0
- package/src/services/ArticleService.test.ts +148 -0
- package/src/services/ArticleService.ts +150 -0
- package/src/services/AutoPublisher.ts +122 -0
- package/src/services/ContentFetcher.ts +89 -0
- package/src/services/ContentGenerator.ts +320 -0
- package/src/services/FileWriter.test.ts +80 -0
- package/src/services/FileWriter.ts +59 -0
- package/src/services/ImageConverter.ts +15 -0
- package/src/services/ImageGenerator.ts +108 -0
- package/src/services/PromptService.ts +84 -0
- package/src/services/SchedulerDBAdapter.ts +75 -0
- package/src/services/SchedulerService.test.ts +286 -0
- package/src/services/SchedulerService.ts +149 -0
- package/src/services/index.ts +27 -0
- package/src/utils/authUtils.test.ts +60 -0
- package/src/utils/authUtils.ts +25 -0
- package/src/utils/envUtils.test.ts +40 -0
- package/src/utils/envUtils.ts +26 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/markdown.test.ts +65 -0
- package/src/utils/markdown.ts +13 -0
- package/src/utils/sanitize.test.ts +180 -0
- package/src/utils/sanitize.ts +98 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|