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,39 @@
1
+ import { defineTable, column } from "astro:db";
2
+
3
+ const SiteSettings = defineTable({
4
+ columns: {
5
+ key: column.text({ primaryKey: true }),
6
+ value: column.text(),
7
+ updatedAt: column.date({ default: new Date() }),
8
+ },
9
+ });
10
+
11
+ const ScheduledPosts = defineTable({
12
+ columns: {
13
+ id: column.text({ primaryKey: true }),
14
+ input: column.text(),
15
+ inputType: column.text(),
16
+ scheduledDate: column.date(),
17
+ status: column.text({ default: "pending" }),
18
+ generatedTitle: column.text({ optional: true }),
19
+ generatedDescription: column.text({ optional: true }),
20
+ generatedContent: column.text({ optional: true }),
21
+ generatedTags: column.text({ optional: true }),
22
+ generatedImageData: column.text({ optional: true }),
23
+ generatedImageAlt: column.text({ optional: true }),
24
+ publishedPath: column.text({ optional: true }),
25
+ createdAt: column.date({ default: new Date() }),
26
+ },
27
+ });
28
+
29
+ const Prompts = defineTable({
30
+ columns: {
31
+ id: column.text({ primaryKey: true }),
32
+ name: column.text(),
33
+ category: column.text(),
34
+ promptText: column.text(),
35
+ updatedAt: column.date({ default: new Date() }),
36
+ },
37
+ });
38
+
39
+ export { SiteSettings, Prompts, ScheduledPosts };
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Article } from './Article';
3
+
4
+ describe('Article', () => {
5
+ const defaultProps = {
6
+ title: 'HTML Accessibility Grundlagen',
7
+ description: 'Lernen Sie die Grundlagen der HTML-Barrierefreiheit',
8
+ content: '# HTML Accessibility\n\nContent here...',
9
+ date: new Date('2025-12-07'),
10
+ tags: ['accessibility', 'html'],
11
+ };
12
+
13
+ it('creates article with valid props', () => {
14
+ const article = new Article(defaultProps);
15
+ expect(article.title).toBe('HTML Accessibility Grundlagen');
16
+ expect(article.tags).toEqual(['accessibility', 'html']);
17
+ });
18
+
19
+ it('generates SEO-compliant slug from title', () => {
20
+ const article = new Article(defaultProps);
21
+ expect(article.slug.toString()).toBe('html-accessibility-grundlagen');
22
+ });
23
+
24
+ it('generates correct filename', () => {
25
+ const article = new Article(defaultProps);
26
+ expect(article.filename).toBe('html-accessibility-grundlagen.md');
27
+ });
28
+
29
+ it('generates correct folderPath with default contentPath', () => {
30
+ const article = new Article(defaultProps);
31
+ expect(article.folderPath).toBe(
32
+ 'nca-ai-cms-content/2025/12/html-accessibility-grundlagen'
33
+ );
34
+ });
35
+
36
+ it('generates correct filepath with index.md', () => {
37
+ const article = new Article(defaultProps);
38
+ expect(article.filepath).toBe(
39
+ 'nca-ai-cms-content/2025/12/html-accessibility-grundlagen/index.md'
40
+ );
41
+ });
42
+
43
+ it('uses configurable contentPath', () => {
44
+ const article = new Article({
45
+ ...defaultProps,
46
+ contentPath: 'custom-content',
47
+ });
48
+ expect(article.folderPath).toBe(
49
+ 'custom-content/2025/12/html-accessibility-grundlagen'
50
+ );
51
+ expect(article.filepath).toBe(
52
+ 'custom-content/2025/12/html-accessibility-grundlagen/index.md'
53
+ );
54
+ });
55
+
56
+ it('defaults contentPath to nca-ai-cms-content when not provided', () => {
57
+ const article = new Article(defaultProps);
58
+ expect(article.contentPath).toBe('nca-ai-cms-content');
59
+ });
60
+
61
+ it('stores explicit contentPath', () => {
62
+ const article = new Article({
63
+ ...defaultProps,
64
+ contentPath: 'src/content/articles',
65
+ });
66
+ expect(article.contentPath).toBe('src/content/articles');
67
+ });
68
+
69
+ it('extracts year from date', () => {
70
+ const article = new Article(defaultProps);
71
+ expect(article.year).toBe(2025);
72
+ });
73
+
74
+ it('extracts zero-padded month from date', () => {
75
+ const article = new Article({
76
+ ...defaultProps,
77
+ date: new Date('2025-01-15'),
78
+ });
79
+ expect(article.month).toBe('01');
80
+ });
81
+
82
+ it('generates valid frontmatter object', () => {
83
+ const article = new Article(defaultProps);
84
+ const frontmatter = article.toFrontmatter();
85
+
86
+ expect(frontmatter.title).toBe('HTML Accessibility Grundlagen');
87
+ expect(frontmatter.description).toBe(
88
+ 'Lernen Sie die Grundlagen der HTML-Barrierefreiheit'
89
+ );
90
+ expect(frontmatter.date).toBe('2025-12-07');
91
+ expect(frontmatter.tags).toEqual(['accessibility', 'html']);
92
+ });
93
+
94
+ it('generates complete markdown with frontmatter', () => {
95
+ const article = new Article(defaultProps);
96
+ const markdown = article.toMarkdown();
97
+
98
+ expect(markdown).toContain('---');
99
+ expect(markdown).toContain('title: "HTML Accessibility Grundlagen"');
100
+ expect(markdown).toContain('# HTML Accessibility');
101
+ });
102
+
103
+ it('truncates SEO title to 60 chars when title exceeds limit', () => {
104
+ const longTitle = 'A'.repeat(67);
105
+ const article = new Article({
106
+ ...defaultProps,
107
+ title: longTitle,
108
+ });
109
+
110
+ expect(article.seoMetadata.title.length).toBe(60);
111
+ expect(article.seoMetadata.title.endsWith('...')).toBe(true);
112
+ expect(article.title).toBe(longTitle); // Full title preserved
113
+ });
114
+
115
+ it('includes optional image fields with relative path', () => {
116
+ const article = new Article({
117
+ ...defaultProps,
118
+ image: './hero.webp',
119
+ imageAlt: 'Accessibility illustration',
120
+ });
121
+
122
+ const frontmatter = article.toFrontmatter();
123
+ expect(frontmatter.image).toBe('./hero.webp');
124
+ expect(frontmatter.imageAlt).toBe('Accessibility illustration');
125
+ });
126
+
127
+ it('uses relative image path in markdown frontmatter', () => {
128
+ const article = new Article({
129
+ ...defaultProps,
130
+ image: './hero.webp',
131
+ imageAlt: 'Accessibility illustration',
132
+ });
133
+
134
+ const markdown = article.toMarkdown();
135
+ expect(markdown).toContain('image: "./hero.webp"');
136
+ expect(markdown).toContain('imageAlt: "Accessibility illustration"');
137
+ });
138
+ });
@@ -0,0 +1,90 @@
1
+ import { Slug } from '../value-objects/Slug';
2
+ import { SEOMetadata } from '../value-objects/SEOMetadata';
3
+
4
+ export type ArticleProps = {
5
+ title: string;
6
+ description: string;
7
+ content: string;
8
+ date: Date;
9
+ tags: string[];
10
+ image?: string;
11
+ imageAlt?: string;
12
+ contentPath?: string;
13
+ };
14
+
15
+ export class Article {
16
+ readonly title: string;
17
+ readonly description: string;
18
+ readonly content: string;
19
+ readonly date: Date;
20
+ readonly tags: string[];
21
+ readonly slug: Slug;
22
+ readonly seoMetadata: SEOMetadata;
23
+ readonly image?: string;
24
+ readonly imageAlt?: string;
25
+ readonly contentPath: string;
26
+
27
+ constructor(props: ArticleProps) {
28
+ this.title = props.title;
29
+ this.description = props.description;
30
+ this.date = props.date;
31
+ this.tags = props.tags;
32
+ this.slug = new Slug(props.title);
33
+ this.seoMetadata = new SEOMetadata(props.title, props.description);
34
+ this.contentPath = props.contentPath ?? 'nca-ai-cms-content';
35
+
36
+ this.content = props.content;
37
+
38
+ if (props.image !== undefined) {
39
+ this.image = props.image;
40
+ }
41
+ if (props.imageAlt !== undefined) {
42
+ this.imageAlt = props.imageAlt;
43
+ }
44
+ }
45
+
46
+ get filename(): string {
47
+ return `${this.slug.toString()}.md`;
48
+ }
49
+
50
+ get year(): number {
51
+ return this.date.getFullYear();
52
+ }
53
+
54
+ get month(): string {
55
+ return String(this.date.getMonth() + 1).padStart(2, '0');
56
+ }
57
+
58
+ get folderPath(): string {
59
+ return `${this.contentPath}/${this.year}/${this.month}/${this.slug.toString()}`;
60
+ }
61
+
62
+ get filepath(): string {
63
+ return `${this.folderPath}/index.md`;
64
+ }
65
+
66
+ toFrontmatter(): Record<string, unknown> {
67
+ return {
68
+ title: this.title,
69
+ description: this.description,
70
+ date: this.date.toISOString().split('T')[0],
71
+ createdAt: this.date.toISOString(),
72
+ tags: this.tags,
73
+ ...(this.image && { image: this.image }),
74
+ ...(this.imageAlt && { imageAlt: this.imageAlt }),
75
+ };
76
+ }
77
+
78
+ toMarkdown(): string {
79
+ const frontmatter = Object.entries(this.toFrontmatter())
80
+ .map(([key, value]) => {
81
+ if (Array.isArray(value)) {
82
+ return `${key}: ${JSON.stringify(value)}`;
83
+ }
84
+ return `${key}: "${value}"`;
85
+ })
86
+ .join('\n');
87
+
88
+ return `---\n${frontmatter}\n---\n\n${this.content}`;
89
+ }
90
+ }
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ScheduledPost } from './ScheduledPost';
3
+ import type { ScheduledPostInputType } from './ScheduledPost';
4
+
5
+ describe('ScheduledPost', () => {
6
+ const defaultProps = {
7
+ input: 'ARIA Landmarks barrierefreie Navigation',
8
+ inputType: 'keywords' as ScheduledPostInputType,
9
+ scheduledDate: new Date('2026-03-15'),
10
+ };
11
+
12
+ it('creates a scheduled post with pending status', () => {
13
+ const post = ScheduledPost.create(defaultProps);
14
+ expect(post.status).toBe('pending');
15
+ expect(post.input).toBe(defaultProps.input);
16
+ expect(post.inputType).toBe('keywords');
17
+ });
18
+
19
+ it('generates a unique id starting with sp_', () => {
20
+ const post = ScheduledPost.create(defaultProps);
21
+ expect(post.id).toMatch(/^sp_/);
22
+ });
23
+
24
+ it('sets createdAt to current date', () => {
25
+ const before = new Date();
26
+ const post = ScheduledPost.create(defaultProps);
27
+ const after = new Date();
28
+ expect(post.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
29
+ expect(post.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
30
+ });
31
+
32
+ it('detects URL input type', () => {
33
+ expect(ScheduledPost.detectInputType('https://example.com')).toBe('url');
34
+ expect(ScheduledPost.detectInputType('http://example.com')).toBe('url');
35
+ });
36
+
37
+ it('detects keywords input type', () => {
38
+ expect(ScheduledPost.detectInputType('ARIA Landmarks')).toBe('keywords');
39
+ expect(ScheduledPost.detectInputType('barrierefreie Navigation')).toBe(
40
+ 'keywords'
41
+ );
42
+ });
43
+
44
+ it('can transition from pending to generated', () => {
45
+ const post = ScheduledPost.create(defaultProps);
46
+ expect(post.canGenerate()).toBe(true);
47
+ expect(post.canPublish()).toBe(false);
48
+ });
49
+
50
+ it('can regenerate when already generated', () => {
51
+ const post = ScheduledPost.fromDB({
52
+ ...defaultProps,
53
+ id: 'sp_1',
54
+ status: 'generated',
55
+ createdAt: new Date(),
56
+ });
57
+ expect(post.canGenerate()).toBe(true);
58
+ expect(post.canPublish()).toBe(true);
59
+ });
60
+
61
+ it('cannot generate or publish when already published', () => {
62
+ const post = ScheduledPost.fromDB({
63
+ ...defaultProps,
64
+ id: 'sp_1',
65
+ status: 'published',
66
+ createdAt: new Date(),
67
+ });
68
+ expect(post.canGenerate()).toBe(false);
69
+ expect(post.canPublish()).toBe(false);
70
+ });
71
+
72
+ it('can delete when not published', () => {
73
+ const pending = ScheduledPost.create(defaultProps);
74
+ expect(pending.canDelete()).toBe(true);
75
+
76
+ const generated = ScheduledPost.fromDB({
77
+ ...defaultProps,
78
+ id: 'sp_1',
79
+ status: 'generated',
80
+ createdAt: new Date(),
81
+ });
82
+ expect(generated.canDelete()).toBe(true);
83
+
84
+ const published = ScheduledPost.fromDB({
85
+ ...defaultProps,
86
+ id: 'sp_1',
87
+ status: 'published',
88
+ createdAt: new Date(),
89
+ });
90
+ expect(published.canDelete()).toBe(false);
91
+ });
92
+
93
+ it('determines if a post is due for auto-publish', () => {
94
+ const yesterday = new Date();
95
+ yesterday.setDate(yesterday.getDate() - 1);
96
+
97
+ const post = ScheduledPost.fromDB({
98
+ ...defaultProps,
99
+ id: 'sp_1',
100
+ scheduledDate: yesterday,
101
+ status: 'generated',
102
+ createdAt: new Date(),
103
+ });
104
+ expect(post.isDue()).toBe(true);
105
+ });
106
+
107
+ it('is not due if date is in the future', () => {
108
+ const nextWeek = new Date();
109
+ nextWeek.setDate(nextWeek.getDate() + 7);
110
+
111
+ const post = ScheduledPost.fromDB({
112
+ ...defaultProps,
113
+ id: 'sp_1',
114
+ scheduledDate: nextWeek,
115
+ status: 'generated',
116
+ createdAt: new Date(),
117
+ });
118
+ expect(post.isDue()).toBe(false);
119
+ });
120
+
121
+ it('is not due if status is pending', () => {
122
+ const yesterday = new Date();
123
+ yesterday.setDate(yesterday.getDate() - 1);
124
+
125
+ const post = ScheduledPost.fromDB({
126
+ ...defaultProps,
127
+ id: 'sp_1',
128
+ scheduledDate: yesterday,
129
+ status: 'pending',
130
+ createdAt: new Date(),
131
+ });
132
+ expect(post.isDue()).toBe(false);
133
+ });
134
+
135
+ it('computes year and month from scheduledDate', () => {
136
+ const post = ScheduledPost.create({
137
+ ...defaultProps,
138
+ scheduledDate: new Date('2026-03-15'),
139
+ });
140
+ expect(post.scheduledYear).toBe(2026);
141
+ expect(post.scheduledMonth).toBe('03');
142
+ });
143
+
144
+ it('reconstructs from DB row', () => {
145
+ const post = ScheduledPost.fromDB({
146
+ id: 'sp_123',
147
+ input: 'test keywords',
148
+ inputType: 'keywords',
149
+ scheduledDate: new Date('2026-04-01'),
150
+ status: 'generated',
151
+ generatedTitle: 'Test Title',
152
+ generatedDescription: 'Test desc',
153
+ generatedContent: '# Test',
154
+ generatedTags: '["tag1","tag2"]',
155
+ generatedImageData: 'base64data',
156
+ generatedImageAlt: 'alt text',
157
+ publishedPath: null,
158
+ createdAt: new Date('2026-01-01'),
159
+ });
160
+
161
+ expect(post.id).toBe('sp_123');
162
+ expect(post.status).toBe('generated');
163
+ expect(post.generatedTitle).toBe('Test Title');
164
+ expect(post.parsedTags).toEqual(['tag1', 'tag2']);
165
+ });
166
+
167
+ it('returns empty array for null tags', () => {
168
+ const post = ScheduledPost.create(defaultProps);
169
+ expect(post.parsedTags).toEqual([]);
170
+ });
171
+
172
+ describe('isDue() timezone safety', () => {
173
+ it('is due when scheduled for today (UTC)', () => {
174
+ const today = new Date();
175
+ const post = ScheduledPost.fromDB({
176
+ ...defaultProps,
177
+ id: 'sp_tz1',
178
+ scheduledDate: today,
179
+ status: 'generated',
180
+ createdAt: new Date(),
181
+ });
182
+ expect(post.isDue()).toBe(true);
183
+ });
184
+
185
+ it('is due when scheduled date is an ISO string parsed as UTC', () => {
186
+ const yesterday = new Date();
187
+ yesterday.setDate(yesterday.getDate() - 1);
188
+ const isoDate = yesterday.toISOString().slice(0, 10);
189
+
190
+ const post = ScheduledPost.fromDB({
191
+ ...defaultProps,
192
+ id: 'sp_tz2',
193
+ scheduledDate: new Date(isoDate),
194
+ status: 'generated',
195
+ createdAt: new Date(),
196
+ });
197
+ expect(post.isDue()).toBe(true);
198
+ });
199
+
200
+ it('is not due when scheduled for tomorrow', () => {
201
+ const tomorrow = new Date();
202
+ tomorrow.setDate(tomorrow.getDate() + 1);
203
+
204
+ const post = ScheduledPost.fromDB({
205
+ ...defaultProps,
206
+ id: 'sp_tz3',
207
+ scheduledDate: tomorrow,
208
+ status: 'generated',
209
+ createdAt: new Date(),
210
+ });
211
+ expect(post.isDue()).toBe(false);
212
+ });
213
+
214
+ it('is not due when status is published', () => {
215
+ const yesterday = new Date();
216
+ yesterday.setDate(yesterday.getDate() - 1);
217
+
218
+ const post = ScheduledPost.fromDB({
219
+ ...defaultProps,
220
+ id: 'sp_tz4',
221
+ scheduledDate: yesterday,
222
+ status: 'published',
223
+ createdAt: new Date(),
224
+ });
225
+ expect(post.isDue()).toBe(false);
226
+ });
227
+ });
228
+ });
@@ -0,0 +1,152 @@
1
+ export type ScheduledPostStatus = 'pending' | 'generated' | 'published';
2
+ export type ScheduledPostInputType = 'url' | 'keywords';
3
+
4
+ export interface ScheduledPostProps {
5
+ input: string;
6
+ inputType: ScheduledPostInputType;
7
+ scheduledDate: Date;
8
+ }
9
+
10
+ export interface ScheduledPostDBRow {
11
+ id: string;
12
+ input: string;
13
+ inputType: string;
14
+ scheduledDate: Date;
15
+ status: string;
16
+ generatedTitle?: string | null;
17
+ generatedDescription?: string | null;
18
+ generatedContent?: string | null;
19
+ generatedTags?: string | null;
20
+ generatedImageData?: string | null;
21
+ generatedImageAlt?: string | null;
22
+ publishedPath?: string | null;
23
+ createdAt: Date;
24
+ }
25
+
26
+ export class ScheduledPost {
27
+ readonly id: string;
28
+ readonly input: string;
29
+ readonly inputType: ScheduledPostInputType;
30
+ readonly scheduledDate: Date;
31
+ readonly status: ScheduledPostStatus;
32
+ readonly generatedTitle: string | null | undefined;
33
+ readonly generatedDescription: string | null | undefined;
34
+ readonly generatedContent: string | null | undefined;
35
+ readonly generatedTags: string | null | undefined;
36
+ readonly generatedImageData: string | null | undefined;
37
+ readonly generatedImageAlt: string | null | undefined;
38
+ readonly publishedPath: string | null | undefined;
39
+ readonly createdAt: Date;
40
+
41
+ private constructor(
42
+ id: string,
43
+ input: string,
44
+ inputType: ScheduledPostInputType,
45
+ scheduledDate: Date,
46
+ status: ScheduledPostStatus,
47
+ createdAt: Date,
48
+ generatedTitle: string | null | undefined,
49
+ generatedDescription: string | null | undefined,
50
+ generatedContent: string | null | undefined,
51
+ generatedTags: string | null | undefined,
52
+ generatedImageData: string | null | undefined,
53
+ generatedImageAlt: string | null | undefined,
54
+ publishedPath: string | null | undefined
55
+ ) {
56
+ this.id = id;
57
+ this.input = input;
58
+ this.inputType = inputType;
59
+ this.scheduledDate = scheduledDate;
60
+ this.status = status;
61
+ this.createdAt = createdAt;
62
+ this.generatedTitle = generatedTitle;
63
+ this.generatedDescription = generatedDescription;
64
+ this.generatedContent = generatedContent;
65
+ this.generatedTags = generatedTags;
66
+ this.generatedImageData = generatedImageData;
67
+ this.generatedImageAlt = generatedImageAlt;
68
+ this.publishedPath = publishedPath;
69
+ }
70
+
71
+ static create(props: ScheduledPostProps): ScheduledPost {
72
+ const id = `sp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
73
+ return new ScheduledPost(
74
+ id,
75
+ props.input,
76
+ props.inputType,
77
+ props.scheduledDate,
78
+ 'pending',
79
+ new Date(),
80
+ undefined,
81
+ undefined,
82
+ undefined,
83
+ undefined,
84
+ undefined,
85
+ undefined,
86
+ undefined
87
+ );
88
+ }
89
+
90
+ static fromDB(row: ScheduledPostDBRow): ScheduledPost {
91
+ return new ScheduledPost(
92
+ row.id,
93
+ row.input,
94
+ row.inputType as ScheduledPostInputType,
95
+ row.scheduledDate,
96
+ row.status as ScheduledPostStatus,
97
+ row.createdAt,
98
+ row.generatedTitle,
99
+ row.generatedDescription,
100
+ row.generatedContent,
101
+ row.generatedTags,
102
+ row.generatedImageData,
103
+ row.generatedImageAlt,
104
+ row.publishedPath
105
+ );
106
+ }
107
+
108
+ static detectInputType(input: string): ScheduledPostInputType {
109
+ try {
110
+ new URL(input);
111
+ return 'url';
112
+ } catch {
113
+ return 'keywords';
114
+ }
115
+ }
116
+
117
+ canGenerate(): boolean {
118
+ return this.status === 'pending' || this.status === 'generated';
119
+ }
120
+
121
+ canPublish(): boolean {
122
+ return this.status === 'generated';
123
+ }
124
+
125
+ canDelete(): boolean {
126
+ return this.status !== 'published';
127
+ }
128
+
129
+ isDue(): boolean {
130
+ if (this.status !== 'generated') return false;
131
+ const nowDate = new Date().toISOString().slice(0, 10);
132
+ const scheduledDate = new Date(this.scheduledDate).toISOString().slice(0, 10);
133
+ return scheduledDate <= nowDate;
134
+ }
135
+
136
+ get scheduledYear(): number {
137
+ return this.scheduledDate.getFullYear();
138
+ }
139
+
140
+ get scheduledMonth(): string {
141
+ return String(this.scheduledDate.getMonth() + 1).padStart(2, '0');
142
+ }
143
+
144
+ get parsedTags(): string[] {
145
+ if (!this.generatedTags) return [];
146
+ try {
147
+ return JSON.parse(this.generatedTags);
148
+ } catch {
149
+ return [];
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Source } from './Source';
3
+
4
+ describe('Source', () => {
5
+ it('creates source from valid HTTPS URL', () => {
6
+ const source = new Source(
7
+ 'https://developer.mozilla.org/en-US/docs/Web/HTML'
8
+ );
9
+ expect(source.url).toBe(
10
+ 'https://developer.mozilla.org/en-US/docs/Web/HTML'
11
+ );
12
+ });
13
+
14
+ it('extracts domain from URL', () => {
15
+ const source = new Source(
16
+ 'https://developer.mozilla.org/en-US/docs/Web/HTML'
17
+ );
18
+ expect(source.domain).toBe('developer.mozilla.org');
19
+ });
20
+
21
+ it('throws error for HTTP URL', () => {
22
+ expect(() => new Source('http://example.com')).toThrow(
23
+ 'Only HTTPS URLs are allowed'
24
+ );
25
+ });
26
+
27
+ it('throws error for invalid URL', () => {
28
+ expect(() => new Source('not-a-url')).toThrow('Invalid URL');
29
+ });
30
+
31
+ it('identifies MDN URLs', () => {
32
+ const source = new Source(
33
+ 'https://developer.mozilla.org/en-US/docs/Web/HTML'
34
+ );
35
+ expect(source.isMDN()).toBe(true);
36
+ });
37
+
38
+ it('identifies W3C URLs', () => {
39
+ const source = new Source('https://www.w3.org/WAI/WCAG21/quickref/');
40
+ expect(source.isW3C()).toBe(true);
41
+ });
42
+
43
+ it('returns false for non-MDN URL', () => {
44
+ const source = new Source('https://example.com');
45
+ expect(source.isMDN()).toBe(false);
46
+ });
47
+
48
+ it('returns false for non-W3C URL', () => {
49
+ const source = new Source('https://example.com');
50
+ expect(source.isW3C()).toBe(false);
51
+ });
52
+
53
+ it('converts to string', () => {
54
+ const source = new Source('https://developer.mozilla.org');
55
+ expect(source.toString()).toBe('https://developer.mozilla.org');
56
+ });
57
+ });