diff --git a/src/lib/blog/BlogController.test.ts b/src/lib/blog/BlogController.test.ts index a9d0810..63b8006 100644 --- a/src/lib/blog/BlogController.test.ts +++ b/src/lib/blog/BlogController.test.ts @@ -1,6 +1,7 @@ -import { describe, it, beforeEach, beforeAll, expect, afterEach } from 'vitest'; +import { describe, it, beforeEach, afterAll, beforeAll, expect, afterEach } from 'vitest'; import { BlogController } from './BlogController.js'; import { MarkdownRepository } from './markdown-repository.js'; +import { exampleMarkdown, exampleMarkdownFrontmatter } from './test-fixtures/example-markdown.js'; describe(`BlogController`, () => { let controller: BlogController; @@ -74,16 +75,32 @@ describe(`BlogController`, () => { }); describe(`Creating a new blog post as a file`, () => { - let fileName: string; + const thisDirectory = import.meta.url + .replace('file://', '') + .split('/') + .filter((part) => part !== 'BlogController.test.ts') + .join('/'); + const fileName = `${thisDirectory}/test-fixtures/test-blog-controller.md`; let controller: BlogController; beforeEach(async () => { - fileName = 'some-made-up-blog-post.md'; controller = await BlogController.singleton(); }); - afterEach(async () => { - await controller.markdownRepository.deleteBlogPostFile(fileName); + afterAll(async () => { + await controller.markdownRepository.deleteBlogPostMarkdownFile(fileName); + }); + + it(`should create a new file in the content folder`, async () => { + // GIVEN + const markdownContent = exampleMarkdown; + + // WHEN + const blogPost = await controller.createBlogPost(fileName, markdownContent); + + // THEN + expect(blogPost).not.toBeNull(); + expect(blogPost.html).not.toBeNull(); }); }); }); diff --git a/src/lib/blog/BlogController.ts b/src/lib/blog/BlogController.ts index 2a6a1af..3d40093 100644 --- a/src/lib/blog/BlogController.ts +++ b/src/lib/blog/BlogController.ts @@ -28,7 +28,7 @@ interface BookReviewListItem { } export class BlogController { - private readonly _markdownRepository: MarkdownRepository; + private _markdownRepository: MarkdownRepository; static async singleton(): Promise { const markdownRepository = await MarkdownRepository.singleton(); @@ -43,6 +43,15 @@ export class BlogController { return this._markdownRepository; } + async createBlogPost(resolvedFileName: string, markdownContent: string): Promise { + const createdBlogPost = await this._markdownRepository.createBlogPostMarkdownFile( + resolvedFileName, + markdownContent + ); + this._markdownRepository = await MarkdownRepository.singleton(); + return createdBlogPost; + } + async getAllBlogPosts(): Promise> { const blogPosts = await this._markdownRepository.blogPosts; diff --git a/src/lib/blog/BlogPost.test.ts b/src/lib/blog/BlogPost.test.ts index 36e5a97..a98e6e8 100644 --- a/src/lib/blog/BlogPost.test.ts +++ b/src/lib/blog/BlogPost.test.ts @@ -29,6 +29,7 @@ describe('BlogPost', () => { date: new Date('2022-01-01T00:00Z'), slug: 'test-slug', markdownContent: 'Test Content', + fileName: `the-file-name.md`, }); // WHEN @@ -41,6 +42,7 @@ describe('BlogPost', () => { .withDate(new Date('2022-01-01T00:00Z')) .withSlug('test-slug') .withMarkdownContent('Test Content') + .withFileName(`the-file-name.md`) .constructAndThenBuild(); expect(blogPost).toStrictEqual(expectedBlogPost); diff --git a/src/lib/blog/BlogPost.ts b/src/lib/blog/BlogPost.ts index 894d98f..4a10672 100644 --- a/src/lib/blog/BlogPost.ts +++ b/src/lib/blog/BlogPost.ts @@ -15,6 +15,7 @@ interface BlogPostParams { author: string; slug: string; markdownContent: string; + fileName: string; // excluding any leading `..` } export class BlogPost { @@ -23,6 +24,7 @@ export class BlogPost { readonly author: string; readonly slug: string; readonly markdownContent: string; + readonly fileName: string; private _html: string | null = null; private _excerpt: string | null = null; @@ -33,6 +35,7 @@ export class BlogPost { this.author = params.author; this.slug = params.slug; this.markdownContent = params.markdownContent; + this.fileName = params.fileName.split(`/`)[-1]; } get html(): string | null { diff --git a/src/lib/blog/markdown-repository.test.ts b/src/lib/blog/markdown-repository.test.ts index 2bbfecc..f321d5a 100644 --- a/src/lib/blog/markdown-repository.test.ts +++ b/src/lib/blog/markdown-repository.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { MarkdownRepository } from './markdown-repository.js'; +import { resolve, dirname } from 'path'; import { MarkdownFile } from './MarkdownFile.js'; import { aBlogPost } from './test-builders/blog-post-builder.js'; @@ -31,6 +32,7 @@ describe(`Blog MarkdownRepository`, () => { .withSlug('2023-02-01-test') .withTitle('Test Blog Post') .withMarkdownContent(testMarkdownContent) + .withFileName('blog-2023-02-01-test.md') .constructAndThenBuild(); // WHEN @@ -68,9 +70,13 @@ describe(`Blog MarkdownRepository`, () => { describe(`Deleting markdown files`, () => { let repository: MarkdownRepository; + const currentDirectory = dirname(import.meta.url.replace('file://', '')); beforeAll(async () => { repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport); + + const resolvedPath = resolve(`${currentDirectory}/test-fixtures/test-file.md`); + await repository.createBlogPostMarkdownFile(resolvedPath, testMarkdownContent); }); it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => { @@ -78,9 +84,17 @@ describe(`Blog MarkdownRepository`, () => { const theFileName = 'non-existent-file.md'; // WHEN/THEN - expect(() => repository.deleteBlogPostMarkdownFile(theFileName)).toThrowError( - `Cannot delete file ${theFileName} as it does not exist` + expect(async () => repository.deleteBlogPostMarkdownFile(theFileName)).rejects.toThrowError( + `File 'non-existent-file.md' not found.` ); }); + + it(`should successfully delete a file when it does exist`, async () => { + // GIVEN + const fileName = `${currentDirectory}/test-fixtures/test-file.md`; + + // WHEN + await repository.deleteBlogPostMarkdownFile(fileName); + }); }); }); diff --git a/src/lib/blog/markdown-repository.ts b/src/lib/blog/markdown-repository.ts index d2f196a..89cdde7 100644 --- a/src/lib/blog/markdown-repository.ts +++ b/src/lib/blog/markdown-repository.ts @@ -1,5 +1,5 @@ import { resolve } from 'path'; -import { open } from 'fs'; +import { writeFile, unlink, existsSync } from 'fs'; import { BlogPost } from './BlogPost.js'; import { MarkdownFile } from './MarkdownFile.js'; @@ -65,6 +65,7 @@ export class MarkdownRepository { slug: markdownFile.frontmatter.slug, author: markdownFile.frontmatter.author, date: markdownFile.frontmatter.date, + fileName: filename, }); fileImports = [...fileImports, markdownFile]; @@ -126,10 +127,64 @@ export class MarkdownRepository { return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null; } - async deleteBlogPostMarkdownFile(fileName: string): Promise { - const file = this.blogPosts.blogPosts.find((blogPost) => blogPost.fileName === fileName); - if (file) { - const file = resolve(blogPostMarkdownDirectory, fileName); + async createBlogPostMarkdownFile(resolvdePath: string, contents: string): Promise { + return new Promise((resolve, reject) => { + writeFile(resolvdePath, contents, (err) => { + if (err) { + console.error({ + message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvdePath}`, + err, + error: JSON.stringify(err), + }); + reject(err); + } + + resolve(); + }); + }) + .then(() => { + const markdownFile = new MarkdownFile({ + fileName: resolvdePath, + content: contents, + }); + + const blogPost = new BlogPost({ + markdownContent: markdownFile.content, + title: markdownFile.frontmatter.title, + slug: markdownFile.frontmatter.slug, + author: markdownFile.frontmatter.author, + date: markdownFile.frontmatter.date, + fileName: resolvdePath, + }); + + return blogPost; + }) + .then(async (blogPost: BlogPost) => { + blogPost.build(); + return blogPost; + }); + } + + async deleteBlogPostMarkdownFile(resolvedFilePath: string): Promise { + const isPresent = existsSync(resolvedFilePath); + + if (!isPresent) { + throw `Sausages File '${resolvedFilePath}' not found.`; } + + return new Promise((resolve, reject) => { + unlink(resolvedFilePath, (err) => { + if (err) { + console.error({ + message: `deleteBlogPostMarkdownFile: Caught error while deleting file ${resolvedFilePath}`, + err, + error: JSON.stringify(err), + }); + reject(err); + } + + resolve(); + }); + }); } } diff --git a/src/lib/blog/test-builders/blog-post-builder.ts b/src/lib/blog/test-builders/blog-post-builder.ts index 49dbf1d..8e91af9 100644 --- a/src/lib/blog/test-builders/blog-post-builder.ts +++ b/src/lib/blog/test-builders/blog-post-builder.ts @@ -5,6 +5,7 @@ class BlogPostBuilder { private _author = 'default author'; private _date = new Date('2022-01-01T00:00Z'); private _slug = 'default-slug'; + private _fileName = 'default-file-name.md'; private _markdownContent = 'default markdown content'; @@ -18,6 +19,11 @@ class BlogPostBuilder { return this; } + withFileName(fileName: string): BlogPostBuilder { + this._fileName = fileName; + return this; + } + withAuthor(author: string): BlogPostBuilder { this._author = author; return this; @@ -46,6 +52,7 @@ class BlogPostBuilder { author: this._author, date: this._date, slug: this._slug, + fileName: this._fileName, }); } } diff --git a/src/lib/blog/test-fixtures/example-markdown.ts b/src/lib/blog/test-fixtures/example-markdown.ts new file mode 100644 index 0000000..d870dd4 --- /dev/null +++ b/src/lib/blog/test-fixtures/example-markdown.ts @@ -0,0 +1,20 @@ +export const exampleMarkdown = `--- +title: "Test Blog Post" +author: "Thomas Wilson" +date: 2023-02-01T08:00:00Z +slug: "2023-02-01-test" +draft: false +--- + +This is a blog post written in markdown. + +This is a [link](http://www.bbc.co.uk) +`; + +export const exampleMarkdownFrontmatter = { + title: 'Test Blog Post', + author: 'Thomas Wilson', + date: new Date('2023-02-01T08:00:00Z'), + slug: '2023-02-01-test', + draft: false, +}; diff --git a/src/routes/api/blog/new.json/+server.ts b/src/routes/api/blog/new.json/+server.ts index 5cfb46a..0b232a1 100644 --- a/src/routes/api/blog/new.json/+server.ts +++ b/src/routes/api/blog/new.json/+server.ts @@ -1,8 +1,56 @@ -import { json } from '@sveltejs/kit'; +import { resolve } from 'path'; +import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; +import { BlogController } from '../../../../lib/blog/BlogController.js'; +import { dump as dumpYaml } from 'js-yaml'; -export const POST: RequestHandler = async ({ getClientAddress }) => { +const thisDirectory = import.meta.url + .replace('file://', '') + .split('/') + .filter((part) => part !== '+server.ts') + .join('/'); + +export const POST: RequestHandler = async ({ getClientAddress, request }) => { const address = await getClientAddress(); + let fileName: string; + let markdownContent: string; + let title: string; + let date: string; + let slug: string; + let author: string; + + try { + const requestBody = await request.json(); + fileName = requestBody.fileName; + markdownContent = requestBody.markdownContent; + title = requestBody.title; + date = requestBody.date; + slug = requestBody.slug; + author = requestBody.author; + } catch (e: any) { + console.log(`Caught error destructuring request body`); + console.error(e); + throw error(400, 'Error in request body.'); + } + + if ([fileName, markdownContent, title, date, slug, author].includes(undefined)) { + throw error(400, `Missing parameters.`); + } else if (address !== '127.0.0.1') { + throw error(403, `Forbidden.`); + } + + const controller = await BlogController.singleton(); + + const worryinglyManualFrontMatter = [`---`, dumpYaml({ title, date: new Date(date), slug, author }), `---`].join( + `\n` + ); + const escapedMarkdown = markdownContent.replaceAll(/\\n/g, '\n'); + + const contentWithFrontmatter = [worryinglyManualFrontMatter, escapedMarkdown].join(`\n`); + + const resolvedFileName = resolve(thisDirectory, `../../../../content/blog/${fileName}`); + + await controller.createBlogPost(resolvedFileName, contentWithFrontmatter); console.log({ address }); return json({ address }); }; diff --git a/src/routes/blog/new/+page.svelte b/src/routes/blog/new/+page.svelte index 6bf44d2..ba0f8eb 100644 --- a/src/routes/blog/new/+page.svelte +++ b/src/routes/blog/new/+page.svelte @@ -1,39 +1,120 @@

New Blog Post

-
- - -
-
- - -
+
+
+ + +
+
+ + +
-
- - -
+
+ + +
-
- -