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,49 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { Article } from '../domain/entities/Article';
4
+ import { FileWriter } from '../services/FileWriter';
5
+ import { contentPath } from 'virtual:nca-ai-cms/config';
6
+ import { jsonResponse, jsonError } from './_utils';
7
+
8
+ const SaveArticleSchema = z.object({
9
+ title: z.string().min(1),
10
+ description: z.string().min(1),
11
+ content: z.string().min(1),
12
+ date: z.string().optional(),
13
+ tags: z.array(z.string()).optional(),
14
+ imageAlt: z.string().optional(),
15
+ });
16
+
17
+ export const POST: APIRoute = async ({ request }) => {
18
+ try {
19
+ const body = await request.json();
20
+ const parsed = SaveArticleSchema.safeParse(body);
21
+ if (!parsed.success) {
22
+ return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
23
+ }
24
+ const data = parsed.data;
25
+
26
+ const article = new Article({
27
+ title: data.title,
28
+ description: data.description,
29
+ content: data.content,
30
+ date: new Date(data.date || Date.now()),
31
+ tags: data.tags || [],
32
+ image: './hero.webp',
33
+ imageAlt: data.imageAlt,
34
+ contentPath,
35
+ });
36
+
37
+ const writer = new FileWriter();
38
+ const result = await writer.write(article);
39
+
40
+ return jsonResponse({
41
+ success: true,
42
+ filepath: result.filepath,
43
+ folderPath: article.folderPath,
44
+ });
45
+ } catch (error) {
46
+ console.error('Save error:', error);
47
+ return jsonError(error);
48
+ }
49
+ };
@@ -0,0 +1,31 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { SchedulerService } from '../../services/SchedulerService';
3
+ import { AstroSchedulerDBAdapter } from '../../services/SchedulerDBAdapter';
4
+ import { jsonResponse, jsonError } from '../_utils';
5
+
6
+ function getService(): SchedulerService {
7
+ return new SchedulerService(new AstroSchedulerDBAdapter());
8
+ }
9
+
10
+ export const DELETE: APIRoute = async ({ params }) => {
11
+ try {
12
+ const id = params.id;
13
+ if (!id) {
14
+ return jsonError('id is required', 400);
15
+ }
16
+
17
+ const service = getService();
18
+ await service.delete(id);
19
+
20
+ return jsonResponse({ success: true });
21
+ } catch (error) {
22
+ console.error('Scheduler delete error:', error);
23
+ const status =
24
+ error instanceof Error && error.message.includes('not found')
25
+ ? 404
26
+ : error instanceof Error && error.message.includes('Cannot delete')
27
+ ? 403
28
+ : 500;
29
+ return jsonError(error, status);
30
+ }
31
+ };
@@ -0,0 +1,94 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { SchedulerService } from '../../services/SchedulerService';
3
+ import { AstroSchedulerDBAdapter } from '../../services/SchedulerDBAdapter';
4
+ import { ContentGenerator } from '../../services/ContentGenerator';
5
+ import { ImageGenerator } from '../../services/ImageGenerator';
6
+ import { PromptService } from '../../services/PromptService';
7
+ import { getEnvVariable } from '../../utils/envUtils';
8
+ import { jsonResponse, jsonError } from '../_utils';
9
+
10
+ function getService(): SchedulerService {
11
+ return new SchedulerService(new AstroSchedulerDBAdapter());
12
+ }
13
+
14
+ export const POST: APIRoute = async ({ request }) => {
15
+ try {
16
+ const { id, mode = 'all' } = await request.json();
17
+
18
+ if (!id) {
19
+ return jsonError('id is required', 400);
20
+ }
21
+
22
+ const service = getService();
23
+ const post = await service.getById(id);
24
+
25
+ if (!post.canGenerate()) {
26
+ return jsonError(
27
+ `Cannot generate: post is in status "${post.status}"`,
28
+ 400
29
+ );
30
+ }
31
+
32
+ const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
33
+
34
+ if (mode === 'text' || mode === 'all') {
35
+ const promptService = new PromptService();
36
+ const contentGenerator = new ContentGenerator({ apiKey, promptService });
37
+
38
+ const article =
39
+ post.inputType === 'url'
40
+ ? await contentGenerator.generateFromUrl(post.input)
41
+ : await contentGenerator.generateFromKeywords(post.input);
42
+
43
+ if (mode === 'text') {
44
+ await service.updateText(id, {
45
+ title: article.title,
46
+ description: article.description,
47
+ content: article.content,
48
+ tags: article.tags,
49
+ });
50
+ } else {
51
+ // mode === 'all': generate image too
52
+ let imageData: string | undefined;
53
+ let imageAlt: string | undefined;
54
+ try {
55
+ const imageGenerator = new ImageGenerator({ apiKey });
56
+ const image = await imageGenerator.generate(article.title);
57
+ imageData = image.base64;
58
+ imageAlt = image.alt;
59
+ } catch (error) {
60
+ console.warn(
61
+ 'Image generation failed, continuing without image:',
62
+ error
63
+ );
64
+ }
65
+
66
+ await service.markGenerated(id, {
67
+ title: article.title,
68
+ description: article.description,
69
+ content: article.content,
70
+ tags: article.tags,
71
+ imageData,
72
+ imageAlt,
73
+ });
74
+ }
75
+ } else if (mode === 'image') {
76
+ // Use existing title or fallback to input
77
+ const title = post.generatedTitle || post.input;
78
+ const imageGenerator = new ImageGenerator({ apiKey });
79
+ const image = await imageGenerator.generate(title);
80
+
81
+ await service.updateImage(id, {
82
+ imageData: image.base64 || '',
83
+ imageAlt: image.alt,
84
+ });
85
+ }
86
+
87
+ const updated = await service.getById(id);
88
+
89
+ return jsonResponse({ post: updated });
90
+ } catch (error) {
91
+ console.error('Scheduler generate error:', error);
92
+ return jsonError(error);
93
+ }
94
+ };
@@ -0,0 +1,96 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { SchedulerService } from '../../services/SchedulerService';
3
+ import { AstroSchedulerDBAdapter } from '../../services/SchedulerDBAdapter';
4
+ import { Article } from '../../domain/entities/Article';
5
+ import { FileWriter } from '../../services/FileWriter';
6
+ import * as path from 'path';
7
+ import { convertToWebP } from '../../services/ImageConverter';
8
+ import { contentPath } from 'virtual:nca-ai-cms/config';
9
+ import { jsonResponse, jsonError } from '../_utils';
10
+
11
+ function getService(): SchedulerService {
12
+ return new SchedulerService(new AstroSchedulerDBAdapter());
13
+ }
14
+
15
+ async function publishPost(
16
+ service: SchedulerService,
17
+ postId: string
18
+ ): Promise<{ id: string; publishedPath: string }> {
19
+ const post = await service.getById(postId);
20
+
21
+ if (!post.canPublish()) {
22
+ throw new Error(`Cannot publish: post is in status "${post.status}"`);
23
+ }
24
+
25
+ if (!post.generatedTitle || !post.generatedContent) {
26
+ throw new Error('Cannot publish: missing generated content');
27
+ }
28
+
29
+ // Create article with the scheduled date (not today)
30
+ const articleProps = {
31
+ title: post.generatedTitle,
32
+ description: post.generatedDescription || '',
33
+ content: post.generatedContent,
34
+ date: post.scheduledDate,
35
+ tags: post.parsedTags,
36
+ image: './hero.webp',
37
+ ...(post.generatedImageAlt ? { imageAlt: post.generatedImageAlt } : {}),
38
+ contentPath,
39
+ };
40
+ const article = new Article(articleProps);
41
+
42
+ // Write article to filesystem
43
+ const writer = new FileWriter();
44
+ await writer.write(article);
45
+
46
+ // Write image if available
47
+ if (post.generatedImageData) {
48
+ const imagePath = path.join(process.cwd(), article.folderPath, 'hero.webp');
49
+ await convertToWebP(post.generatedImageData, imagePath);
50
+ }
51
+
52
+ // Mark as published in DB
53
+ await service.markPublished(post.id, article.folderPath);
54
+
55
+ return { id: post.id, publishedPath: article.folderPath };
56
+ }
57
+
58
+ export const POST: APIRoute = async ({ request }) => {
59
+ try {
60
+ const data = await request.json();
61
+ const service = getService();
62
+
63
+ // Auto-publish all due posts
64
+ if (data.mode === 'auto') {
65
+ const duePosts = await service.getDuePosts();
66
+ const results: { id: string; publishedPath: string }[] = [];
67
+ const failed: { id: string; error: string }[] = [];
68
+
69
+ for (const post of duePosts) {
70
+ try {
71
+ const result = await publishPost(service, post.id);
72
+ results.push(result);
73
+ } catch (error) {
74
+ const message =
75
+ error instanceof Error ? error.message : String(error);
76
+ console.error(`Failed to auto-publish ${post.id}:`, error);
77
+ failed.push({ id: post.id, error: message });
78
+ }
79
+ }
80
+
81
+ return jsonResponse({ published: results, failed });
82
+ }
83
+
84
+ // Publish single post
85
+ if (!data.id) {
86
+ return jsonError('id or mode:"auto" is required', 400);
87
+ }
88
+
89
+ const result = await publishPost(service, data.id);
90
+
91
+ return jsonResponse({ success: true, ...result });
92
+ } catch (error) {
93
+ console.error('Scheduler publish error:', error);
94
+ return jsonError(error);
95
+ }
96
+ };
@@ -0,0 +1,51 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { SchedulerService } from '../services/SchedulerService';
4
+ import { AstroSchedulerDBAdapter } from '../services/SchedulerDBAdapter';
5
+ import { jsonResponse, jsonError } from './_utils';
6
+
7
+ const CreateScheduledPostSchema = z.object({
8
+ input: z.string().min(1, 'input is required'),
9
+ scheduledDate: z.string().min(1, 'scheduledDate is required'),
10
+ });
11
+
12
+ function getService(): SchedulerService {
13
+ return new SchedulerService(new AstroSchedulerDBAdapter());
14
+ }
15
+
16
+ export const GET: APIRoute = async () => {
17
+ try {
18
+ const service = getService();
19
+ const posts = await service.list();
20
+ return jsonResponse({ posts });
21
+ } catch (error) {
22
+ console.error('Scheduler list error:', error);
23
+ return jsonError(error);
24
+ }
25
+ };
26
+
27
+ export const POST: APIRoute = async ({ request }) => {
28
+ try {
29
+ const body = await request.json();
30
+ const parsed = CreateScheduledPostSchema.safeParse(body);
31
+ if (!parsed.success) {
32
+ return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
33
+ }
34
+ const data = parsed.data;
35
+
36
+ const service = getService();
37
+ const post = await service.create({
38
+ input: data.input,
39
+ scheduledDate: new Date(data.scheduledDate),
40
+ });
41
+
42
+ return jsonResponse({ post }, 201);
43
+ } catch (error) {
44
+ console.error('Scheduler create error:', error);
45
+ const status =
46
+ error instanceof Error && error.message.includes('already scheduled')
47
+ ? 409
48
+ : 500;
49
+ return jsonError(error, status);
50
+ }
51
+ };
@@ -0,0 +1,115 @@
1
+ import { useState } from 'react';
2
+ import type { TabType } from './editor/types';
3
+ import { styles } from './editor/styles';
4
+ import { useTabNavigation } from './editor/useTabNavigation';
5
+ import {
6
+ GenerateTabProvider,
7
+ GenerateTabControls,
8
+ GenerateTabPreview,
9
+ } from './editor/GenerateTab';
10
+ import { SettingsTab } from './editor/SettingsTab';
11
+ import { PlannerTab } from './editor/PlannerTab';
12
+
13
+ const TABS: { key: TabType; label: string }[] = [
14
+ { key: 'generate', label: 'Generieren' },
15
+ { key: 'planner', label: 'Planer' },
16
+ { key: 'settings', label: 'Einstellungen' },
17
+ ];
18
+
19
+ export default function Editor() {
20
+ const [activeTab, setActiveTab] = useState<TabType>('generate');
21
+
22
+ const tabIndex = TABS.findIndex((t) => t.key === activeTab);
23
+ const { handleTabKeyDown, tabListRef } = useTabNavigation({
24
+ tabCount: TABS.length,
25
+ activeIndex: tabIndex,
26
+ onActivate: (index) => { const tab = TABS[index]; if (tab) setActiveTab(tab.key); },
27
+ });
28
+
29
+ const isFullWidth = activeTab !== 'generate';
30
+
31
+ const handleLogout = async () => {
32
+ await fetch('/api/auth/logout', { method: 'POST' });
33
+ window.location.href = '/login';
34
+ };
35
+
36
+ return (
37
+ <div style={styles.container}>
38
+ <div style={styles.headerRow}>
39
+ <div
40
+ ref={tabListRef}
41
+ role="tablist"
42
+ aria-label="Editor-Tabs"
43
+ style={styles.tabNav}
44
+ >
45
+ {TABS.map((tab) => (
46
+ <button
47
+ key={tab.key}
48
+ type="button"
49
+ role="tab"
50
+ aria-selected={activeTab === tab.key}
51
+ aria-controls={`panel-${tab.key}`}
52
+ id={`tab-${tab.key}`}
53
+ tabIndex={activeTab === tab.key ? 0 : -1}
54
+ style={
55
+ activeTab === tab.key ? styles.tabActive : styles.tab
56
+ }
57
+ onClick={() => setActiveTab(tab.key)}
58
+ onKeyDown={handleTabKeyDown}
59
+ >
60
+ {tab.label}
61
+ </button>
62
+ ))}
63
+ </div>
64
+ <button
65
+ type="button"
66
+ style={styles.logoutButton}
67
+ onClick={handleLogout}
68
+ >
69
+ Abmelden
70
+ </button>
71
+ </div>
72
+
73
+ {activeTab === 'generate' && (
74
+ <GenerateTabProvider>
75
+ <div
76
+ id="panel-generate"
77
+ role="tabpanel"
78
+ aria-labelledby="tab-generate"
79
+ style={{
80
+ display: 'grid',
81
+ gridTemplateColumns: '380px 1fr',
82
+ gap: '1.5rem',
83
+ alignItems: 'start',
84
+ }}
85
+ >
86
+ <GenerateTabControls />
87
+ <GenerateTabPreview />
88
+ </div>
89
+ </GenerateTabProvider>
90
+ )}
91
+
92
+ {activeTab === 'planner' && (
93
+ <div
94
+ id="panel-planner"
95
+ role="tabpanel"
96
+ aria-labelledby="tab-planner"
97
+ style={isFullWidth ? styles.panelFullWidth : undefined}
98
+ >
99
+ <PlannerTab />
100
+ </div>
101
+ )}
102
+
103
+ {activeTab === 'settings' && (
104
+ <div
105
+ id="panel-settings"
106
+ role="tabpanel"
107
+ aria-labelledby="tab-settings"
108
+ style={isFullWidth ? styles.panelFullWidth : undefined}
109
+ >
110
+ <SettingsTab />
111
+ </div>
112
+ )}
113
+ </div>
114
+ );
115
+ }