BlogEngine: Create Blog Posts locally with endpoint
This commit is contained in:
parent
57dd0a017e
commit
3e2053e884
10 changed files with 305 additions and 36 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ interface BookReviewListItem {
|
|||
}
|
||||
|
||||
export class BlogController {
|
||||
private readonly _markdownRepository: MarkdownRepository;
|
||||
private _markdownRepository: MarkdownRepository;
|
||||
|
||||
static async singleton(): Promise<BlogController> {
|
||||
const markdownRepository = await MarkdownRepository.singleton();
|
||||
|
|
@ -43,6 +43,15 @@ export class BlogController {
|
|||
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>> {
|
||||
const blogPosts = await this._markdownRepository.blogPosts;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const file = this.blogPosts.blogPosts.find((blogPost) => blogPost.fileName === fileName);
|
||||
if (file) {
|
||||
const file = resolve(blogPostMarkdownDirectory, fileName);
|
||||
async createBlogPostMarkdownFile(resolvdePath: string, contents: string): Promise<BlogPost> {
|
||||
return new Promise<void>((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<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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
src/lib/blog/test-fixtures/example-markdown.ts
Normal file
20
src/lib/blog/test-fixtures/example-markdown.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,39 +1,120 @@
|
|||
<script lang="ts">
|
||||
import { format as formatDate } from "date-fns";
|
||||
import { BlogPost } from "$lib/blog/BlogPost.js";
|
||||
import { goto } from "$app/navigation";
|
||||
let title = "";
|
||||
let author = "Thomas Wilson";
|
||||
let date = formatDate(new Date(), "yyyy-MM-dd");
|
||||
let date = new Date();
|
||||
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>
|
||||
|
||||
<section class="new-blog-post">
|
||||
<h1>New Blog Post</h1>
|
||||
<div class="field">
|
||||
<label class="field__label" for="title">Title</label>
|
||||
<input type="text" id="title" bind:value={title} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="author">Author</label>
|
||||
<input type="text" id="author" bind:value={author} />
|
||||
</div>
|
||||
<form on:submit|preventDefault={onCreate}>
|
||||
<div class="field">
|
||||
<label class="field__label" for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
required
|
||||
bind:value={title}
|
||||
on:change={handleTitleChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="author">Author</label>
|
||||
<input type="text" id="author" required bind:value={author} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="date">Date</label>
|
||||
<input type="text" id="date" bind:value={date} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="slug">Slug</label>
|
||||
<input type="text" id="slug" required bind:value={slug} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="content">Content</label>
|
||||
<textarea id="content" rows="10" cols="50" bind:value={content} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="content">Content</label>
|
||||
<textarea id="content" rows="10" cols="50" bind:value={content} />
|
||||
</div>
|
||||
|
||||
<div class="submit">
|
||||
<button class="create-button" on:click={onCreate}>Create</button>
|
||||
</div>
|
||||
<div class="submit">
|
||||
<button
|
||||
class="preview-button"
|
||||
type="button"
|
||||
on:click|preventDefault={onCreatePreviewBlogPost}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button class="create-button">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{#if blogPost}
|
||||
<section class="preview">
|
||||
<h2>Preview</h2>
|
||||
<article>
|
||||
{@html blogPost.html}
|
||||
</article>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
section {
|
||||
--gap: 8px;
|
||||
|
|
@ -72,4 +153,17 @@
|
|||
font-size: 1.15rem;
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue