BlogEngine: Create Blog Posts locally with endpoint

This commit is contained in:
Thomas 2023-02-12 22:13:29 +00:00
parent 57dd0a017e
commit 3e2053e884
10 changed files with 305 additions and 36 deletions

View file

@ -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 { BlogController } from './BlogController.js';
import { MarkdownRepository } from './markdown-repository.js'; import { MarkdownRepository } from './markdown-repository.js';
import { exampleMarkdown, exampleMarkdownFrontmatter } from './test-fixtures/example-markdown.js';
describe(`BlogController`, () => { describe(`BlogController`, () => {
let controller: BlogController; let controller: BlogController;
@ -74,16 +75,32 @@ describe(`BlogController`, () => {
}); });
describe(`Creating a new blog post as a file`, () => { 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; let controller: BlogController;
beforeEach(async () => { beforeEach(async () => {
fileName = 'some-made-up-blog-post.md';
controller = await BlogController.singleton(); controller = await BlogController.singleton();
}); });
afterEach(async () => { afterAll(async () => {
await controller.markdownRepository.deleteBlogPostFile(fileName); 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();
}); });
}); });
}); });

View file

@ -28,7 +28,7 @@ interface BookReviewListItem {
} }
export class BlogController { export class BlogController {
private readonly _markdownRepository: MarkdownRepository; private _markdownRepository: MarkdownRepository;
static async singleton(): Promise<BlogController> { static async singleton(): Promise<BlogController> {
const markdownRepository = await MarkdownRepository.singleton(); const markdownRepository = await MarkdownRepository.singleton();
@ -43,6 +43,15 @@ export class BlogController {
return this._markdownRepository; return this._markdownRepository;
} }
async createBlogPost(resolvedFileName: string, markdownContent: string): Promise<BlogPost> {
const createdBlogPost = await this._markdownRepository.createBlogPostMarkdownFile(
resolvedFileName,
markdownContent
);
this._markdownRepository = await MarkdownRepository.singleton();
return createdBlogPost;
}
async getAllBlogPosts(): Promise<Array<BlogPostListItem | BookReviewListItem>> { async getAllBlogPosts(): Promise<Array<BlogPostListItem | BookReviewListItem>> {
const blogPosts = await this._markdownRepository.blogPosts; const blogPosts = await this._markdownRepository.blogPosts;

View file

@ -29,6 +29,7 @@ describe('BlogPost', () => {
date: new Date('2022-01-01T00:00Z'), date: new Date('2022-01-01T00:00Z'),
slug: 'test-slug', slug: 'test-slug',
markdownContent: 'Test Content', markdownContent: 'Test Content',
fileName: `the-file-name.md`,
}); });
// WHEN // WHEN
@ -41,6 +42,7 @@ describe('BlogPost', () => {
.withDate(new Date('2022-01-01T00:00Z')) .withDate(new Date('2022-01-01T00:00Z'))
.withSlug('test-slug') .withSlug('test-slug')
.withMarkdownContent('Test Content') .withMarkdownContent('Test Content')
.withFileName(`the-file-name.md`)
.constructAndThenBuild(); .constructAndThenBuild();
expect(blogPost).toStrictEqual(expectedBlogPost); expect(blogPost).toStrictEqual(expectedBlogPost);

View file

@ -15,6 +15,7 @@ interface BlogPostParams {
author: string; author: string;
slug: string; slug: string;
markdownContent: string; markdownContent: string;
fileName: string; // excluding any leading `..`
} }
export class BlogPost { export class BlogPost {
@ -23,6 +24,7 @@ export class BlogPost {
readonly author: string; readonly author: string;
readonly slug: string; readonly slug: string;
readonly markdownContent: string; readonly markdownContent: string;
readonly fileName: string;
private _html: string | null = null; private _html: string | null = null;
private _excerpt: string | null = null; private _excerpt: string | null = null;
@ -33,6 +35,7 @@ export class BlogPost {
this.author = params.author; this.author = params.author;
this.slug = params.slug; this.slug = params.slug;
this.markdownContent = params.markdownContent; this.markdownContent = params.markdownContent;
this.fileName = params.fileName.split(`/`)[-1];
} }
get html(): string | null { get html(): string | null {

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll } from 'vitest'; import { describe, it, expect, beforeAll } from 'vitest';
import { MarkdownRepository } from './markdown-repository.js'; import { MarkdownRepository } from './markdown-repository.js';
import { resolve, dirname } from 'path';
import { MarkdownFile } from './MarkdownFile.js'; import { MarkdownFile } from './MarkdownFile.js';
import { aBlogPost } from './test-builders/blog-post-builder.js'; import { aBlogPost } from './test-builders/blog-post-builder.js';
@ -31,6 +32,7 @@ describe(`Blog MarkdownRepository`, () => {
.withSlug('2023-02-01-test') .withSlug('2023-02-01-test')
.withTitle('Test Blog Post') .withTitle('Test Blog Post')
.withMarkdownContent(testMarkdownContent) .withMarkdownContent(testMarkdownContent)
.withFileName('blog-2023-02-01-test.md')
.constructAndThenBuild(); .constructAndThenBuild();
// WHEN // WHEN
@ -68,9 +70,13 @@ describe(`Blog MarkdownRepository`, () => {
describe(`Deleting markdown files`, () => { describe(`Deleting markdown files`, () => {
let repository: MarkdownRepository; let repository: MarkdownRepository;
const currentDirectory = dirname(import.meta.url.replace('file://', ''));
beforeAll(async () => { beforeAll(async () => {
repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport); 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 () => { 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'; const theFileName = 'non-existent-file.md';
// WHEN/THEN // WHEN/THEN
expect(() => repository.deleteBlogPostMarkdownFile(theFileName)).toThrowError( expect(async () => repository.deleteBlogPostMarkdownFile(theFileName)).rejects.toThrowError(
`Cannot delete file ${theFileName} as it does not exist` `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);
});
}); });
}); });

View file

@ -1,5 +1,5 @@
import { resolve } from 'path'; import { resolve } from 'path';
import { open } from 'fs'; import { writeFile, unlink, existsSync } from 'fs';
import { BlogPost } from './BlogPost.js'; import { BlogPost } from './BlogPost.js';
import { MarkdownFile } from './MarkdownFile.js'; import { MarkdownFile } from './MarkdownFile.js';
@ -65,6 +65,7 @@ export class MarkdownRepository {
slug: markdownFile.frontmatter.slug, slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author, author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date, date: markdownFile.frontmatter.date,
fileName: filename,
}); });
fileImports = [...fileImports, markdownFile]; fileImports = [...fileImports, markdownFile];
@ -126,10 +127,64 @@ export class MarkdownRepository {
return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null; return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null;
} }
async deleteBlogPostMarkdownFile(fileName: string): Promise<void> { async createBlogPostMarkdownFile(resolvdePath: string, contents: string): Promise<BlogPost> {
const file = this.blogPosts.blogPosts.find((blogPost) => blogPost.fileName === fileName); return new Promise<void>((resolve, reject) => {
if (file) { writeFile(resolvdePath, contents, (err) => {
const file = resolve(blogPostMarkdownDirectory, fileName); 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<BlogPostFrontmatterValues>({
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<void> {
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();
});
});
} }
} }

View file

@ -5,6 +5,7 @@ class BlogPostBuilder {
private _author = 'default author'; private _author = 'default author';
private _date = new Date('2022-01-01T00:00Z'); private _date = new Date('2022-01-01T00:00Z');
private _slug = 'default-slug'; private _slug = 'default-slug';
private _fileName = 'default-file-name.md';
private _markdownContent = 'default markdown content'; private _markdownContent = 'default markdown content';
@ -18,6 +19,11 @@ class BlogPostBuilder {
return this; return this;
} }
withFileName(fileName: string): BlogPostBuilder {
this._fileName = fileName;
return this;
}
withAuthor(author: string): BlogPostBuilder { withAuthor(author: string): BlogPostBuilder {
this._author = author; this._author = author;
return this; return this;
@ -46,6 +52,7 @@ class BlogPostBuilder {
author: this._author, author: this._author,
date: this._date, date: this._date,
slug: this._slug, slug: this._slug,
fileName: this._fileName,
}); });
} }
} }

View file

@ -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,
};

View file

@ -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 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(); 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 }); console.log({ address });
return json({ address }); return json({ address });
}; };

View file

@ -1,27 +1,91 @@
<script lang="ts"> <script lang="ts">
import { format as formatDate } from "date-fns"; import { format as formatDate } from "date-fns";
import { BlogPost } from "$lib/blog/BlogPost.js";
import { goto } from "$app/navigation";
let title = ""; let title = "";
let author = "Thomas Wilson"; let author = "Thomas Wilson";
let date = formatDate(new Date(), "yyyy-MM-dd"); let date = new Date();
let content = ""; let content = "";
let slug = "";
let blogPost: BlogPost | null = null;
function onCreate() {} $: safeContent = JSON.stringify(content);
function slugifyString(originalString: string): string {
return originalString
.toLowerCase()
.replaceAll(/ /g, "-")
.replaceAll(/[^a-zA-Z0-9-]+/g, "")
.replaceAll(/-+/g, "-");
}
function handleTitleChange() {
const dateAsString = formatDate(date, "yyyy-MM-dd");
const slugifiedTitle = slugifyString(title);
slug = `${dateAsString}-${slugifiedTitle}`;
}
async function onCreatePreviewBlogPost() {
const _blogPost = new BlogPost({
author,
title,
markdownContent: content,
slug,
date,
fileName: `${slug}.md`
});
await _blogPost.build();
blogPost = _blogPost;
}
async function onCreate() {
const requestBody = {
title,
author,
slug,
markdownContent: JSON.stringify(content),
fileName: `${slug}.md`,
date: date.toISOString()
};
fetch("/api/blog/new.json", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
}).then(async (res) => {
if (res.status === 200) {
return await goto(`blog/${slug}`);
} else {
alert("Something went wrong");
}
});
}
</script> </script>
<section class="new-blog-post"> <section class="new-blog-post">
<h1>New Blog Post</h1> <h1>New Blog Post</h1>
<form on:submit|preventDefault={onCreate}>
<div class="field"> <div class="field">
<label class="field__label" for="title">Title</label> <label class="field__label" for="title">Title</label>
<input type="text" id="title" bind:value={title} /> <input
type="text"
id="title"
required
bind:value={title}
on:change={handleTitleChange}
/>
</div> </div>
<div class="field"> <div class="field">
<label class="field__label" for="author">Author</label> <label class="field__label" for="author">Author</label>
<input type="text" id="author" bind:value={author} /> <input type="text" id="author" required bind:value={author} />
</div> </div>
<div class="field"> <div class="field">
<label class="field__label" for="date">Date</label> <label class="field__label" for="slug">Slug</label>
<input type="text" id="date" bind:value={date} /> <input type="text" id="slug" required bind:value={slug} />
</div> </div>
<div class="field"> <div class="field">
@ -30,10 +94,27 @@
</div> </div>
<div class="submit"> <div class="submit">
<button class="create-button" on:click={onCreate}>Create</button> <button
class="preview-button"
type="button"
on:click|preventDefault={onCreatePreviewBlogPost}
>
Preview
</button>
<button class="create-button">Create</button>
</div> </div>
</form>
</section> </section>
{#if blogPost}
<section class="preview">
<h2>Preview</h2>
<article>
{@html blogPost.html}
</article>
</section>
{/if}
<style> <style>
section { section {
--gap: 8px; --gap: 8px;
@ -72,4 +153,17 @@
font-size: 1.15rem; font-size: 1.15rem;
cursor: pointer; cursor: pointer;
} }
.preview {
display: grid;
grid-template-columns: 100%;
grid-template-rows: min-content 1fr;
gap: var(--gap);
place-items: center;
}
.preview article {
width: 100%;
max-width: 65ch;
}
</style> </style>