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,43 @@
1
+ export class Source {
2
+ readonly url: string;
3
+ readonly domain: string;
4
+
5
+ constructor(url: string) {
6
+ if (!Source.isValidUrl(url)) {
7
+ throw new Error(`Invalid URL: ${url}`);
8
+ }
9
+
10
+ if (!url.startsWith('https://')) {
11
+ throw new Error('Only HTTPS URLs are allowed');
12
+ }
13
+
14
+ this.url = url;
15
+ this.domain = Source.extractDomain(url);
16
+ }
17
+
18
+ static isValidUrl(url: string): boolean {
19
+ try {
20
+ new URL(url);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ static extractDomain(url: string): string {
28
+ const parsed = new URL(url);
29
+ return parsed.hostname;
30
+ }
31
+
32
+ isMDN(): boolean {
33
+ return this.domain === 'developer.mozilla.org';
34
+ }
35
+
36
+ isW3C(): boolean {
37
+ return this.domain.includes('w3.org');
38
+ }
39
+
40
+ toString(): string {
41
+ return this.url;
42
+ }
43
+ }
@@ -0,0 +1,9 @@
1
+ export { Article, type ArticleProps } from './Article';
2
+ export { Source } from './Source';
3
+ export {
4
+ ScheduledPost,
5
+ type ScheduledPostStatus,
6
+ type ScheduledPostInputType,
7
+ type ScheduledPostProps,
8
+ type ScheduledPostDBRow,
9
+ } from './ScheduledPost';
@@ -0,0 +1,16 @@
1
+ export {
2
+ Slug,
3
+ SEOMetadata,
4
+ ArticleFinder,
5
+ type ArticleLocation,
6
+ } from './value-objects';
7
+ export {
8
+ Article,
9
+ type ArticleProps,
10
+ Source,
11
+ ScheduledPost,
12
+ type ScheduledPostStatus,
13
+ type ScheduledPostInputType,
14
+ type ScheduledPostProps,
15
+ type ScheduledPostDBRow,
16
+ } from './entities';
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { ArticleFinder } from './ArticleFinder';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+
7
+ describe('ArticleFinder', () => {
8
+ let tempDir: string;
9
+ let finder: ArticleFinder;
10
+
11
+ beforeEach(async () => {
12
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'article-finder-test-'));
13
+ finder = new ArticleFinder(tempDir);
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await fs.rm(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('findBySlug', () => {
21
+ it('finds article folder by slug', async () => {
22
+ // Setup: create article folder structure
23
+ const articleFolder = path.join(tempDir, '2026', '01', 'my-article-slug');
24
+ await fs.mkdir(articleFolder, { recursive: true });
25
+ await fs.writeFile(
26
+ path.join(articleFolder, 'index.md'),
27
+ '---\ntitle: Test\n---\nContent'
28
+ );
29
+
30
+ const result = await finder.findBySlug('my-article-slug');
31
+
32
+ expect(result).not.toBeNull();
33
+ expect(result?.folderPath).toBe(articleFolder);
34
+ expect(result?.indexPath).toBe(path.join(articleFolder, 'index.md'));
35
+ });
36
+
37
+ it('returns null for non-existent slug', async () => {
38
+ const result = await finder.findBySlug('does-not-exist');
39
+ expect(result).toBeNull();
40
+ });
41
+
42
+ it('finds article in any year/month folder', async () => {
43
+ // Create article in different year/month
44
+ const articleFolder = path.join(tempDir, '2025', '06', 'summer-article');
45
+ await fs.mkdir(articleFolder, { recursive: true });
46
+ await fs.writeFile(
47
+ path.join(articleFolder, 'index.md'),
48
+ '---\ntitle: Summer\n---'
49
+ );
50
+
51
+ const result = await finder.findBySlug('summer-article');
52
+
53
+ expect(result).not.toBeNull();
54
+ expect(result?.folderPath).toBe(articleFolder);
55
+ });
56
+
57
+ it('returns first match when multiple articles have similar slugs', async () => {
58
+ // Create two articles with similar names
59
+ const folder1 = path.join(tempDir, '2026', '01', 'test-article');
60
+ const folder2 = path.join(tempDir, '2025', '12', 'test-article');
61
+ await fs.mkdir(folder1, { recursive: true });
62
+ await fs.mkdir(folder2, { recursive: true });
63
+ await fs.writeFile(
64
+ path.join(folder1, 'index.md'),
65
+ '---\ntitle: Test 1\n---'
66
+ );
67
+ await fs.writeFile(
68
+ path.join(folder2, 'index.md'),
69
+ '---\ntitle: Test 2\n---'
70
+ );
71
+
72
+ const result = await finder.findBySlug('test-article');
73
+
74
+ expect(result).not.toBeNull();
75
+ // Should find one of them (order depends on filesystem)
76
+ expect(result?.folderPath).toContain('test-article');
77
+ });
78
+
79
+ it('ignores folders without index.md', async () => {
80
+ // Create folder without index.md
81
+ const emptyFolder = path.join(tempDir, '2026', '01', 'empty-folder');
82
+ await fs.mkdir(emptyFolder, { recursive: true });
83
+
84
+ const result = await finder.findBySlug('empty-folder');
85
+
86
+ expect(result).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe('getArticlePath', () => {
91
+ it('returns full article ID from folder path', async () => {
92
+ const articleFolder = path.join(tempDir, '2026', '01', 'test-slug');
93
+ await fs.mkdir(articleFolder, { recursive: true });
94
+ await fs.writeFile(
95
+ path.join(articleFolder, 'index.md'),
96
+ '---\ntitle: Test\n---'
97
+ );
98
+
99
+ const result = await finder.findBySlug('test-slug');
100
+
101
+ expect(result?.articleId).toBe('2026/01/test-slug');
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,61 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+
4
+ export interface ArticleLocation {
5
+ folderPath: string;
6
+ indexPath: string;
7
+ articleId: string;
8
+ }
9
+
10
+ export class ArticleFinder {
11
+ constructor(private readonly basePath: string) {}
12
+
13
+ async findBySlug(slug: string): Promise<ArticleLocation | null> {
14
+ try {
15
+ const years = await this.getDirectories(this.basePath);
16
+
17
+ for (const year of years) {
18
+ const yearPath = path.join(this.basePath, year);
19
+ const months = await this.getDirectories(yearPath);
20
+
21
+ for (const month of months) {
22
+ const monthPath = path.join(yearPath, month);
23
+ const articles = await this.getDirectories(monthPath);
24
+
25
+ for (const article of articles) {
26
+ if (article === slug) {
27
+ const folderPath = path.join(monthPath, article);
28
+ const indexPath = path.join(folderPath, 'index.md');
29
+
30
+ // Verify index.md exists
31
+ try {
32
+ await fs.access(indexPath);
33
+ return {
34
+ folderPath,
35
+ indexPath,
36
+ articleId: `${year}/${month}/${article}`,
37
+ };
38
+ } catch {
39
+ // index.md doesn't exist, skip this folder
40
+ continue;
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ return null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ private async getDirectories(dirPath: string): Promise<string[]> {
54
+ try {
55
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
56
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SEOMetadata } from './SEOMetadata';
3
+
4
+ describe('SEOMetadata', () => {
5
+ it('creates valid metadata', () => {
6
+ const meta = new SEOMetadata('Test Title', 'Test description');
7
+ expect(meta.title).toBe('Test Title');
8
+ expect(meta.description).toBe('Test description');
9
+ });
10
+
11
+ it('truncates title over 60 characters with ellipsis', () => {
12
+ const longTitle = 'A'.repeat(70);
13
+ const meta = new SEOMetadata(longTitle, 'desc');
14
+ expect(meta.title.length).toBe(60);
15
+ expect(meta.title.endsWith('...')).toBe(true);
16
+ });
17
+
18
+ it('truncates description over 155 characters with ellipsis', () => {
19
+ const longDesc = 'A'.repeat(200);
20
+ const meta = new SEOMetadata('title', longDesc);
21
+ expect(meta.description.length).toBe(155);
22
+ expect(meta.description.endsWith('...')).toBe(true);
23
+ });
24
+
25
+ it('accepts exactly 60 character title', () => {
26
+ const title = 'A'.repeat(60);
27
+ const meta = new SEOMetadata(title, 'desc');
28
+ expect(meta.title.length).toBe(60);
29
+ expect(meta.title).not.toContain('...');
30
+ });
31
+
32
+ it('accepts exactly 155 character description', () => {
33
+ const desc = 'A'.repeat(155);
34
+ const meta = new SEOMetadata('title', desc);
35
+ expect(meta.description.length).toBe(155);
36
+ expect(meta.description).not.toContain('...');
37
+ });
38
+
39
+ it('preserves short title without modification', () => {
40
+ const meta = new SEOMetadata('Short', 'desc');
41
+ expect(meta.title).toBe('Short');
42
+ });
43
+
44
+ it('preserves short description without modification', () => {
45
+ const meta = new SEOMetadata('title', 'Short');
46
+ expect(meta.description).toBe('Short');
47
+ });
48
+ });
@@ -0,0 +1,19 @@
1
+ export class SEOMetadata {
2
+ readonly title: string;
3
+ readonly description: string;
4
+
5
+ static readonly MAX_TITLE_LENGTH = 60;
6
+ static readonly MAX_DESCRIPTION_LENGTH = 155;
7
+
8
+ constructor(title: string, description: string) {
9
+ this.title =
10
+ title.length > SEOMetadata.MAX_TITLE_LENGTH
11
+ ? title.slice(0, SEOMetadata.MAX_TITLE_LENGTH - 3) + '...'
12
+ : title;
13
+
14
+ this.description =
15
+ description.length > SEOMetadata.MAX_DESCRIPTION_LENGTH
16
+ ? description.slice(0, SEOMetadata.MAX_DESCRIPTION_LENGTH - 3) + '...'
17
+ : description;
18
+ }
19
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Slug } from './Slug';
3
+
4
+ describe('Slug', () => {
5
+ it('generates lowercase slug from title', () => {
6
+ const slug = new Slug('HTML Accessibility');
7
+ expect(slug.toString()).toBe('html-accessibility');
8
+ });
9
+
10
+ it('handles German umlauts', () => {
11
+ const slug = new Slug('Barrierefreiheit für Anfänger');
12
+ expect(slug.toString()).toBe('barrierefreiheit-fuer-anfaenger');
13
+ });
14
+
15
+ it('handles special characters', () => {
16
+ const slug = new Slug('Forms & Inputs: A Guide!');
17
+ expect(slug.toString()).toBe('forms-inputs-a-guide');
18
+ });
19
+
20
+ it('removes multiple consecutive hyphens', () => {
21
+ const slug = new Slug('Test Multiple Spaces');
22
+ expect(slug.toString()).toBe('test-multiple-spaces');
23
+ });
24
+
25
+ it('trims hyphens from start and end', () => {
26
+ const slug = new Slug('---Title---');
27
+ expect(slug.toString()).toBe('title');
28
+ });
29
+
30
+ it('handles German ß correctly', () => {
31
+ const slug = new Slug('Größe und Maße');
32
+ expect(slug.toString()).toBe('groesse-und-masse');
33
+ });
34
+
35
+ it('compares slugs for equality', () => {
36
+ const slug1 = new Slug('Test Title');
37
+ const slug2 = new Slug('test-title');
38
+ expect(slug1.equals(slug2)).toBe(true);
39
+ });
40
+
41
+ it('returns false for unequal slugs', () => {
42
+ const slug1 = new Slug('First Title');
43
+ const slug2 = new Slug('Second Title');
44
+ expect(slug1.equals(slug2)).toBe(false);
45
+ });
46
+
47
+ it('handles NFD normalization for accented characters', () => {
48
+ const slug = new Slug('Café Résumé');
49
+ expect(slug.toString()).toBe('cafe-resume');
50
+ });
51
+ });
@@ -0,0 +1,33 @@
1
+ export class Slug {
2
+ private readonly value: string;
3
+
4
+ constructor(input: string) {
5
+ this.value = Slug.generate(input);
6
+ }
7
+
8
+ static generate(input: string): string {
9
+ return (
10
+ input
11
+ .toLowerCase()
12
+ // Handle German special characters BEFORE normalization
13
+ .replace(/ä/g, 'ae')
14
+ .replace(/ö/g, 'oe')
15
+ .replace(/ü/g, 'ue')
16
+ .replace(/ß/g, 'ss')
17
+ // Now normalize and remove remaining diacritics
18
+ .normalize('NFD')
19
+ .replace(/[\u0300-\u036f]/g, '')
20
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
21
+ .replace(/^-+|-+$/g, '') // Trim hyphens from start/end
22
+ .replace(/-+/g, '-')
23
+ ); // Replace multiple hyphens with single
24
+ }
25
+
26
+ toString(): string {
27
+ return this.value;
28
+ }
29
+
30
+ equals(other: Slug): boolean {
31
+ return this.value === other.value;
32
+ }
33
+ }
@@ -0,0 +1,4 @@
1
+ export { Slug } from './Slug';
2
+ export { SEOMetadata } from './SEOMetadata';
3
+ export { ArticleFinder } from './ArticleFinder';
4
+ export type { ArticleLocation } from './ArticleFinder';
package/src/index.ts ADDED
@@ -0,0 +1,146 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import { SiteSettings, Prompts, ScheduledPosts } from './db/tables.js';
3
+
4
+ export interface NcaAiCmsPluginOptions {
5
+ contentPath?: string;
6
+ autoPublish?: boolean;
7
+ }
8
+
9
+ export default function ncaAiCms(
10
+ options: NcaAiCmsPluginOptions = {}
11
+ ): AstroIntegration {
12
+ const contentPath = options.contentPath ?? 'nca-ai-cms-content';
13
+ const autoPublish = options.autoPublish ?? process.env.NODE_ENV === 'production';
14
+
15
+ return {
16
+ name: 'nca-ai-cms-astro-plugin',
17
+ hooks: {
18
+ 'astro:db:setup'({ extendDb }: { extendDb: (config: { tables: Record<string, unknown> }) => void }) {
19
+ extendDb({
20
+ tables: { SiteSettings, Prompts, ScheduledPosts },
21
+ });
22
+ },
23
+
24
+ 'astro:config:setup'({ injectRoute, updateConfig, addMiddleware }) {
25
+ addMiddleware({
26
+ entrypoint: 'nca-ai-cms-astro-plugin/middleware.ts',
27
+ order: 'pre',
28
+ });
29
+
30
+ // Virtual module for config sharing
31
+ updateConfig({
32
+ vite: {
33
+ plugins: [
34
+ {
35
+ name: 'nca-ai-cms-virtual-config',
36
+ resolveId(id) {
37
+ if (id === 'virtual:nca-ai-cms/config') {
38
+ return '\0virtual:nca-ai-cms/config';
39
+ }
40
+ },
41
+ load(id) {
42
+ if (id === '\0virtual:nca-ai-cms/config') {
43
+ return `export const contentPath = ${JSON.stringify(contentPath)};\nexport const autoPublish = ${JSON.stringify(autoPublish)};`;
44
+ }
45
+ },
46
+ },
47
+ ],
48
+ },
49
+ });
50
+
51
+ // Inject API routes
52
+ injectRoute({
53
+ pattern: '/api/generate-content',
54
+ entrypoint: 'nca-ai-cms-astro-plugin/api/generate-content.ts',
55
+ });
56
+ injectRoute({
57
+ pattern: '/api/generate-image',
58
+ entrypoint: 'nca-ai-cms-astro-plugin/api/generate-image.ts',
59
+ });
60
+ injectRoute({
61
+ pattern: '/api/save',
62
+ entrypoint: 'nca-ai-cms-astro-plugin/api/save.ts',
63
+ });
64
+ injectRoute({
65
+ pattern: '/api/save-image',
66
+ entrypoint: 'nca-ai-cms-astro-plugin/api/save-image.ts',
67
+ });
68
+ injectRoute({
69
+ pattern: '/api/prompts',
70
+ entrypoint: 'nca-ai-cms-astro-plugin/api/prompts.ts',
71
+ });
72
+ injectRoute({
73
+ pattern: '/api/scheduler',
74
+ entrypoint: 'nca-ai-cms-astro-plugin/api/scheduler.ts',
75
+ });
76
+ injectRoute({
77
+ pattern: '/api/scheduler/generate',
78
+ entrypoint: 'nca-ai-cms-astro-plugin/api/scheduler/generate.ts',
79
+ });
80
+ injectRoute({
81
+ pattern: '/api/scheduler/publish',
82
+ entrypoint: 'nca-ai-cms-astro-plugin/api/scheduler/publish.ts',
83
+ });
84
+ injectRoute({
85
+ pattern: '/api/scheduler/[id]',
86
+ entrypoint: 'nca-ai-cms-astro-plugin/api/scheduler/[id].ts',
87
+ });
88
+ injectRoute({
89
+ pattern: '/api/articles/[id]',
90
+ entrypoint: 'nca-ai-cms-astro-plugin/api/articles/[id].ts',
91
+ });
92
+ injectRoute({
93
+ pattern: '/api/articles/[id]/apply',
94
+ entrypoint: 'nca-ai-cms-astro-plugin/api/articles/[id]/apply.ts',
95
+ });
96
+ injectRoute({
97
+ pattern: '/api/articles/[id]/regenerate-text',
98
+ entrypoint:
99
+ 'nca-ai-cms-astro-plugin/api/articles/[id]/regenerate-text.ts',
100
+ });
101
+ injectRoute({
102
+ pattern: '/api/articles/[id]/regenerate-image',
103
+ entrypoint:
104
+ 'nca-ai-cms-astro-plugin/api/articles/[id]/regenerate-image.ts',
105
+ });
106
+
107
+ // Inject auth routes
108
+ injectRoute({
109
+ pattern: '/api/auth/login',
110
+ entrypoint: 'nca-ai-cms-astro-plugin/api/auth/login.ts',
111
+ });
112
+ injectRoute({
113
+ pattern: '/api/auth/logout',
114
+ entrypoint: 'nca-ai-cms-astro-plugin/api/auth/logout.ts',
115
+ });
116
+ injectRoute({
117
+ pattern: '/api/auth/check',
118
+ entrypoint: 'nca-ai-cms-astro-plugin/api/auth/check.ts',
119
+ });
120
+
121
+ // Inject pages
122
+ injectRoute({
123
+ pattern: '/login',
124
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/login.astro',
125
+ });
126
+ injectRoute({
127
+ pattern: '/editor',
128
+ entrypoint: 'nca-ai-cms-astro-plugin/pages/editor.astro',
129
+ });
130
+ },
131
+
132
+ 'astro:server:start'() {
133
+ if (autoPublish) {
134
+ import('./services/AutoPublisher.js').then(
135
+ ({ startAutoPublisher }) => {
136
+ startAutoPublisher(contentPath);
137
+ }
138
+ ).catch((err) => {
139
+ console.error('[nca-ai-cms] AutoPublisher failed to start:', err);
140
+ });
141
+ }
142
+ },
143
+ },
144
+ };
145
+ }
146
+
@@ -0,0 +1,30 @@
1
+ import { defineMiddleware } from 'astro:middleware';
2
+ import { isPublicPath, isProtectedPath, isAuthenticated } from './utils/authUtils.js';
3
+
4
+ export const onRequest = defineMiddleware(async ({ request, cookies, redirect }, next) => {
5
+ const url = new URL(request.url);
6
+ const { pathname } = url;
7
+
8
+ if (isPublicPath(pathname)) {
9
+ return next();
10
+ }
11
+
12
+ if (!isProtectedPath(pathname)) {
13
+ return next();
14
+ }
15
+
16
+ const authCookie = cookies.get('editor-auth')?.value;
17
+
18
+ if (isAuthenticated(authCookie)) {
19
+ return next();
20
+ }
21
+
22
+ if (pathname.startsWith('/api/')) {
23
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
24
+ status: 401,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ });
27
+ }
28
+
29
+ return redirect('/login', 302);
30
+ });
@@ -0,0 +1,22 @@
1
+ ---
2
+ import Editor from '../components/Editor';
3
+ ---
4
+ <html lang="de">
5
+ <head>
6
+ <meta charset="UTF-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>Content Editor</title>
9
+ <style>
10
+ body { margin: 0; padding: 2rem; background: var(--color-bg, #0a0a0b); color: var(--color-text, #faf9f7); font-family: system-ui, sans-serif; }
11
+ .container { max-width: 1400px; margin: 0 auto; }
12
+ h1 { font-size: 2rem; margin-bottom: 2rem; }
13
+ @keyframes spin { to { transform: rotate(360deg); } }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <div class="container">
18
+ <h1>Content Editor</h1>
19
+ <Editor client:load />
20
+ </div>
21
+ </body>
22
+ </html>