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,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderMarkdown } from "./markdown.js";
3
+
4
+ describe("renderMarkdown", () => {
5
+ it("converts a heading to an h1 tag", async () => {
6
+ const result = await renderMarkdown("# Hello");
7
+ expect(result).toContain("<h1>Hello</h1>");
8
+ });
9
+
10
+ it("converts h2 and h3 headings", async () => {
11
+ const result = await renderMarkdown("## Two\n\n### Three");
12
+ expect(result).toContain("<h2>Two</h2>");
13
+ expect(result).toContain("<h3>Three</h3>");
14
+ });
15
+
16
+ it("converts markdown links to anchor tags", async () => {
17
+ const result = await renderMarkdown("[Example](https://example.com)");
18
+ expect(result).toContain('<a href="https://example.com">Example</a>');
19
+ });
20
+
21
+ it("converts markdown images to img tags", async () => {
22
+ const result = await renderMarkdown(
23
+ "![Alt text](https://example.com/img.png)",
24
+ );
25
+ expect(result).toContain("<img");
26
+ expect(result).toContain('src="https://example.com/img.png"');
27
+ expect(result).toContain('alt="Alt text"');
28
+ });
29
+
30
+ it("strips script tags injected via markdown", async () => {
31
+ const result = await renderMarkdown(
32
+ 'Hello <script>alert("xss")</script> world',
33
+ );
34
+ expect(result).not.toContain("<script>");
35
+ expect(result).not.toContain("alert");
36
+ });
37
+
38
+ it("strips event handlers from inline HTML", async () => {
39
+ const result = await renderMarkdown(
40
+ '<p onclick="alert(1)">Click</p>',
41
+ );
42
+ expect(result).not.toContain("onclick");
43
+ expect(result).toContain("Click");
44
+ });
45
+
46
+ it("returns empty string for empty input", async () => {
47
+ const result = await renderMarkdown("");
48
+ expect(result).toBe("");
49
+ });
50
+
51
+ it("converts blockquotes", async () => {
52
+ const result = await renderMarkdown("> A wise quote");
53
+ expect(result).toContain("<blockquote>");
54
+ expect(result).toContain("A wise quote");
55
+ });
56
+
57
+ it("converts inline formatting (bold, italic, strikethrough)", async () => {
58
+ const result = await renderMarkdown(
59
+ "**bold** *italic* ~~deleted~~",
60
+ );
61
+ expect(result).toContain("<strong>bold</strong>");
62
+ expect(result).toContain("<em>italic</em>");
63
+ expect(result).toContain("<del>deleted</del>");
64
+ });
65
+ });
@@ -0,0 +1,13 @@
1
+ import { marked } from "marked";
2
+ import { sanitizeMarkdownHtml } from "./sanitize.js";
3
+
4
+ /**
5
+ * Converts a markdown string to sanitized HTML.
6
+ *
7
+ * Always use this function instead of calling `marked()` directly --
8
+ * bare marked output is unsanitized and vulnerable to XSS.
9
+ */
10
+ export async function renderMarkdown(markdown: string): Promise<string> {
11
+ const rawHtml = await marked(markdown);
12
+ return sanitizeMarkdownHtml(rawHtml);
13
+ }
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ sanitizeMarkdownHtml,
4
+ escapeJsonLd,
5
+ escapeHtml,
6
+ } from "./sanitize.js";
7
+
8
+ describe("sanitizeMarkdownHtml", () => {
9
+ // --- Safe HTML preservation ---
10
+
11
+ it("preserves basic paragraph content", () => {
12
+ const html = "<p>Hello world</p>";
13
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
14
+ });
15
+
16
+ it("preserves headings h1 through h6", () => {
17
+ for (let i = 1; i <= 6; i++) {
18
+ const html = `<h${i}>Heading ${i}</h${i}>`;
19
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
20
+ }
21
+ });
22
+
23
+ it("preserves inline formatting tags", () => {
24
+ const html =
25
+ "<p><strong>bold</strong> <em>italic</em> <b>b</b> <i>i</i> <del>del</del> <s>strike</s></p>";
26
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
27
+ });
28
+
29
+ it("preserves sub, sup, and mark tags", () => {
30
+ const html = "<p><sub>sub</sub> <sup>sup</sup> <mark>marked</mark></p>";
31
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
32
+ });
33
+
34
+ it("preserves links with permitted attributes", () => {
35
+ const html =
36
+ '<p><a href="https://example.com" title="Example" target="_blank" rel="noopener">link</a></p>';
37
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
38
+ });
39
+
40
+ it("preserves images with permitted attributes", () => {
41
+ const html =
42
+ '<img src="https://example.com/img.png" alt="photo" title="Photo" width="100" height="50" loading="lazy" />';
43
+ expect(sanitizeMarkdownHtml(html)).toContain('src="https://example.com/img.png"');
44
+ expect(sanitizeMarkdownHtml(html)).toContain('alt="photo"');
45
+ expect(sanitizeMarkdownHtml(html)).toContain('loading="lazy"');
46
+ });
47
+
48
+ it("preserves images with data: scheme", () => {
49
+ const html = '<img src="data:image/png;base64,abc123" alt="inline" />';
50
+ expect(sanitizeMarkdownHtml(html)).toContain("data:image/png;base64,abc123");
51
+ });
52
+
53
+ it("preserves code blocks with class attribute", () => {
54
+ const html = '<pre class="language-ts"><code class="language-ts">const x = 1;</code></pre>';
55
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
56
+ });
57
+
58
+ it("preserves unordered and ordered lists", () => {
59
+ const ul = "<ul><li>one</li><li>two</li></ul>";
60
+ const ol = "<ol><li>first</li><li>second</li></ol>";
61
+ expect(sanitizeMarkdownHtml(ul)).toBe(ul);
62
+ expect(sanitizeMarkdownHtml(ol)).toBe(ol);
63
+ });
64
+
65
+ it("preserves definition lists (dl, dt, dd)", () => {
66
+ const html = "<dl><dt>Term</dt><dd>Definition</dd></dl>";
67
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
68
+ });
69
+
70
+ it("preserves table elements with align attribute", () => {
71
+ const html =
72
+ '<table><thead><tr><th align="left">Col</th></tr></thead><tbody><tr><td align="right">Val</td></tr></tbody></table>';
73
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
74
+ });
75
+
76
+ it("preserves blockquote, hr, and br tags", () => {
77
+ const html = "<blockquote><p>Quote</p></blockquote><hr /><br />";
78
+ expect(sanitizeMarkdownHtml(html)).toContain("<blockquote>");
79
+ expect(sanitizeMarkdownHtml(html)).toContain("<hr");
80
+ expect(sanitizeMarkdownHtml(html)).toContain("<br");
81
+ });
82
+
83
+ it("preserves id attribute on any element", () => {
84
+ const html = '<h2 id="section-one">Section One</h2>';
85
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
86
+ });
87
+
88
+ it("preserves div tags", () => {
89
+ const html = "<div><p>Content</p></div>";
90
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
91
+ });
92
+
93
+ it("preserves mailto links", () => {
94
+ const html = '<a href="mailto:test@example.com">Email</a>';
95
+ expect(sanitizeMarkdownHtml(html)).toBe(html);
96
+ });
97
+
98
+ // --- XSS stripping ---
99
+
100
+ it("strips script tags", () => {
101
+ const html = '<p>Hello</p><script>alert("xss")</script>';
102
+ expect(sanitizeMarkdownHtml(html)).toBe("<p>Hello</p>");
103
+ });
104
+
105
+ it("strips event handler attributes", () => {
106
+ const html = '<p onclick="alert(1)">Click me</p>';
107
+ expect(sanitizeMarkdownHtml(html)).toBe("<p>Click me</p>");
108
+ });
109
+
110
+ it("strips javascript: scheme in links", () => {
111
+ const html = '<a href="javascript:alert(1)">XSS</a>';
112
+ expect(sanitizeMarkdownHtml(html)).not.toContain("javascript:");
113
+ });
114
+
115
+ it("strips iframe tags", () => {
116
+ const html = '<iframe src="https://evil.com"></iframe>';
117
+ expect(sanitizeMarkdownHtml(html)).toBe("");
118
+ });
119
+
120
+ it("strips style tags", () => {
121
+ const html = "<style>body { display: none; }</style><p>Text</p>";
122
+ expect(sanitizeMarkdownHtml(html)).toBe("<p>Text</p>");
123
+ });
124
+
125
+ it("strips disallowed attributes from allowed tags", () => {
126
+ const html = '<p style="color:red" data-custom="x">Text</p>';
127
+ expect(sanitizeMarkdownHtml(html)).toBe("<p>Text</p>");
128
+ });
129
+ });
130
+
131
+ describe("escapeJsonLd", () => {
132
+ it("escapes closing script tags", () => {
133
+ const input = '{"name":"</script>"}';
134
+ const result = escapeJsonLd(input);
135
+ expect(result).not.toContain("</script>");
136
+ expect(result).toContain("<\\/script>");
137
+ });
138
+
139
+ it("escapes closing script tags case-insensitively", () => {
140
+ const input = '{"x":"</SCRIPT>"}';
141
+ const result = escapeJsonLd(input);
142
+ expect(result).not.toMatch(/<\/script>/i);
143
+ });
144
+
145
+ it("escapes HTML comments", () => {
146
+ const input = '{"comment":"<!-- hidden -->"}';
147
+ const result = escapeJsonLd(input);
148
+ expect(result).not.toContain("<!--");
149
+ expect(result).toContain("<\\!--");
150
+ });
151
+
152
+ it("returns unchanged string when nothing to escape", () => {
153
+ const input = '{"name":"Safe String"}';
154
+ expect(escapeJsonLd(input)).toBe(input);
155
+ });
156
+ });
157
+
158
+ describe("escapeHtml", () => {
159
+ it("escapes ampersands", () => {
160
+ expect(escapeHtml("a & b")).toBe("a &amp; b");
161
+ });
162
+
163
+ it("escapes less-than and greater-than signs", () => {
164
+ expect(escapeHtml("<div>")).toBe("&lt;div&gt;");
165
+ });
166
+
167
+ it("escapes double quotes", () => {
168
+ expect(escapeHtml('"hello"')).toBe("&quot;hello&quot;");
169
+ });
170
+
171
+ it("escapes single quotes", () => {
172
+ expect(escapeHtml("it's")).toBe("it&#039;s");
173
+ });
174
+
175
+ it("escapes all special characters together", () => {
176
+ expect(escapeHtml('<a href="x">&\'')).toBe(
177
+ "&lt;a href=&quot;x&quot;&gt;&amp;&#039;",
178
+ );
179
+ });
180
+ });
@@ -0,0 +1,98 @@
1
+ import sanitize from "sanitize-html";
2
+
3
+ const MARKDOWN_ALLOWED_TAGS: string[] = [
4
+ "h1",
5
+ "h2",
6
+ "h3",
7
+ "h4",
8
+ "h5",
9
+ "h6",
10
+ "p",
11
+ "blockquote",
12
+ "pre",
13
+ "code",
14
+ "ul",
15
+ "ol",
16
+ "li",
17
+ "table",
18
+ "thead",
19
+ "tbody",
20
+ "tfoot",
21
+ "tr",
22
+ "th",
23
+ "td",
24
+ "caption",
25
+ "colgroup",
26
+ "col",
27
+ "hr",
28
+ "br",
29
+ "div",
30
+ "a",
31
+ "strong",
32
+ "em",
33
+ "b",
34
+ "i",
35
+ "del",
36
+ "s",
37
+ "sub",
38
+ "sup",
39
+ "mark",
40
+ "img",
41
+ "dl",
42
+ "dt",
43
+ "dd",
44
+ ];
45
+
46
+ const MARKDOWN_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
47
+ a: ["href", "title", "target", "rel"],
48
+ img: ["src", "alt", "title", "width", "height", "loading"],
49
+ td: ["align"],
50
+ th: ["align"],
51
+ code: ["class"],
52
+ pre: ["class"],
53
+ "*": ["id"],
54
+ };
55
+
56
+ const MARKDOWN_ALLOWED_SCHEMES: Record<string, string[]> = {
57
+ a: ["http", "https", "mailto"],
58
+ img: ["http", "https", "data"],
59
+ };
60
+
61
+ /**
62
+ * Sanitizes HTML produced from markdown rendering using an allowlist approach.
63
+ * Strips all tags, attributes, and URI schemes not explicitly permitted.
64
+ */
65
+ export function sanitizeMarkdownHtml(dirtyHtml: string): string {
66
+ return sanitize(dirtyHtml, {
67
+ allowedTags: MARKDOWN_ALLOWED_TAGS,
68
+ allowedAttributes: MARKDOWN_ALLOWED_ATTRIBUTES,
69
+ allowedSchemes: MARKDOWN_ALLOWED_SCHEMES.a,
70
+ allowedSchemesByTag: MARKDOWN_ALLOWED_SCHEMES,
71
+ allowProtocolRelative: false,
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Escapes dangerous sequences in a JSON string intended for embedding
77
+ * inside a `<script type="application/ld+json">` block.
78
+ *
79
+ * Prevents premature script tag closure and HTML comment injection.
80
+ */
81
+ export function escapeJsonLd(jsonString: string): string {
82
+ return jsonString
83
+ .replace(/<\/script/gi, "<\\/script")
84
+ .replace(/<!--/g, "<\\!--");
85
+ }
86
+
87
+ /**
88
+ * Encodes special HTML characters as entities.
89
+ * Use for interpolating untrusted text into HTML element content.
90
+ */
91
+ export function escapeHtml(unsafe: string): string {
92
+ return unsafe
93
+ .replace(/&/g, "&amp;")
94
+ .replace(/</g, "&lt;")
95
+ .replace(/>/g, "&gt;")
96
+ .replace(/"/g, "&quot;")
97
+ .replace(/'/g, "&#039;");
98
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "outDir": "./dist",
13
+ "rootDir": "./src",
14
+ "jsx": "react-jsx",
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUncheckedIndexedAccess": true
19
+ },
20
+ "include": ["src/**/*"],
21
+ "exclude": ["dist", "node_modules", "**/*.test.ts"]
22
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'html'],
11
+ exclude: ['node_modules/', '**/*.d.ts'],
12
+ },
13
+ },
14
+ });