BlogEngine: Update the BlogIndex page to use the new BlogController. Remove reliance on the old Pythonlist

This commit is contained in:
Thomas 2023-02-05 16:19:07 +00:00
parent 2d16ce03df
commit dbc368fc3c
14 changed files with 289 additions and 51 deletions

View file

@ -46,11 +46,13 @@
"nanoid": "3.3.4",
"node-fetch": "^3.2.10",
"rehype-stringify": "^9.0.3",
"remark": "^14.0.2",
"remark-frontmatter": "^4.0.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"remark-stringify": "^10.0.2",
"sanitize-html": "^2.7.0",
"strip-markdown": "^5.0.0",
"to-vfile": "^7.2.3",
"unified": "^10.1.2",
"zod": "^3.18.0"

View file

@ -1,42 +1,92 @@
import { describe, it, expect } from 'vitest';
import { BlogPost } from './BlogPost.js';
import { aBlogPost } from './test-builders/blog-post-builder.js';
const exampleMarkdownWithFrontMatter = `---
title: "Test Blog Post"
date: 2023-02-01T08:00:00Z
slug: "2023-02-01-test"
author: Thomas Wilson
---
This is the content of the blog post.
This is a [link](http://www.bbc.co.uk)
This is a [link](http://www.bbc.co.uk).
- This is a list item
- This is another list item
`;
describe('BlogPost', () => {
it(`should construct`, () => {
describe(`Constructing`, () => {
it(`should construct`, async () => {
// GIVEN
const blogPost = new BlogPost({ title: 'Test Title', markdownContent: 'Test Content' });
const blogPost = new BlogPost({
title: 'Test Title',
author: 'Test Author',
date: new Date('2022-01-01T00:00Z'),
slug: 'test-slug',
markdownContent: 'Test Content',
});
// WHEN
await blogPost.build();
// THEN
expect(blogPost.title).toBe('Test Title');
expect(blogPost.markdownContent).toBe('Test Content');
const expectedBlogPost = await aBlogPost()
.withTitle('Test Title')
.withAuthor('Test Author')
.withDate(new Date('2022-01-01T00:00Z'))
.withSlug('test-slug')
.withMarkdownContent('Test Content')
.constructAndThenBuild();
expect(blogPost).toStrictEqual(expectedBlogPost);
expect(blogPost.html).toBeDefined();
expect(blogPost.excerpt).toBeDefined();
});
});
describe(`Building the blog post`, () => {
it(`should know if a blog post has been built`, () => {
// GIVEN
const blogPost = aBlogPost().build();
// WHEN
const hasBeenBuilt = blogPost.hasBeenBuilt;
// THEN
expect(hasBeenBuilt).toBe(false);
expect(blogPost.html).toBeNull();
expect(blogPost.excerpt).toBeNull();
});
it(`should know if a blog post has been built`, async () => {
// GIVEN
const blogPost = aBlogPost().build();
// WHEN
await blogPost.build();
// THEN
expect(blogPost.hasBeenBuilt).toBe(true);
expect(blogPost.html).toBeDefined();
expect(blogPost.excerpt).toBeDefined();
});
});
it(`Should parse markdown to HTML`, async () => {
// GIVEN
const blogPost = new BlogPost({ title: 'Test Title', markdownContent: exampleMarkdownWithFrontMatter });
const blogPost = await aBlogPost().withMarkdownContent(exampleMarkdownWithFrontMatter).constructAndThenBuild();
// WHEN
const html = await blogPost.getHtml();
const html = blogPost.html;
// THEN
expect(html).toStrictEqual(
[
`<p>This is the content of the blog post.</p>`,
`<p>This is a <a href="http://www.bbc.co.uk">link</a></p>`,
`<p>This is a <a href="http://www.bbc.co.uk">link</a>.</p>`,
`<ul>`,
`<li>This is a list item</li>`,
`<li>This is another list item</li>`,
@ -44,4 +94,15 @@ describe('BlogPost', () => {
].join(`\n`)
);
});
it(`should have a plain-text excerpt`, async () => {
// GIVEN
const blogPost = await aBlogPost().withMarkdownContent(exampleMarkdownWithFrontMatter).constructAndThenBuild();
// WHEN
const excerpt = await blogPost.getExcerpt();
// THEN
expect(excerpt).toBe('This is the content of the blog post. This is a link.');
});
});

View file

@ -1,29 +1,86 @@
import type { Processor } from 'unified';
import { unified } from 'unified';
import { remark } from 'remark';
import markdown from 'remark-parse';
import markdownFrontmatter from 'remark-frontmatter';
import remarkStringify from 'remark-stringify';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import stripMarkdown from 'strip-markdown';
import remarkFrontmatter from 'remark-frontmatter';
interface BlogPostParams {
title: string;
date: Date;
author: string;
slug: string;
markdownContent: string;
}
export class BlogPost {
readonly title: string;
readonly date: Date;
readonly author: string;
readonly slug: string;
readonly markdownContent: string;
private _html: string | null = null;
private _excerpt: string | null = null;
constructor(params: BlogPostParams) {
this.title = params.title;
this.date = params.date;
this.author = params.author;
this.slug = params.slug;
this.markdownContent = params.markdownContent;
}
get html(): string | null {
return this._html;
}
get excerpt(): string | null {
return this._excerpt;
}
get hasBeenBuilt(): boolean {
return this._html !== null && this._excerpt !== null;
}
async build(): Promise<void> {
await this.getHtml();
await this.getExcerpt();
}
async getExcerpt(wordLength = 50): Promise<string> {
const processor = this.markdownToExcerptProcessorFactory();
const value = await processor.process(this.markdownContent);
const textValueWithNoLinebreaks = value.toString();
// A regex that looks for any character, followed by `.`, and then another character.
// e.g. "This is a sentence.This is another sentence."
// becomes "This is a sentence. This is another sentence."
const reg = /([a-zA-Z0-9])\.([a-zA-Z0-9])/g;
const textWithSpacesBetweenSentences = textValueWithNoLinebreaks
.replaceAll('\r', ' ')
.replaceAll('\n', ' ')
.replaceAll(reg, '$1. $2')
.split(' ')
.filter((word) => word !== ' ' && word !== '')
.slice(0, wordLength)
.join(' ');
this._excerpt = textWithSpacesBetweenSentences;
return this._excerpt;
}
async getHtml(): Promise<string> {
const processor = this.markdownToHtmlProcessorFactory();
const html = await processor.process(this.markdownContent);
return html.toString();
this._html = html.toString();
return this._html;
}
private markdownToHtmlProcessorFactory(): Processor {
@ -34,4 +91,11 @@ export class BlogPost {
.use(remarkRehype)
.use(rehypeStringify);
}
private markdownToExcerptProcessorFactory(): Processor {
return remark()
.use(markdown)
.use(remarkFrontmatter)
.use(stripMarkdown, { remove: ['list'] });
}
}

View file

@ -15,6 +15,19 @@ describe(`BlogPostSet`, () => {
expect(blogPostSet.blogPosts).toStrictEqual([blogPostOne, blogPostTwo]);
});
it(`Should be able to build all the blog posts`, async () => {
// GIVEN
const blogPostOne = aBlogPost().withTitle('Blog Post One').build();
const blogPostTwo = aBlogPost().withTitle('Blog Post Two').build();
const blogPostSet = new BlogPostSet([blogPostOne, blogPostTwo]);
// WHEN
await blogPostSet.buildAllBlogPosts();
// THEN
expect(blogPostSet.blogPosts.every((post) => post.hasBeenBuilt)).toBe(true);
});
describe(`Finding a blog post by title`, () => {
const blogPostOne = aBlogPost().withTitle('Blog Post One').build();
const blogPostTwo = aBlogPost().withTitle('Blog Post Two').build();

View file

@ -1,9 +1,21 @@
import type { BlogPost } from './BlogPost.js';
export class BlogPostSet {
constructor(readonly blogPosts: BlogPost[]) {}
private _blogPosts: BlogPost[] = [];
constructor(blogPosts: BlogPost[]) {
this._blogPosts = blogPosts;
}
get blogPosts(): BlogPost[] {
return this._blogPosts;
}
getBlogPostWithTitle(title: string): BlogPost | null {
return this.blogPosts.find((post) => post.title === title) ?? null;
return this._blogPosts.find((post) => post.title === title) ?? null;
}
async buildAllBlogPosts(): Promise<void> {
await Promise.all(this.blogPosts.map((post) => post.build()));
}
}

View file

@ -31,6 +31,9 @@ describe(`Blog MarkdownRepository`, () => {
});
const expectedBlogPost = aBlogPost()
.withAuthor('Thomas Wilson')
.withDate(new Date('2023-02-01T08:00:00Z'))
.withSlug('2023-02-01-test')
.withTitle('Test Blog Post')
.withMarkdownContent(testMarkdownContent)
.build();

View file

@ -2,6 +2,13 @@ import { BlogPost } from './BlogPost.js';
import { MarkdownFile } from './MarkdownFile.js';
import { BlogPostSet } from './BlogPostSet.js';
interface FrontmatterValues {
title: string;
slug: string;
date: Date;
author: string;
}
export class MarkdownRepository {
readonly markdownFiles: MarkdownFile[];
readonly blogPosts: BlogPostSet;
@ -13,22 +20,32 @@ export class MarkdownRepository {
}
public static async fromViteGlobImport(globImport): Promise<MarkdownRepository> {
let fileImports: MarkdownFile[] = [];
let fileImports: MarkdownFile<FrontmatterValues>[] = [];
let blogPosts: BlogPost[] = [];
const allFiles = Object.entries(globImport);
for (const entry of allFiles) {
const [filename, module] = entry as [string, () => Promise<string>];
try {
const fileContent = await module();
const markdownFile = new MarkdownFile<{ title: string }>({ fileName: filename, content: fileContent });
const markdownFile = new MarkdownFile<FrontmatterValues>({ fileName: filename, content: fileContent });
const blogPost = new BlogPost({
markdownContent: markdownFile.content,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date,
});
fileImports = [...fileImports, markdownFile];
blogPosts = [...blogPosts, blogPost];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
return new MarkdownRepository(fileImports, blogPosts);

View file

@ -2,6 +2,10 @@ import { BlogPost } from '../BlogPost.js';
class BlogPostBuilder {
private _title = 'default title';
private _author = 'default author';
private _date = new Date('2022-01-01T00:00Z');
private _slug = 'default-slug';
private _markdownContent = 'default markdown content';
withTitle(title: string): BlogPostBuilder {
@ -14,8 +18,35 @@ class BlogPostBuilder {
return this;
}
withAuthor(author: string): BlogPostBuilder {
this._author = author;
return this;
}
withDate(date: Date): BlogPostBuilder {
this._date = date;
return this;
}
withSlug(slug: string): BlogPostBuilder {
this._slug = slug;
return this;
}
async constructAndThenBuild(): Promise<BlogPost> {
const blogPost = this.build();
await blogPost.build();
return blogPost;
}
build(): BlogPost {
return new BlogPost({ title: this._title, markdownContent: this._markdownContent });
return new BlogPost({
title: this._title,
markdownContent: this._markdownContent,
author: this._author,
date: this._date,
slug: this._slug,
});
}
}

View file

@ -1,26 +1,11 @@
import { json } from '@sveltejs/kit';
import { BlogController } from './BlogController.js';
import allPosts from '../../../content/posts.json';
export const GET = async ({ url }) => {
export const GET = async () => {
try {
const posts = Object.entries(allPosts).map(([key, value]) => ({
...value,
}));
const sortedBlogPosts = posts.sort((a, b) => {
if (a.date > b.date) {
return -1;
}
if (a.date < b.date) {
return 1;
}
return 0;
});
return json({
posts: sortedBlogPosts,
});
const controller = await BlogController.singleton();
const blogPosts = await controller.getAllBlogPosts();
return json({ posts: blogPosts });
} catch (error) {
console.error({ error: JSON.stringify(error) });
return json(

View file

@ -14,8 +14,13 @@ describe(`BlogController`, () => {
// GIVEN
const blogPosts = await controller.getAllBlogPosts();
// WHEN
const aKnownBlogPost = blogPosts.find((post) => post.title === 'Vibe Check #10');
const aMadeUpBlogPost = blogPosts.find((post) => post.title === 'Some made up blog post');
// then
expect(blogPosts.getBlogPostWithTitle('Vibe Check #10')).toBeDefined();
expect(aMadeUpBlogPost).toBeNull();
expect(aKnownBlogPost).not.toBeNull();
});
});
});

View file

@ -1,18 +1,42 @@
import type { BlogPostSet } from '../../../lib/blog/BlogPostSet.js';
import { MarkdownRepository } from '../../../lib/blog/markdown-repository.js';
const blogPosts = import.meta.glob('../../content/blog/*.md', { as: 'raw' });
const blogPostMetaGlobImport = import.meta.glob('../../../content/blog/*.md', { as: 'raw' });
interface BlogPostListItem {
title: string;
author: string;
date: string;
book_review: boolean;
preview: string;
content: string;
slug: string;
}
export class BlogController {
static async singleton(): Promise<BlogController> {
const markdownRepository = await MarkdownRepository.fromViteGlobImport(blogPosts);
const markdownRepository = await MarkdownRepository.fromViteGlobImport(blogPostMetaGlobImport);
return new BlogController(markdownRepository);
}
constructor(private readonly markdownRepository: MarkdownRepository) {}
async getAllBlogPosts(): Promise<BlogPostSet> {
async getAllBlogPosts(): Promise<BlogPostListItem[]> {
const blogPosts = await this.markdownRepository.blogPosts;
return blogPosts;
await blogPosts.buildAllBlogPosts();
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map((blogPost) => {
return {
title: blogPost.title,
author: blogPost.author,
book_review: false,
content: blogPost.html,
date: blogPost.date.toISOString(),
preview: blogPost.excerpt,
slug: blogPost.slug,
};
});
return blogPostListItems.sort((a, b) => (a.date > b.date ? -1 : 1));
}
}

View file

@ -3,7 +3,9 @@
import Navbar from "$lib/components/Navbar.svelte";
import { intlFormat } from "date-fns";
export const prerender = true;
export let data: PageData;
$: ({
posts,
firstPost,

View file

@ -357,7 +357,7 @@
dependencies:
"@types/node" "*"
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3":
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3", "@types/unist@^2.0.6":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
@ -2255,7 +2255,7 @@ remark-frontmatter@^4.0.1:
micromark-extension-frontmatter "^1.0.0"
unified "^10.0.0"
remark-parse@^10.0.1:
remark-parse@^10.0.0, remark-parse@^10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-10.0.1.tgz#6f60ae53edbf0cf38ea223fe643db64d112e0775"
integrity sha512-1fUyHr2jLsVOkhbvPRBJ5zTKZZyD6yZzYaWCS6BPBdQ8vEMBCH+9zNCDA6tET/zHCi/jLqjCWtlJZUPk+DbnFw==
@ -2274,7 +2274,7 @@ remark-rehype@^10.1.0:
mdast-util-to-hast "^12.1.0"
unified "^10.0.0"
remark-stringify@^10.0.2:
remark-stringify@^10.0.0, remark-stringify@^10.0.2:
version "10.0.2"
resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-10.0.2.tgz#50414a6983f5008eb9e72eed05f980582d1f69d7"
integrity sha512-6wV3pvbPvHkbNnWB0wdDvVFHOe1hBRAx1Q/5g/EpH4RppAII6J8Gnwe7VbHuXaoKIF6LAg6ExTel/+kNqSQ7lw==
@ -2283,6 +2283,16 @@ remark-stringify@^10.0.2:
mdast-util-to-markdown "^1.0.0"
unified "^10.0.0"
remark@^14.0.2:
version "14.0.2"
resolved "https://registry.yarnpkg.com/remark/-/remark-14.0.2.tgz#4a1833f7441a5c29e44b37bb1843fb820797b40f"
integrity sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==
dependencies:
"@types/mdast" "^3.0.0"
remark-parse "^10.0.0"
remark-stringify "^10.0.0"
unified "^10.0.0"
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
@ -2579,6 +2589,15 @@ strip-literal@^1.0.0:
dependencies:
acorn "^8.8.1"
strip-markdown@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/strip-markdown/-/strip-markdown-5.0.0.tgz#222b864ecce6cf2ef87b2bb1e6466464e8127081"
integrity sha512-PXSts6Ta9A/TwGxVVSRlQs1ukJTAwwtbip2OheJEjPyfykaQ4sJSTnQWjLTI2vYWNts/R/91/csagp15W8n9gA==
dependencies:
"@types/mdast" "^3.0.0"
"@types/unist" "^2.0.6"
unified "^10.0.0"
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"