nca-ai-cms-astro-plugin 1.0.15 → 1.0.16

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.15",
3
+ "version": "1.0.16",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -0,0 +1,42 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { z } from 'zod';
3
+ import { ContentGenerator } from '../services/ContentGenerator';
4
+ import { PromptService } from '../services/PromptService';
5
+ import { getEnvVariable } from '../utils/envUtils';
6
+ import { jsonResponse, jsonError } from './_utils';
7
+
8
+ const GeneratePageSchema = z.object({
9
+ input: z.string().min(1, 'Input is required'),
10
+ });
11
+
12
+ export const POST: APIRoute = async ({ request }) => {
13
+ try {
14
+ const body = await request.json();
15
+ const parsed = GeneratePageSchema.safeParse(body);
16
+ if (!parsed.success) {
17
+ return jsonError(parsed.error.errors[0]?.message ?? 'Invalid request', 400);
18
+ }
19
+ const { input } = parsed.data;
20
+ const isUrl = /^https?:\/\//.test(input);
21
+
22
+ const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
23
+ const promptService = new PromptService();
24
+ const generator = new ContentGenerator({ apiKey, promptService });
25
+ const page = isUrl
26
+ ? await generator.generateFromUrl(input)
27
+ : await generator.generateFromKeywords(input);
28
+
29
+ return jsonResponse({
30
+ title: page.title,
31
+ description: page.description,
32
+ content: page.content,
33
+ filepath: page.filepath,
34
+ tags: page.tags,
35
+ date: page.date.toISOString(),
36
+ ...(generator.warnings.length > 0 ? { warnings: generator.warnings } : {}),
37
+ });
38
+ } catch (error) {
39
+ console.error('Page generation error:', error);
40
+ return jsonError(error);
41
+ }
42
+ };
@@ -0,0 +1,61 @@
1
+ import type { APIRoute } from 'astro';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import matter from 'gray-matter';
5
+ import {
6
+ ArticleService,
7
+ ArticleNotFoundError,
8
+ } from '../../../services/ArticleService';
9
+ import { convertToWebP } from '../../../services/ImageConverter';
10
+ import { pagesPath } from 'virtual:nca-ai-cms/config';
11
+ import { jsonResponse, jsonError } from '../../_utils';
12
+
13
+ interface ApplyRequest {
14
+ title?: string;
15
+ description?: string;
16
+ content?: string;
17
+ tags?: string[];
18
+ imageUrl?: string;
19
+ imageAlt?: string;
20
+ }
21
+
22
+ export const POST: APIRoute = async ({ params, request }) => {
23
+ try {
24
+ const slug = params.id;
25
+ if (!slug) return jsonError('Page ID required', 400);
26
+
27
+ const data: ApplyRequest = await request.json();
28
+ const service = new ArticleService(pagesPath);
29
+ const existingPage = await service.read(slug);
30
+ if (!existingPage) throw new ArticleNotFoundError(slug);
31
+
32
+ if (data.imageUrl) {
33
+ const heroPath = path.join(existingPage.folderPath, 'hero.webp');
34
+ const base64Data = data.imageUrl.replace(/^data:image\/\w+;base64,/, '');
35
+ await convertToWebP(base64Data, heroPath);
36
+
37
+ if (data.imageAlt) {
38
+ const indexPath = path.join(existingPage.folderPath, 'index.md');
39
+ const fileContent = await fs.readFile(indexPath, 'utf-8');
40
+ const { data: frontmatter, content } = matter(fileContent);
41
+ frontmatter.imageAlt = data.imageAlt;
42
+ const updatedContent = matter.stringify(content, frontmatter);
43
+ await fs.writeFile(indexPath, updatedContent);
44
+ }
45
+ }
46
+
47
+ if (data.content || data.title || data.description) {
48
+ await service.updateContent(slug, {
49
+ ...(data.title && { title: data.title }),
50
+ ...(data.description && { description: data.description }),
51
+ ...(data.content && { content: data.content }),
52
+ });
53
+ }
54
+
55
+ return jsonResponse({ success: true, articleId: existingPage.articleId });
56
+ } catch (error) {
57
+ if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
58
+ console.error('Apply page changes error:', error);
59
+ return jsonError(error);
60
+ }
61
+ };
@@ -0,0 +1,35 @@
1
+ import type { APIRoute } from 'astro';
2
+ import {
3
+ ArticleService,
4
+ ArticleNotFoundError,
5
+ } from '../../../services/ArticleService';
6
+ import { ImageGenerator } from '../../../services/ImageGenerator';
7
+ import { getEnvVariable } from '../../../utils/envUtils';
8
+ import { pagesPath } from 'virtual:nca-ai-cms/config';
9
+ import { jsonResponse, jsonError } from '../../_utils';
10
+
11
+ export const POST: APIRoute = async ({ params }) => {
12
+ try {
13
+ const slug = params.id;
14
+ if (!slug) return jsonError('Page ID required', 400);
15
+
16
+ const service = new ArticleService(pagesPath);
17
+ const existingPage = await service.read(slug);
18
+ if (!existingPage) throw new ArticleNotFoundError(slug);
19
+
20
+ const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
21
+ const generator = new ImageGenerator({ apiKey });
22
+ const image = await generator.generate(existingPage.title);
23
+
24
+ return jsonResponse({
25
+ url: image.url,
26
+ alt: image.alt,
27
+ articleId: existingPage.articleId,
28
+ articleTitle: existingPage.title,
29
+ });
30
+ } catch (error) {
31
+ if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
32
+ console.error('Regenerate page image error:', error);
33
+ return jsonError(error);
34
+ }
35
+ };
@@ -0,0 +1,39 @@
1
+ import type { APIRoute } from 'astro';
2
+ import {
3
+ ArticleService,
4
+ ArticleNotFoundError,
5
+ } from '../../../services/ArticleService';
6
+ import { ContentGenerator } from '../../../services/ContentGenerator';
7
+ import { PromptService } from '../../../services/PromptService';
8
+ import { getEnvVariable } from '../../../utils/envUtils';
9
+ import { pagesPath } from 'virtual:nca-ai-cms/config';
10
+ import { jsonResponse, jsonError } from '../../_utils';
11
+
12
+ export const POST: APIRoute = async ({ params }) => {
13
+ try {
14
+ const slug = params.id;
15
+ if (!slug) return jsonError('Page ID required', 400);
16
+
17
+ const service = new ArticleService(pagesPath);
18
+ const existingPage = await service.read(slug);
19
+ if (!existingPage) throw new ArticleNotFoundError(slug);
20
+
21
+ const apiKey = getEnvVariable('GOOGLE_GEMINI_API_KEY');
22
+ const promptService = new PromptService();
23
+ const generator = new ContentGenerator({ apiKey, promptService });
24
+ const newPage = await generator.generateFromKeywords(existingPage.title);
25
+
26
+ return jsonResponse({
27
+ title: newPage.title,
28
+ description: newPage.description,
29
+ content: newPage.content,
30
+ tags: newPage.tags,
31
+ originalTitle: existingPage.title,
32
+ articleId: existingPage.articleId,
33
+ });
34
+ } catch (error) {
35
+ if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
36
+ console.error('Regenerate page text error:', error);
37
+ return jsonError(error);
38
+ }
39
+ };
@@ -0,0 +1,40 @@
1
+ import type { APIRoute } from 'astro';
2
+ import {
3
+ ArticleService,
4
+ ArticleNotFoundError,
5
+ } from '../../services/ArticleService';
6
+ import { pagesPath } from 'virtual:nca-ai-cms/config';
7
+ import { jsonResponse, jsonError } from '../_utils';
8
+
9
+ // GET /api/pages/[id] - Get page details
10
+ export const GET: APIRoute = async ({ params }) => {
11
+ try {
12
+ const slug = params.id;
13
+ if (!slug) return jsonError('Page ID required', 400);
14
+
15
+ const service = new ArticleService(pagesPath);
16
+ const page = await service.read(slug);
17
+ if (!page) return jsonError('Page not found', 404);
18
+
19
+ return jsonResponse(page);
20
+ } catch (error) {
21
+ console.error('Read page error:', error);
22
+ return jsonError(error);
23
+ }
24
+ };
25
+
26
+ // DELETE /api/pages/[id] - Delete a page by slug
27
+ export const DELETE: APIRoute = async ({ params }) => {
28
+ try {
29
+ const slug = params.id;
30
+ if (!slug) return jsonError('Page ID required', 400);
31
+
32
+ const service = new ArticleService(pagesPath);
33
+ await service.delete(slug);
34
+ return jsonResponse({ success: true });
35
+ } catch (error) {
36
+ if (error instanceof ArticleNotFoundError) return jsonError(error, 404);
37
+ console.error('Delete page error:', error);
38
+ return jsonError(error);
39
+ }
40
+ };
@@ -0,0 +1,16 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { ArticleService } from '../../services/ArticleService';
3
+ import { pagesPath } from 'virtual:nca-ai-cms/config';
4
+ import { jsonResponse, jsonError } from '../_utils';
5
+
6
+ // GET /api/pages - List all pages
7
+ export const GET: APIRoute = async () => {
8
+ try {
9
+ const service = new ArticleService(pagesPath);
10
+ const pages = await service.list();
11
+ return jsonResponse(pages);
12
+ } catch (error) {
13
+ console.error('List pages error:', error);
14
+ return jsonError(error);
15
+ }
16
+ };
@@ -0,0 +1,50 @@
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 { pagesPath } from 'virtual:nca-ai-cms/config';
6
+ import { jsonResponse, jsonError } from './_utils';
7
+
8
+ const SavePageSchema = 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 = SavePageSchema.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 page = 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: pagesPath,
35
+ flatPath: true,
36
+ });
37
+
38
+ const writer = new FileWriter();
39
+ const result = await writer.write(page);
40
+
41
+ return jsonResponse({
42
+ success: true,
43
+ filepath: result.filepath,
44
+ folderPath: page.folderPath,
45
+ });
46
+ } catch (error) {
47
+ console.error('Save page error:', error);
48
+ return jsonError(error);
49
+ }
50
+ };
@@ -9,10 +9,12 @@ import {
9
9
  } from './editor/GenerateTab';
10
10
  import { SettingsTab } from './editor/SettingsTab';
11
11
  import { PlannerTab } from './editor/PlannerTab';
12
+ import { PagesTab } from './editor/PagesTab';
12
13
 
13
14
  const TABS: { key: TabType; label: string }[] = [
14
15
  { key: 'generate', label: 'Generieren' },
15
16
  { key: 'planner', label: 'Planer' },
17
+ { key: 'pages', label: 'Seiten' },
16
18
  { key: 'settings', label: 'Einstellungen' },
17
19
  ];
18
20
 
@@ -100,6 +102,17 @@ export default function Editor() {
100
102
  </div>
101
103
  )}
102
104
 
105
+ {activeTab === 'pages' && (
106
+ <div
107
+ id="panel-pages"
108
+ role="tabpanel"
109
+ aria-labelledby="tab-pages"
110
+ style={isFullWidth ? styles.panelFullWidth : undefined}
111
+ >
112
+ <PagesTab />
113
+ </div>
114
+ )}
115
+
103
116
  {activeTab === 'settings' && (
104
117
  <div
105
118
  id="panel-settings"
@@ -0,0 +1,231 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { styles } from './styles';
3
+
4
+ interface PageData {
5
+ articleId: string;
6
+ title: string;
7
+ description: string;
8
+ date: string;
9
+ tags: string[];
10
+ image?: string;
11
+ imageAlt?: string;
12
+ }
13
+
14
+ export function PagesTab() {
15
+ const [pages, setPages] = useState<PageData[]>([]);
16
+ const [loading, setLoading] = useState(true);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [generating, setGenerating] = useState(false);
19
+ const [newInput, setNewInput] = useState('');
20
+ const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
21
+
22
+ const loadPages = useCallback(async () => {
23
+ setLoading(true);
24
+ setError(null);
25
+ try {
26
+ const res = await fetch('/api/pages');
27
+ if (!res.ok) throw new Error('Fehler beim Laden der Seiten');
28
+ const data = (await res.json()) as PageData[];
29
+ setPages(data);
30
+ } catch (err) {
31
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ loadPages();
39
+ }, [loadPages]);
40
+
41
+ const handleGenerate = async () => {
42
+ if (!newInput.trim()) return;
43
+ setGenerating(true);
44
+ setError(null);
45
+ try {
46
+ // Generate content
47
+ const contentRes = await fetch('/api/generate-page', {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({ input: newInput }),
51
+ });
52
+ if (!contentRes.ok) throw new Error('Fehler bei der Seitengenerierung');
53
+ const content = await contentRes.json();
54
+
55
+ // Generate image
56
+ const imageRes = await fetch('/api/generate-image', {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ title: content.title }),
60
+ });
61
+ const image = imageRes.ok ? await imageRes.json() : null;
62
+
63
+ // Save page
64
+ const saveRes = await fetch('/api/save-page', {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify({
68
+ title: content.title,
69
+ description: content.description,
70
+ content: content.content,
71
+ tags: content.tags,
72
+ date: content.date,
73
+ imageAlt: image?.alt,
74
+ }),
75
+ });
76
+ if (!saveRes.ok) throw new Error('Fehler beim Speichern der Seite');
77
+ const saveResult = await saveRes.json();
78
+
79
+ // Save image if generated
80
+ if (image?.url && saveResult.folderPath) {
81
+ await fetch('/api/save-image', {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({
85
+ imageUrl: image.url,
86
+ folderPath: saveResult.folderPath,
87
+ }),
88
+ });
89
+ }
90
+
91
+ setNewInput('');
92
+ await loadPages();
93
+ } catch (err) {
94
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
95
+ } finally {
96
+ setGenerating(false);
97
+ }
98
+ };
99
+
100
+ const handleDelete = async (slug: string) => {
101
+ try {
102
+ const res = await fetch(`/api/pages/${slug}`, {
103
+ method: 'DELETE',
104
+ credentials: 'same-origin',
105
+ });
106
+ if (!res.ok) throw new Error('Fehler beim Löschen');
107
+ setDeleteConfirm(null);
108
+ await loadPages();
109
+ } catch (err) {
110
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
111
+ }
112
+ };
113
+
114
+ if (loading) {
115
+ return <div style={styles.loadingBox}>Seiten werden geladen...</div>;
116
+ }
117
+
118
+ return (
119
+ <div style={styles.plannerContent}>
120
+ {/* Generate new page form */}
121
+ <div style={styles.plannerForm}>
122
+ <div style={styles.addFormTitle}>Neue Seite generieren</div>
123
+ <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'end' }}>
124
+ <div style={{ flex: 1 }}>
125
+ <label style={styles.addFormLabel}>Thema, Keywords oder URL</label>
126
+ <input
127
+ type="text"
128
+ value={newInput}
129
+ onChange={(e) => setNewInput(e.target.value)}
130
+ placeholder="z.B. Laserreinigung, Gleitschleifen..."
131
+ style={styles.addFormInput}
132
+ onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
133
+ />
134
+ </div>
135
+ <button
136
+ type="button"
137
+ style={styles.addButton}
138
+ onClick={handleGenerate}
139
+ disabled={generating || !newInput.trim()}
140
+ >
141
+ {generating ? 'Generiert...' : 'Seite erstellen'}
142
+ </button>
143
+ </div>
144
+ </div>
145
+
146
+ {error && <div style={styles.error}>{error}</div>}
147
+
148
+ {/* Page list */}
149
+ {pages.length === 0 ? (
150
+ <div style={styles.emptyState}>
151
+ Noch keine Seiten vorhanden. Erstellen Sie Ihre erste Seite!
152
+ </div>
153
+ ) : (
154
+ <div style={styles.plannerList}>
155
+ {pages.map((page) => (
156
+ <div key={page.articleId} style={styles.plannerCard}>
157
+ <div style={styles.plannerCardHeader}>
158
+ <div>
159
+ <div style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--color-text, #faf9f7)' }}>
160
+ {page.title}
161
+ </div>
162
+ <div style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #b8b5b0)', marginTop: '0.25rem' }}>
163
+ {page.description}
164
+ </div>
165
+ </div>
166
+ <div style={styles.plannerCardActions}>
167
+ <a
168
+ href={`/services/${page.articleId}`}
169
+ style={{
170
+ ...styles.editButton,
171
+ textDecoration: 'none',
172
+ display: 'inline-flex',
173
+ alignItems: 'center',
174
+ }}
175
+ >
176
+ Ansehen
177
+ </a>
178
+ {deleteConfirm === page.articleId ? (
179
+ <>
180
+ <button
181
+ type="button"
182
+ style={styles.deleteButton}
183
+ onClick={() => handleDelete(page.articleId)}
184
+ >
185
+ Bestätigen
186
+ </button>
187
+ <button
188
+ type="button"
189
+ style={styles.cancelButton}
190
+ onClick={() => setDeleteConfirm(null)}
191
+ >
192
+ Abbrechen
193
+ </button>
194
+ </>
195
+ ) : (
196
+ <button
197
+ type="button"
198
+ style={styles.deleteButton}
199
+ onClick={() => setDeleteConfirm(page.articleId)}
200
+ >
201
+ Löschen
202
+ </button>
203
+ )}
204
+ </div>
205
+ </div>
206
+ {page.tags && page.tags.length > 0 && (
207
+ <div style={{ padding: '0.75rem 1.25rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' as const }}>
208
+ {page.tags.map((tag) => (
209
+ <span
210
+ key={tag}
211
+ style={{
212
+ background: 'var(--color-surface-accent, #222226)',
213
+ border: '1px solid var(--color-border, #2a2a2e)',
214
+ borderRadius: '4px',
215
+ padding: '0.15rem 0.5rem',
216
+ fontSize: '0.75rem',
217
+ color: 'var(--color-text-muted, #b8b5b0)',
218
+ }}
219
+ >
220
+ {tag}
221
+ </span>
222
+ ))}
223
+ </div>
224
+ )}
225
+ </div>
226
+ ))}
227
+ </div>
228
+ )}
229
+ </div>
230
+ );
231
+ }
@@ -1,4 +1,4 @@
1
- export type TabType = 'generate' | 'planner' | 'settings';
1
+ export type TabType = 'generate' | 'planner' | 'pages' | 'settings';
2
2
 
3
3
  export type SettingsSubTab =
4
4
  | 'homepage'
package/src/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  declare module 'virtual:nca-ai-cms/config' {
2
2
  export const contentPath: string;
3
+ export const pagesPath: string;
3
4
  export const autoPublish: boolean;
4
5
  }
@@ -135,4 +135,27 @@ describe('Article', () => {
135
135
  expect(markdown).toContain('image: "./hero.webp"');
136
136
  expect(markdown).toContain('imageAlt: "Accessibility illustration"');
137
137
  });
138
+
139
+ it('generates flat folderPath when flatPath is true', () => {
140
+ const article = new Article({
141
+ ...defaultProps,
142
+ contentPath: 'nca-ai-cms-pages',
143
+ flatPath: true,
144
+ });
145
+
146
+ expect(article.folderPath).toBe(
147
+ 'nca-ai-cms-pages/html-accessibility-grundlagen'
148
+ );
149
+ });
150
+
151
+ it('generates nested folderPath when flatPath is false', () => {
152
+ const article = new Article({
153
+ ...defaultProps,
154
+ flatPath: false,
155
+ });
156
+
157
+ expect(article.folderPath).toBe(
158
+ 'nca-ai-cms-content/2025/12/html-accessibility-grundlagen'
159
+ );
160
+ });
138
161
  });
@@ -10,6 +10,7 @@ export type ArticleProps = {
10
10
  image?: string;
11
11
  imageAlt?: string;
12
12
  contentPath?: string;
13
+ flatPath?: boolean;
13
14
  };
14
15
 
15
16
  export class Article {
@@ -23,6 +24,7 @@ export class Article {
23
24
  readonly image?: string;
24
25
  readonly imageAlt?: string;
25
26
  readonly contentPath: string;
27
+ readonly flatPath: boolean;
26
28
 
27
29
  constructor(props: ArticleProps) {
28
30
  this.title = props.title;
@@ -32,6 +34,7 @@ export class Article {
32
34
  this.slug = new Slug(props.title);
33
35
  this.seoMetadata = new SEOMetadata(props.title, props.description);
34
36
  this.contentPath = props.contentPath ?? 'nca-ai-cms-content';
37
+ this.flatPath = props.flatPath ?? false;
35
38
 
36
39
  this.content = props.content;
37
40
 
@@ -56,6 +59,9 @@ export class Article {
56
59
  }
57
60
 
58
61
  get folderPath(): string {
62
+ if (this.flatPath) {
63
+ return `${this.contentPath}/${this.slug.toString()}`;
64
+ }
59
65
  return `${this.contentPath}/${this.year}/${this.month}/${this.slug.toString()}`;
60
66
  }
61
67
 
@@ -101,4 +101,37 @@ describe('ArticleFinder', () => {
101
101
  expect(result?.articleId).toBe('2026/01/test-slug');
102
102
  });
103
103
  });
104
+
105
+ describe('flat folder support', () => {
106
+ it('finds article in flat structure (basePath/slug/index.md)', async () => {
107
+ const flatFolder = path.join(tempDir, 'laserreinigung');
108
+ await fs.mkdir(flatFolder, { recursive: true });
109
+ await fs.writeFile(
110
+ path.join(flatFolder, 'index.md'),
111
+ '---\ntitle: Laserreinigung\n---\nContent'
112
+ );
113
+
114
+ const result = await finder.findBySlug('laserreinigung');
115
+
116
+ expect(result).not.toBeNull();
117
+ expect(result?.folderPath).toBe(flatFolder);
118
+ expect(result?.articleId).toBe('laserreinigung');
119
+ });
120
+
121
+ it('flat folder takes priority over year/month match', async () => {
122
+ // Create both flat and nested
123
+ const flatFolder = path.join(tempDir, 'my-page');
124
+ await fs.mkdir(flatFolder, { recursive: true });
125
+ await fs.writeFile(path.join(flatFolder, 'index.md'), '---\ntitle: Flat\n---');
126
+
127
+ const nestedFolder = path.join(tempDir, '2026', '01', 'my-page');
128
+ await fs.mkdir(nestedFolder, { recursive: true });
129
+ await fs.writeFile(path.join(nestedFolder, 'index.md'), '---\ntitle: Nested\n---');
130
+
131
+ const result = await finder.findBySlug('my-page');
132
+
133
+ expect(result?.articleId).toBe('my-page');
134
+ expect(result?.folderPath).toBe(flatFolder);
135
+ });
136
+ });
104
137
  });
@@ -12,6 +12,18 @@ export class ArticleFinder {
12
12
 
13
13
  async findBySlug(slug: string): Promise<ArticleLocation | null> {
14
14
  try {
15
+ // 1. Check flat structure: basePath/slug/index.md
16
+ const flatPath = path.join(this.basePath, slug);
17
+ const flatIndex = path.join(flatPath, 'index.md');
18
+ try {
19
+ await fs.access(flatIndex);
20
+ const stat = await fs.stat(flatPath);
21
+ if (stat.isDirectory()) {
22
+ return { folderPath: flatPath, indexPath: flatIndex, articleId: slug };
23
+ }
24
+ } catch {}
25
+
26
+ // 2. Fall back to year/month/slug structure
15
27
  const years = await this.getDirectories(this.basePath);
16
28
 
17
29
  for (const year of years) {
@@ -27,7 +39,6 @@ export class ArticleFinder {
27
39
  const folderPath = path.join(monthPath, article);
28
40
  const indexPath = path.join(folderPath, 'index.md');
29
41
 
30
- // Verify index.md exists
31
42
  try {
32
43
  await fs.access(indexPath);
33
44
  return {
@@ -36,7 +47,6 @@ export class ArticleFinder {
36
47
  articleId: `${year}/${month}/${article}`,
37
48
  };
38
49
  } catch {
39
- // index.md doesn't exist, skip this folder
40
50
  continue;
41
51
  }
42
52
  }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import node from '@astrojs/node';
6
6
 
7
7
  export interface NcaAiCmsPluginOptions {
8
8
  contentPath?: string;
9
+ pagesPath?: string;
9
10
  autoPublish?: boolean;
10
11
  }
11
12
 
@@ -13,6 +14,7 @@ export default function ncaAiCms(
13
14
  options: NcaAiCmsPluginOptions = {}
14
15
  ): AstroIntegration {
15
16
  const contentPath = options.contentPath ?? 'nca-ai-cms-content';
17
+ const pagesPath = options.pagesPath ?? 'nca-ai-cms-pages';
16
18
  const autoPublish = options.autoPublish ?? import.meta.env.PROD;
17
19
 
18
20
  return {
@@ -60,7 +62,7 @@ export default function ncaAiCms(
60
62
  },
61
63
  load(id) {
62
64
  if (id === '\0virtual:nca-ai-cms/config') {
63
- return `export const contentPath = ${JSON.stringify(contentPath)};\nexport const autoPublish = ${JSON.stringify(autoPublish)};`;
65
+ return `export const contentPath = ${JSON.stringify(contentPath)};\nexport const pagesPath = ${JSON.stringify(pagesPath)};\nexport const autoPublish = ${JSON.stringify(autoPublish)};`;
64
66
  }
65
67
  },
66
68
  },
@@ -137,6 +139,48 @@ export default function ncaAiCms(
137
139
  prerender: false,
138
140
  });
139
141
 
142
+ // Inject page API routes (mirrors article routes with pagesPath)
143
+ injectRoute({
144
+ pattern: '/api/pages',
145
+ entrypoint: 'nca-ai-cms-astro-plugin/api/pages/index.ts',
146
+ prerender: false,
147
+ });
148
+ injectRoute({
149
+ pattern: '/api/pages/[id]',
150
+ entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id].ts',
151
+ prerender: false,
152
+ });
153
+ injectRoute({
154
+ pattern: '/api/pages/[id]/apply',
155
+ entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id]/apply.ts',
156
+ prerender: false,
157
+ });
158
+ injectRoute({
159
+ pattern: '/api/pages/[id]/regenerate-text',
160
+ entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id]/regenerate-text.ts',
161
+ prerender: false,
162
+ });
163
+ injectRoute({
164
+ pattern: '/api/pages/[id]/regenerate-image',
165
+ entrypoint: 'nca-ai-cms-astro-plugin/api/pages/[id]/regenerate-image.ts',
166
+ prerender: false,
167
+ });
168
+ injectRoute({
169
+ pattern: '/api/generate-page',
170
+ entrypoint: 'nca-ai-cms-astro-plugin/api/generate-page.ts',
171
+ prerender: false,
172
+ });
173
+ injectRoute({
174
+ pattern: '/api/save-page',
175
+ entrypoint: 'nca-ai-cms-astro-plugin/api/save-page.ts',
176
+ prerender: false,
177
+ });
178
+ injectRoute({
179
+ pattern: '/api/page-image/[...path]',
180
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/api/page-image/[...path].ts',
181
+ prerender: false,
182
+ });
183
+
140
184
  // Inject auth routes
141
185
  injectRoute({
142
186
  pattern: '/api/auth/login',
@@ -0,0 +1,50 @@
1
+ import type { APIRoute } from 'astro';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { pagesPath } from 'virtual:nca-ai-cms/config';
5
+
6
+ export const GET: APIRoute = async ({ params }) => {
7
+ const imagePath = params.path;
8
+
9
+ if (!imagePath) {
10
+ return new Response('Not found', { status: 404 });
11
+ }
12
+
13
+ const allowedExtensions = ['.webp', '.png', '.jpg', '.jpeg'];
14
+ const ext = path.extname(imagePath).toLowerCase();
15
+
16
+ if (!allowedExtensions.includes(ext)) {
17
+ return new Response('Invalid file type', { status: 400 });
18
+ }
19
+
20
+ const contentDir = path.resolve(process.cwd(), pagesPath);
21
+ const fullPath = path.resolve(contentDir, imagePath);
22
+
23
+ if (!fullPath.startsWith(contentDir + path.sep)) {
24
+ return new Response('Invalid path', { status: 400 });
25
+ }
26
+
27
+ try {
28
+ const imageBuffer = await fs.readFile(fullPath);
29
+ const contentType =
30
+ ext === '.webp'
31
+ ? 'image/webp'
32
+ : ext === '.png'
33
+ ? 'image/png'
34
+ : 'image/jpeg';
35
+
36
+ const stats = await fs.stat(fullPath);
37
+ const etag = `"${stats.mtimeMs.toString(36)}"`;
38
+
39
+ return new Response(imageBuffer, {
40
+ status: 200,
41
+ headers: {
42
+ 'Content-Type': contentType,
43
+ 'Cache-Control': 'public, max-age=0, must-revalidate',
44
+ ETag: etag,
45
+ },
46
+ });
47
+ } catch {
48
+ return new Response('Image not found', { status: 404 });
49
+ }
50
+ };
@@ -43,17 +43,27 @@ export class ArticleService {
43
43
  const fullBasePath = path.join(process.cwd(), this.basePath);
44
44
 
45
45
  try {
46
- const years = await fs.readdir(fullBasePath);
46
+ const entries = await fs.readdir(fullBasePath);
47
47
 
48
- for (const year of years) {
49
- const yearPath = path.join(fullBasePath, year);
50
- const yearStat = await fs.stat(yearPath).catch(() => null);
51
- if (!yearStat?.isDirectory()) continue;
48
+ for (const entry of entries) {
49
+ const entryPath = path.join(fullBasePath, entry);
50
+ const entryStat = await fs.stat(entryPath).catch(() => null);
51
+ if (!entryStat?.isDirectory()) continue;
52
52
 
53
- const months = await fs.readdir(yearPath);
53
+ // Check if this is a flat article (has index.md directly)
54
+ const flatIndex = path.join(entryPath, 'index.md');
55
+ try {
56
+ await fs.access(flatIndex);
57
+ const article = await this.read(entry);
58
+ if (article) articles.push(article);
59
+ continue;
60
+ } catch {}
61
+
62
+ // Otherwise treat as year directory → scan month/slug
63
+ const months = await fs.readdir(entryPath);
54
64
 
55
65
  for (const month of months) {
56
- const monthPath = path.join(yearPath, month);
66
+ const monthPath = path.join(entryPath, month);
57
67
  const monthStat = await fs.stat(monthPath).catch(() => null);
58
68
  if (!monthStat?.isDirectory()) continue;
59
69
 
@@ -1,7 +1,7 @@
1
1
  import { validateSession } from '../services/SessionService.js';
2
2
 
3
3
  const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/logout', '/login'];
4
- const PUBLIC_PATH_PREFIXES = ['/api/article-image/'];
4
+ const PUBLIC_PATH_PREFIXES = ['/api/article-image/', '/api/page-image/'];
5
5
 
6
6
  export function isPublicPath(pathname: string): boolean {
7
7
  return PUBLIC_PATHS.includes(pathname) || PUBLIC_PATH_PREFIXES.some(prefix => pathname.startsWith(prefix));
package/update.md CHANGED
@@ -1,3 +1,18 @@
1
+ # v1.0.16
2
+
3
+ ## Feature: Pages content type with flat URL structure
4
+ - New `pagesPath` plugin option (default: `nca-ai-cms-pages`) for managing CMS pages separately from articles
5
+ - Flat folder support in ArticleFinder — pages stored as `basePath/slug/index.md` without date hierarchy
6
+ - SEO-friendly URLs: `/services/laserreinigung` instead of `/services/2024/04/laserreinigung`
7
+ - Flat paths take priority over year/month paths when both exist
8
+ - New `flatPath` option on Article entity for flat folder creation
9
+ - 8 new API routes: `/api/pages/*` (list, CRUD, regenerate text/image, apply, generate, save)
10
+ - `/api/page-image/[...path]` route for serving page images (public, no auth required)
11
+ - New "Seiten" tab in editor UI — list, create, delete pages with AI content generation
12
+ - 4 new tests (211 total, up from 207)
13
+
14
+ ---
15
+
1
16
  # v1.0.15
2
17
 
3
18
  ## Security: authentication hardening