diff --git a/package.json b/package.json
index a9210bb..be65d35 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/content/blog/2021-01-27-albums-2020.md b/src/content/blog/2021-01-27-albums-2020.md
deleted file mode 100644
index e69de29..0000000
diff --git a/src/lib/blog/BlogPost.test.ts b/src/lib/blog/BlogPost.test.ts
index cde83f2..a663bf2 100644
--- a/src/lib/blog/BlogPost.test.ts
+++ b/src/lib/blog/BlogPost.test.ts
@@ -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`, () => {
- // GIVEN
- const blogPost = new BlogPost({ title: 'Test Title', markdownContent: 'Test Content' });
+ describe(`Constructing`, () => {
+ it(`should construct`, async () => {
+ // GIVEN
+ const blogPost = new BlogPost({
+ title: 'Test Title',
+ author: 'Test Author',
+ date: new Date('2022-01-01T00:00Z'),
+ slug: 'test-slug',
+ markdownContent: 'Test Content',
+ });
- // THEN
- expect(blogPost.title).toBe('Test Title');
- expect(blogPost.markdownContent).toBe('Test Content');
+ // WHEN
+ await blogPost.build();
+
+ // THEN
+ 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(
[
`
This is the content of the blog post.
`,
- `This is a link
`,
+ `This is a link.
`,
``,
`- This is a list item
`,
`- This is another list item
`,
@@ -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.');
+ });
});
diff --git a/src/lib/blog/BlogPost.ts b/src/lib/blog/BlogPost.ts
index 7469f3d..f90e187 100644
--- a/src/lib/blog/BlogPost.ts
+++ b/src/lib/blog/BlogPost.ts
@@ -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 {
+ await this.getHtml();
+ await this.getExcerpt();
+ }
+
+ async getExcerpt(wordLength = 50): Promise {
+ 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 {
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'] });
+ }
}
diff --git a/src/lib/blog/BlogPostSet.test.ts b/src/lib/blog/BlogPostSet.test.ts
index b102c7d..0874ab1 100644
--- a/src/lib/blog/BlogPostSet.test.ts
+++ b/src/lib/blog/BlogPostSet.test.ts
@@ -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();
diff --git a/src/lib/blog/BlogPostSet.ts b/src/lib/blog/BlogPostSet.ts
index a02211c..cc1dc5a 100644
--- a/src/lib/blog/BlogPostSet.ts
+++ b/src/lib/blog/BlogPostSet.ts
@@ -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 {
+ await Promise.all(this.blogPosts.map((post) => post.build()));
}
}
diff --git a/src/lib/blog/markdown-repository.test.ts b/src/lib/blog/markdown-repository.test.ts
index 7f49fb4..3832103 100644
--- a/src/lib/blog/markdown-repository.test.ts
+++ b/src/lib/blog/markdown-repository.test.ts
@@ -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();
diff --git a/src/lib/blog/markdown-repository.ts b/src/lib/blog/markdown-repository.ts
index 7b5b7ab..b0ff206 100644
--- a/src/lib/blog/markdown-repository.ts
+++ b/src/lib/blog/markdown-repository.ts
@@ -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 {
- let fileImports: MarkdownFile[] = [];
+ let fileImports: MarkdownFile[] = [];
let blogPosts: BlogPost[] = [];
const allFiles = Object.entries(globImport);
for (const entry of allFiles) {
const [filename, module] = entry as [string, () => Promise];
- const fileContent = await module();
+ try {
+ const fileContent = await module();
- const markdownFile = new MarkdownFile<{ title: string }>({ fileName: filename, content: fileContent });
- const blogPost = new BlogPost({
- markdownContent: markdownFile.content,
- title: markdownFile.frontmatter.title,
- });
+ const markdownFile = new MarkdownFile({ 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];
+ fileImports = [...fileImports, markdownFile];
+ blogPosts = [...blogPosts, blogPost];
+ } catch (e) {
+ console.error({
+ message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
+ error: e,
+ });
+ }
}
return new MarkdownRepository(fileImports, blogPosts);
diff --git a/src/lib/blog/test-builders/blog-post-builder.ts b/src/lib/blog/test-builders/blog-post-builder.ts
index dfca8c9..49dbf1d 100644
--- a/src/lib/blog/test-builders/blog-post-builder.ts
+++ b/src/lib/blog/test-builders/blog-post-builder.ts
@@ -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 {
+ 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,
+ });
}
}
diff --git a/src/routes/api/blog.json/+server.ts b/src/routes/api/blog.json/+server.ts
index 5d7357d..70bdf0b 100644
--- a/src/routes/api/blog.json/+server.ts
+++ b/src/routes/api/blog.json/+server.ts
@@ -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(
diff --git a/src/routes/api/blog.json/BlogController.test.ts b/src/routes/api/blog.json/BlogController.test.ts
index b00faa7..df2a1f5 100644
--- a/src/routes/api/blog.json/BlogController.test.ts
+++ b/src/routes/api/blog.json/BlogController.test.ts
@@ -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();
});
});
});
diff --git a/src/routes/api/blog.json/BlogController.ts b/src/routes/api/blog.json/BlogController.ts
index b6094bb..b67d06a 100644
--- a/src/routes/api/blog.json/BlogController.ts
+++ b/src/routes/api/blog.json/BlogController.ts
@@ -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 {
- const markdownRepository = await MarkdownRepository.fromViteGlobImport(blogPosts);
+ const markdownRepository = await MarkdownRepository.fromViteGlobImport(blogPostMetaGlobImport);
return new BlogController(markdownRepository);
}
constructor(private readonly markdownRepository: MarkdownRepository) {}
- async getAllBlogPosts(): Promise {
+ async getAllBlogPosts(): Promise {
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));
}
}
diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte
index e22bbef..fc4b616 100644
--- a/src/routes/blog/+page.svelte
+++ b/src/routes/blog/+page.svelte
@@ -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,
diff --git a/yarn.lock b/yarn.lock
index c7cc85c..1f7ef4c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"