refactor: remove references to SnoutStreetStudio posts

This commit is contained in:
Thomas 2025-12-18 22:17:44 +00:00
parent eb1e9d7b67
commit d5dc6d0565
No known key found for this signature in database
19 changed files with 632 additions and 872 deletions

View file

@ -1,149 +1,149 @@
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';
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;
beforeEach(async () => {
controller = await BlogController.singleton();
});
describe(`Getting all posts which show up on the /blog page`, () => {
it(`should load blogs from the content folder`, async () => {
// GIVEN
const blogPosts = await controller.getAllBlogPosts();
// WHEN
const aKnownBlogPost = blogPosts.find(
(post) => post.title === "Vibe Check #10",
);
const aKnownBookReview = blogPosts.find((post) => post.title === "After");
const aMadeUpBlogPost = blogPosts.find(
(post) => post.title === "Some made up blog post",
);
// then
expect(aMadeUpBlogPost).toBeUndefined();
expect(aKnownBlogPost).not.toBeUndefined();
expect(aKnownBookReview).not.toBeUndefined();
});
});
describe(`getBlogPostBySlug`, () => {
it(`should return null when the post doesn't exist`, async () => {
// When
const shouldBeNull = await controller.getBlogPostBySlug(
"some-made-up-blog-post",
);
// Then
expect(shouldBeNull).toBeNull();
});
it(`should return the blog post when it exists`, async () => {
// When
const blogPost = await controller.getBlogPostBySlug(
"2023-02-03-vibe-check-10",
);
// Then
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe("Vibe Check #10");
});
});
describe(`Finding content by slug`, () => {
describe(`Finding a blog post`, () => {
// GIVEN
const slugForRealBlogPost = "2023-02-03-vibe-check-10";
const slugForFakeBlogPost = "some-made-up-blog-post";
it(`should return null if there's no blog post with the slug`, async () => {
// WHEN
const blogPost =
await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost);
// THEN
expect(blogPost).toBeNull();
});
it(`should return the blog post if it exists`, async () => {
// WHEN
const blogPost =
await controller.getAnyKindOfContentBySlug(slugForRealBlogPost);
// THEN
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe("Vibe Check #10");
});
});
describe(`Finding a book review`, () => {
const realSlug = "after";
const fakeSlug = "some-made-up-book-review";
it(`should return null if there's no book review with the slug`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(fakeSlug);
// THEN
expect(bookReview).toBeNull();
});
it(`should return the book review if it exists`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(realSlug);
// THEN
expect(bookReview).not.toBeNull();
expect(bookReview.title).toBe("After");
});
});
});
describe(`Creating a new blog post as a file`, () => {
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 () => {
controller = await BlogController.singleton();
controller = await BlogController.singleton();
});
describe(`Getting all posts which show up on the /blog page`, () => {
it(`should load blogs from the content folder`, async () => {
// GIVEN
const blogPosts = await controller.getAllBlogPosts();
// WHEN
const aKnownBlogPost = blogPosts.find((post) => post.title === 'Vibe Check #10');
const aKnownBookReview = blogPosts.find((post) => post.title === 'After');
const aKnownSnoutStreetStudiosPost = blogPosts.find((post) => post.title === 'Cinnamon Dust Linen Shirt');
const aMadeUpBlogPost = blogPosts.find((post) => post.title === 'Some made up blog post');
// then
expect(aMadeUpBlogPost).toBeUndefined();
expect(aKnownBlogPost).not.toBeUndefined();
expect(aKnownBookReview).not.toBeUndefined();
expect(aKnownSnoutStreetStudiosPost).not.toBeUndefined();
});
afterAll(async () => {
await controller.markdownRepository.deleteBlogPostMarkdownFile(fileName);
});
describe(`getBlogPostBySlug`, () => {
it(`should return null when the post doesn't exist`, async () => {
// When
const shouldBeNull = await controller.getBlogPostBySlug('some-made-up-blog-post');
it(`should create a new file in the content folder`, async () => {
// GIVEN
const markdownContent = exampleMarkdown;
// Then
expect(shouldBeNull).toBeNull();
});
// WHEN
const blogPost = await controller.createBlogPost(
fileName,
markdownContent,
);
it(`should return the blog post when it exists`, async () => {
// When
const blogPost = await controller.getBlogPostBySlug('2023-02-03-vibe-check-10');
// Then
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe('Vibe Check #10');
});
});
describe(`Finding content by slug`, () => {
describe(`Finding a blog post`, () => {
// GIVEN
const slugForRealBlogPost = '2023-02-03-vibe-check-10';
const slugForFakeBlogPost = 'some-made-up-blog-post';
it(`should return null if there's no blog post with the slug`, async () => {
// WHEN
const blogPost = await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost);
// THEN
expect(blogPost).toBeNull();
});
it(`should return the blog post if it exists`, async () => {
// WHEN
const blogPost = await controller.getAnyKindOfContentBySlug(slugForRealBlogPost);
// THEN
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe('Vibe Check #10');
});
});
describe(`Finding a book review`, () => {
const realSlug = 'after';
const fakeSlug = 'some-made-up-book-review';
it(`should return null if there's no book review with the slug`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(fakeSlug);
// THEN
expect(bookReview).toBeNull();
});
it(`should return the book review if it exists`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(realSlug);
// THEN
expect(bookReview).not.toBeNull();
expect(bookReview.title).toBe('After');
});
});
describe(`Finding a Snout Street Studios post`, () => {
const realSlug = '2023-08-cinnamon-dust-linen-shirt';
const fakeSlug = 'some-made-up-snout-street-studios-post';
it(`should return null if there's no Snout Street Studios post with the slug`, async () => {
// WHEN
const snoutStreetStudiosPost = await controller.getAnyKindOfContentBySlug(fakeSlug);
// THEN
expect(snoutStreetStudiosPost).toBeNull();
});
it(`should return the Snout Street Studios post if it exists`, async () => {
// WHEN
const snoutStreetStudiosPost = await controller.getAnyKindOfContentBySlug(realSlug);
// THEN
expect(snoutStreetStudiosPost).not.toBeNull();
expect(snoutStreetStudiosPost.title).toBe('Cinnamon Dust Linen Shirt');
});
});
});
describe(`Creating a new blog post as a file`, () => {
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 () => {
controller = await BlogController.singleton();
});
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();
});
// THEN
expect(blogPost).not.toBeNull();
expect(blogPost.html).not.toBeNull();
});
});
});

View file

@ -1,176 +1,158 @@
import type { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js';
import type { BlogPost } from './BlogPost.js';
import type { BookReview } from './BookReview.js';
import { MarkdownRepository } from './markdown-repository.js';
import type { BlogPost } from "./BlogPost.js";
import type { BookReview } from "./BookReview.js";
import { MarkdownRepository } from "./markdown-repository.js";
interface BlogItem {
title: string;
date: string;
content: string;
slug: string;
content_type: 'blog' | 'book_review' | 'snout_street_studios';
tags?: string[];
export interface BlogItem {
title: string;
date: string;
content: string;
slug: string;
content_type: "blog" | "book_review" | "snout_street_studios";
tags?: string[];
}
interface BlogPostListItem extends BlogItem {
title: string;
author: string;
date: string;
book_review: boolean;
preview: string;
tags: string[];
export interface BlogPostListItem extends BlogItem {
title: string;
author: string;
date: string;
book_review: boolean;
preview: string;
tags: string[];
}
interface BookReviewListItem extends BlogItem {
book_review: true;
title: string;
author: string;
image: string;
slug: string;
score: number;
finished: string;
}
interface SnoutStreetStudiosPostListItem extends BlogItem {
title: string;
slug: string;
date: string;
export interface BookReviewListItem extends BlogItem {
book_review: true;
title: string;
author: string;
image: string;
slug: string;
score: number;
finished: string;
}
export class BlogController {
private _markdownRepository: MarkdownRepository;
private _markdownRepository: MarkdownRepository;
static async singleton(): Promise<BlogController> {
const markdownRepository = await MarkdownRepository.singleton();
return new BlogController(markdownRepository);
static async singleton(): Promise<BlogController> {
const markdownRepository = await MarkdownRepository.singleton();
return new BlogController(markdownRepository);
}
constructor(markdownRepository: MarkdownRepository) {
this._markdownRepository = markdownRepository;
}
get markdownRepository(): 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(true);
return createdBlogPost;
}
async getAllBlogPosts(
pageSize?: number,
): Promise<Array<BlogPostListItem | BookReviewListItem>> {
const blogPosts = this._markdownRepository.blogPosts;
const bookReviews = this._markdownRepository.bookReviews;
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map(
(blogPost) => {
return this.blogPostToBlogPostListItem(blogPost);
},
);
const bookReviewListItems: BookReviewListItem[] =
bookReviews.bookReviews.map((bookReview) => {
return this.bookReviewToBookReviewListItem(bookReview);
});
const allBlogPosts = [...blogPostListItems, ...bookReviewListItems].sort(
(a, b) => (a.date > b.date ? -1 : 1),
);
if (pageSize === undefined) {
return allBlogPosts;
}
constructor(markdownRepository: MarkdownRepository) {
this._markdownRepository = markdownRepository;
return allBlogPosts.slice(0, pageSize);
}
async getBlogPostBySlug(slug: string): Promise<BlogPostListItem | null> {
const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
}
get markdownRepository(): MarkdownRepository {
return this._markdownRepository;
return null;
}
async getBlogPostsByTags(tags: string[]): Promise<BlogPostListItem[]> {
const posts = await this.getAllBlogPosts();
const blogPosts = posts.filter(
(post) => post.content_type === "blog",
) as BlogPostListItem[];
return blogPosts
.filter((post: BlogPostListItem) => post["tags"]?.length > 0)
.filter((post: BlogPostListItem) =>
(post.tags as string[]).some((tag) => tags.includes(tag)),
);
}
async getAnyKindOfContentBySlug(
slug: string,
): Promise<BookReviewListItem | BlogPostListItem | null> {
const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
}
async createBlogPost(resolvedFileName: string, markdownContent: string): Promise<BlogPost> {
const createdBlogPost = await this._markdownRepository.createBlogPostMarkdownFile(
resolvedFileName,
markdownContent
);
this._markdownRepository = await MarkdownRepository.singleton(true);
return createdBlogPost;
const bookReview = this._markdownRepository.getBookReviewBySlug(slug);
if (bookReview) {
return this.bookReviewToBookReviewListItem(bookReview);
}
async getAllBlogPosts(
pageSize?: number
): Promise<Array<BlogPostListItem | BookReviewListItem | SnoutStreetStudiosPostListItem>> {
const blogPosts = this._markdownRepository.blogPosts;
return null;
}
const bookReviews = this._markdownRepository.bookReviews;
private bookReviewToBookReviewListItem(
bookReview: BookReview,
): BookReviewListItem {
return {
book_review: true,
title: bookReview.title,
author: bookReview.author,
date: bookReview.date.toISOString(),
finished: bookReview.finished.toISOString(),
image: bookReview.image,
score: bookReview.score,
slug: bookReview.slug,
content: bookReview.html,
content_type: "book_review",
};
}
const snoutStreetStudiosPosts = this._markdownRepository.snoutStreetStudiosPosts;
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map((blogPost) => {
return this.blogPostToBlogPostListItem(blogPost);
});
const bookReviewListItems: BookReviewListItem[] = bookReviews.bookReviews.map((bookReview) => {
return this.bookReviewToBookReviewListItem(bookReview);
});
const snoutStreetStudiosPostListItems: SnoutStreetStudiosPostListItem[] = snoutStreetStudiosPosts.posts.map(
(post) => this.snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(post)
);
const allBlogPosts = [...blogPostListItems, ...bookReviewListItems, ...snoutStreetStudiosPostListItems].sort(
(a, b) => (a.date > b.date ? -1 : 1)
);
if (pageSize === undefined) {
return allBlogPosts;
}
return allBlogPosts.slice(0, pageSize);
}
async getBlogPostBySlug(slug: string): Promise<BlogPostListItem | null> {
const blogPost = await this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
}
return null;
}
async getBlogPostsByTags(tags: string[]): Promise<BlogPostListItem[]> {
const posts = await this.getAllBlogPosts();
const blogPosts = posts.filter((post) => post.content_type === 'blog') as BlogPostListItem[];
return blogPosts
.filter((post: BlogPostListItem) => post['tags']?.length > 0)
.filter((post: BlogPostListItem) => (post.tags as string[]).some((tag) => tags.includes(tag)));
}
async getAnyKindOfContentBySlug(
slug: string
): Promise<BookReviewListItem | BlogPostListItem | SnoutStreetStudiosPostListItem | null> {
const blogPost = await this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
}
const bookReview = await this._markdownRepository.getBookReviewBySlug(slug);
if (bookReview) {
return this.bookReviewToBookReviewListItem(bookReview);
}
const snoutStreetStudiosPost = await this._markdownRepository.getSnoutStreetStudiosPostBySlug(slug);
if (snoutStreetStudiosPost) {
return this.snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(snoutStreetStudiosPost);
}
return null;
}
private bookReviewToBookReviewListItem(bookReview: BookReview): BookReviewListItem {
return {
book_review: true,
title: bookReview.title,
author: bookReview.author,
date: bookReview.date.toISOString(),
finished: bookReview.finished.toISOString(),
image: bookReview.image,
score: bookReview.score,
slug: bookReview.slug,
content: bookReview.html,
content_type: 'book_review',
};
}
private blogPostToBlogPostListItem(blogPost: BlogPost): BlogPostListItem {
return {
title: blogPost.title,
author: blogPost.author,
book_review: false,
content: blogPost.html,
date: blogPost.date.toISOString(),
preview: blogPost.excerpt,
slug: blogPost.slug,
content_type: 'blog',
tags: blogPost.tags,
};
}
private snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(
post: SnoutStreetStudiosPost
): SnoutStreetStudiosPostListItem {
return {
title: post.title,
slug: post.slug,
date: post.date.toISOString(),
content_type: 'snout_street_studios',
content: post.html,
};
}
private blogPostToBlogPostListItem(blogPost: BlogPost): BlogPostListItem {
return {
title: blogPost.title,
author: blogPost.author,
book_review: false,
content: blogPost.html,
date: blogPost.date.toISOString(),
preview: blogPost.excerpt,
slug: blogPost.slug,
content_type: "blog",
tags: blogPost.tags,
};
}
}

View file

@ -1,17 +0,0 @@
import { describe, it, expect } from 'vitest';
import { aSnoutStreetStudiosPost } from './test-builders/snout-street-studios-post-builder.js';
import { SnoutStreetStudiosPostSet } from './SnoutStreetStudiosPostSet.js';
describe(`SnoutStreetStudiosBlogPostSet`, () => {
it(`Should contain a list of posts`, () => {
// GIVEN
const postOne = aSnoutStreetStudiosPost().withTitle('Post One').build();
const postTwo = aSnoutStreetStudiosPost().withTitle('Post Two').build();
// WHEN
const postSet = new SnoutStreetStudiosPostSet([postOne, postTwo]);
// THEN
expect(postSet.posts).toStrictEqual([postOne, postTwo]);
});
});

View file

@ -1,13 +0,0 @@
import type { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js';
export class SnoutStreetStudiosPostSet {
private readonly _posts: SnoutStreetStudiosPost[] = [];
constructor(posts: SnoutStreetStudiosPost[]) {
this._posts = posts;
}
public get posts(): SnoutStreetStudiosPost[] {
return this._posts;
}
}

View file

@ -1,105 +1,103 @@
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { MarkdownRepository } from './markdown-repository.js';
import { resolve, dirname } from 'path';
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { MarkdownRepository } from "./markdown-repository.js";
import { resolve, dirname } from "path";
import { aBlogPost } from './test-builders/blog-post-builder.js';
import { aSnoutStreetStudiosPost } from './test-builders/snout-street-studios-post-builder.js';
import { aBlogPost } from "./test-builders/blog-post-builder.js";
const blogPostImport = import.meta.glob(`./test-fixtures/blog-*.md`, { as: 'raw' });
const bookReviewImport = import.meta.glob(`./test-fixtures/book-review-*.md`, { as: 'raw' });
const snoutStreetPostImport = import.meta.glob(`./test-fixtures/snout-street-studio-*.md`, { as: 'raw' });
const blogPostImport = import.meta.glob(`./test-fixtures/blog-*.md`, {
as: "raw",
});
const bookReviewImport = import.meta.glob(`./test-fixtures/book-review-*.md`, {
as: "raw",
});
const snoutStreetPostImport = import.meta.glob(
`./test-fixtures/snout-street-studio-*.md`,
{ as: "raw" },
);
const expectedHtml = `<p>This is a blog post written in markdown.</p>
<p>This is a <a href="http://www.bbc.co.uk">link</a></p>`;
describe(`Blog MarkdownRepository`, () => {
let repository: MarkdownRepository;
beforeEach(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
);
});
it(`should load`, async () => {
// GIVEN
const expectedBlogPost = aBlogPost()
.withAuthor("Thomas Wilson")
.withDate(new Date("2023-02-01T08:00:00Z"))
.withSlug("2023-02-01-test")
.withTitle("Test Blog Post")
.withExcerpt("This is a blog post written in markdown. This is a link")
.withHtml(expectedHtml)
.withFileName("blog-2023-02-01-test.md")
.build();
// WHEN
const blogPost =
repository.blogPosts.getBlogPostWithTitle("Test Blog Post");
// THEN
expect(repository).toBeDefined();
expect(blogPost).toStrictEqual(expectedBlogPost);
});
it(`should automatically build all the blog posts and book reviews`, async () => {
// WHEN/THEN
expect(repository.blogPosts.blogPosts[0].html).not.toBeNull();
expect(repository.bookReviews.bookReviews[0].html).not.toBeNull();
});
describe(`Finding by Slug`, () => {
it(`should return null if there's neither a blog post nor a book review with the slug`, async () => {
// WHEN
const markdownFile = repository.getBlogPostBySlug("non-existent-slug");
// THEN
expect(markdownFile).toBeNull();
});
});
describe(`Deleting markdown files`, () => {
let repository: MarkdownRepository;
const currentDirectory = dirname(import.meta.url.replace("file://", ""));
beforeEach(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
snoutStreetPostImport
);
beforeAll(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
snoutStreetPostImport,
);
const resolvedPath = resolve(
`${currentDirectory}/test-fixtures/test-file.md`,
);
await repository.createBlogPostMarkdownFile(resolvedPath, expectedHtml);
});
it(`should load`, async () => {
// GIVEN
const expectedBlogPost = await aBlogPost()
.withAuthor('Thomas Wilson')
.withDate(new Date('2023-02-01T08:00:00Z'))
.withSlug('2023-02-01-test')
.withTitle('Test Blog Post')
.withExcerpt('This is a blog post written in markdown. This is a link')
.withHtml(expectedHtml)
.withFileName('blog-2023-02-01-test.md')
.build();
it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => {
// GIVEN
const theFileName = "non-existent-file.md";
const expectedSnoutStreetPost = aSnoutStreetStudiosPost()
.withSlug('the-test-slug')
.withTitle('Test Post')
.withDate(new Date('2023-09-02T06:40:00.000Z'))
.withExcerpt('This is a test post.')
.withHtml('<p>This is a test post.</p>')
.build();
// WHEN
const blogPost = repository.blogPosts.getBlogPostWithTitle('Test Blog Post');
const snoutStreetPosts = repository.snoutStreetStudiosPosts.posts;
// THEN
expect(repository).toBeDefined();
expect(blogPost).toStrictEqual(expectedBlogPost);
expect(snoutStreetPosts).toStrictEqual([expectedSnoutStreetPost]);
// WHEN/THEN
expect(async () =>
repository.deleteBlogPostMarkdownFile(theFileName),
).rejects.toThrowError(`File 'non-existent-file.md' not found.`);
});
it(`should automatically build all the blog posts and book reviews`, async () => {
// WHEN/THEN
expect(repository.blogPosts.blogPosts[0].html).not.toBeNull();
expect(repository.bookReviews.bookReviews[0].html).not.toBeNull();
});
describe(`Finding by Slug`, () => {
it(`should return null if there's neither a blog post nor a book review with the slug`, async () => {
// WHEN
const markdownFile = repository.getBlogPostBySlug('non-existent-slug');
// THEN
expect(markdownFile).toBeNull();
});
});
describe(`Deleting markdown files`, () => {
let repository: MarkdownRepository;
const currentDirectory = dirname(import.meta.url.replace('file://', ''));
beforeAll(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
snoutStreetPostImport
);
const resolvedPath = resolve(`${currentDirectory}/test-fixtures/test-file.md`);
await repository.createBlogPostMarkdownFile(resolvedPath, expectedHtml);
});
it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => {
// GIVEN
const theFileName = 'non-existent-file.md';
// WHEN/THEN
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);
});
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,231 +1,222 @@
import { writeFile, unlink, existsSync } from 'fs';
import { writeFile, unlink, existsSync } from "fs";
import { BlogPost } from './BlogPost.js';
import { MarkdownFile } from './MarkdownFile.js';
import { BlogPostSet } from './BlogPostSet.js';
import { BookReviewSet } from './BookReviewSet.js';
import { BookReview } from './BookReview.js';
import { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js';
import { SnoutStreetStudiosPostSet } from './SnoutStreetStudiosPostSet.js';
import { BlogPost } from "./BlogPost.js";
import { MarkdownFile } from "./MarkdownFile.js";
import { BlogPostSet } from "./BlogPostSet.js";
import { BookReviewSet } from "./BookReviewSet.js";
import { BookReview } from "./BookReview.js";
// We have to duplicate the `../..` here because import.meta must have a static string,
// and it (rightfully) cannot have dynamic locations
const blogPostMetaGlobImport = import.meta.glob(`../../content/blog/*.md`, { as: 'raw' });
const bookReviewsMetaGlobImport = import.meta.glob(`../../content/book-reviews/*.md`, { as: 'raw' });
const snoutStreetStudiosPostMetaGlobImport = import.meta.glob('../../content/snout-street-studios/*.md', { as: 'raw' });
const blogPostMetaGlobImport = import.meta.glob(`../../content/blog/*.md`, {
as: "raw",
});
const bookReviewsMetaGlobImport = import.meta.glob(
`../../content/book-reviews/*.md`,
{ as: "raw" },
);
interface BlogPostFrontmatterValues {
title: string;
slug: string;
date: Date;
author: string;
tags?: string[];
title: string;
slug: string;
date: Date;
author: string;
tags?: string[];
}
interface BookReviewFrontmatterValues {
title: string;
author: string; // Author of the book, not the review
slug: string;
date: Date;
finished: Date;
score: number;
image: string;
}
interface SnoutStreetStudiosPostFrontmatterValues {
title: string;
slug: string;
date: string;
title: string;
author: string; // Author of the book, not the review
slug: string;
date: Date;
finished: Date;
score: number;
image: string;
}
export class MarkdownRepository {
readonly blogPosts: BlogPostSet;
readonly bookReviews: BookReviewSet;
readonly snoutStreetStudiosPosts: SnoutStreetStudiosPostSet;
private static _singleton: MarkdownRepository;
readonly blogPosts: BlogPostSet;
readonly bookReviews: BookReviewSet;
private static _singleton: MarkdownRepository;
private constructor(
blogPosts: BlogPost[],
bookReviews: BookReview[],
snoutStreetStudiosPosts: SnoutStreetStudiosPost[]
) {
this.blogPosts = new BlogPostSet(blogPosts);
this.bookReviews = new BookReviewSet(bookReviews);
this.snoutStreetStudiosPosts = new SnoutStreetStudiosPostSet(snoutStreetStudiosPosts);
private constructor(blogPosts: BlogPost[], bookReviews: BookReview[]) {
this.blogPosts = new BlogPostSet(blogPosts);
this.bookReviews = new BookReviewSet(bookReviews);
}
public static async singleton(
forceRefresh = false,
): Promise<MarkdownRepository> {
if (forceRefresh || !this._singleton) {
console.log(
`[MarkdownRepository::singleton] Building MarkdownRepository singleton.`,
);
this._singleton = await MarkdownRepository.fromViteGlobImport(
blogPostMetaGlobImport,
bookReviewsMetaGlobImport,
);
}
public static async singleton(forceRefresh = false): Promise<MarkdownRepository> {
if (forceRefresh || !this._singleton) {
console.log(`[MarkdownRepository::singleton] Building MarkdownRepository singleton.`);
this._singleton = await MarkdownRepository.fromViteGlobImport(
blogPostMetaGlobImport,
bookReviewsMetaGlobImport,
snoutStreetStudiosPostMetaGlobImport
);
}
return this._singleton;
}
return this._singleton;
}
public static async fromViteGlobImport(
blogGlobImport: any,
bookReviewGlobImport: any,
): Promise<MarkdownRepository> {
let fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = [];
let blogPosts: BlogPost[] = [];
let bookReviews: BookReview[] = [];
public static async fromViteGlobImport(
blogGlobImport: any,
bookReviewGlobImport: any,
snoutStreetPostGlobImport: any
): Promise<MarkdownRepository> {
let fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = [];
let blogPosts: BlogPost[] = [];
let bookReviews: BookReview[] = [];
let snoutStreetPosts: SnoutStreetStudiosPost[] = [];
const blogPostFiles = Object.entries(blogGlobImport);
const blogPostFiles = Object.entries(blogGlobImport);
for (const blogPostFile of blogPostFiles) {
const [filename, module] = blogPostFile as [
string,
() => Promise<string>,
];
try {
const markdownFile =
await MarkdownFile.build<BlogPostFrontmatterValues>(
filename,
await module(),
);
for (const blogPostFile of blogPostFiles) {
const [filename, module] = blogPostFile as [string, () => Promise<string>];
try {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(filename, await module());
const blogPost = new BlogPost({
excerpt: markdownFile.excerpt,
html: markdownFile.html,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date,
fileName: filename,
tags: markdownFile.frontmatter.tags,
});
fileImports = [...fileImports, markdownFile];
blogPosts = [...blogPosts, blogPost];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
const [filename, module] = bookReviewFile as [string, () => Promise<string>];
try {
const markdownFile = await MarkdownFile.build<BookReviewFrontmatterValues>(filename, await module());
const bookReview = new BookReview({
author: markdownFile.frontmatter.author,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
date: markdownFile.frontmatter.date,
draft: false,
finished: markdownFile.frontmatter.finished,
image: markdownFile.frontmatter.image,
score: markdownFile.frontmatter.score,
html: markdownFile.html,
});
bookReviews = [...bookReviews, bookReview];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
for (const snoutStreetPostFile of Object.entries(snoutStreetPostGlobImport)) {
const [filename, module] = snoutStreetPostFile as [string, () => Promise<string>];
try {
const markdownFile = await MarkdownFile.build<SnoutStreetStudiosPostFrontmatterValues>(
filename,
await module()
);
const snoutStreetPost = new SnoutStreetStudiosPost({
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
date: new Date(markdownFile.frontmatter.date),
html: markdownFile.html,
excerpt: markdownFile.excerpt,
});
snoutStreetPosts = [...snoutStreetPosts, snoutStreetPost];
} catch (e: any) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
console.log(`[MarkdownRepository::fromViteGlobImport] Loaded ${fileImports.length} files.`);
const repository = new MarkdownRepository(blogPosts, bookReviews, snoutStreetPosts);
console.log(`[MarkdownRepository::fromViteGlobImport] Built all posts.`);
return repository;
}
getBlogPostBySlug(slug: string): BlogPost | null {
return this.blogPosts.blogPosts.find((blogPost) => blogPost.slug === slug) ?? null;
}
getBookReviewBySlug(slug: string): BookReview | null {
return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null;
}
getSnoutStreetStudiosPostBySlug(slug: string): SnoutStreetStudiosPost | null {
return this.snoutStreetStudiosPosts.posts.find((post) => post.slug === slug) ?? null;
}
async createBlogPostMarkdownFile(resolvedPath: string, contents: string): Promise<BlogPost> {
return new Promise<void>((resolve, reject) => {
writeFile(resolvedPath, contents, (err) => {
if (err) {
console.error({
message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvedPath}`,
err,
error: JSON.stringify(err),
});
reject(err);
}
resolve();
});
}).then(async () => {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(resolvedPath, contents);
const blogPost = new BlogPost({
html: markdownFile.html,
excerpt: markdownFile.excerpt,
title: markdownFile.frontmatter?.title ?? undefined,
slug: markdownFile.frontmatter?.slug ?? undefined,
author: markdownFile.frontmatter?.author ?? undefined,
date: markdownFile.frontmatter?.date ?? undefined,
fileName: resolvedPath,
tags: [],
});
return blogPost;
const blogPost = new BlogPost({
excerpt: markdownFile.excerpt,
html: markdownFile.html,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date,
fileName: filename,
tags: markdownFile.frontmatter.tags ?? [],
});
fileImports = [...fileImports, markdownFile];
blogPosts = [...blogPosts, blogPost];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
async deleteBlogPostMarkdownFile(resolvedFilePath: string): Promise<void> {
const isPresent = existsSync(resolvedFilePath);
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
const [filename, module] = bookReviewFile as [
string,
() => Promise<string>,
];
try {
const markdownFile =
await MarkdownFile.build<BookReviewFrontmatterValues>(
filename,
await module(),
);
if (!isPresent) {
throw `Sausages File '${resolvedFilePath}' not found.`;
const bookReview = new BookReview({
author: markdownFile.frontmatter.author,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
date: markdownFile.frontmatter.date,
draft: false,
finished: markdownFile.frontmatter.finished,
image: markdownFile.frontmatter.image,
score: markdownFile.frontmatter.score,
html: markdownFile.html,
});
bookReviews = [...bookReviews, bookReview];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
console.log(
`[MarkdownRepository::fromViteGlobImport] Loaded ${fileImports.length} files.`,
);
const repository = new MarkdownRepository(blogPosts, bookReviews);
console.log(`[MarkdownRepository::fromViteGlobImport] Built all posts.`);
return repository;
}
getBlogPostBySlug(slug: string): BlogPost | null {
return (
this.blogPosts.blogPosts.find((blogPost) => blogPost.slug === slug) ??
null
);
}
getBookReviewBySlug(slug: string): BookReview | null {
return (
this.bookReviews.bookReviews.find(
(bookReview) => bookReview.slug === slug,
) ?? null
);
}
async createBlogPostMarkdownFile(
resolvedPath: string,
contents: string,
): Promise<BlogPost> {
return new Promise<void>((resolve, reject) => {
writeFile(resolvedPath, contents, (err) => {
if (err) {
console.error({
message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvedPath}`,
err,
error: JSON.stringify(err),
});
reject(err);
}
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();
});
}).then(async () => {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(
resolvedPath,
contents,
);
resolve();
});
});
const blogPost = new BlogPost({
html: markdownFile.html,
excerpt: markdownFile.excerpt,
title: markdownFile.frontmatter?.title ?? undefined,
slug: markdownFile.frontmatter?.slug ?? undefined,
author: markdownFile.frontmatter?.author ?? undefined,
date: markdownFile.frontmatter?.date ?? undefined,
fileName: resolvedPath,
tags: [],
});
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

@ -1,48 +0,0 @@
import { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js';
class SnoutStreetStudiosPostBuilder {
private slug = 'the-default-slug';
private title = 'the-default-title';
private date = new Date('2000-01-01');
private html = 'the-default-html';
private excerpt = 'the-default-excerpt';
public withSlug(slug: string): SnoutStreetStudiosPostBuilder {
this.slug = slug;
return this;
}
public withTitle(title: string): SnoutStreetStudiosPostBuilder {
this.title = title;
return this;
}
public withDate(date: Date): SnoutStreetStudiosPostBuilder {
this.date = date;
return this;
}
public withHtml(html: string): SnoutStreetStudiosPostBuilder {
this.html = html;
return this;
}
public withExcerpt(excerpt: string): SnoutStreetStudiosPostBuilder {
this.excerpt = excerpt;
return this;
}
build(): SnoutStreetStudiosPost {
return new SnoutStreetStudiosPost({
slug: this.slug,
title: this.title,
date: this.date,
html: this.html,
excerpt: this.excerpt,
});
}
}
export function aSnoutStreetStudiosPost(): SnoutStreetStudiosPostBuilder {
return new SnoutStreetStudiosPostBuilder();
}

View file

@ -1,19 +0,0 @@
---
title: 'Test Post'
post_type: 'finished_project'
date: 2023-09-02T06:40:00.000Z
garment_birthday: 2023-09-01
slug: the-test-slug
labour_hours: '100'
elapsed_time: '5 weeks'
cloth_description: 'test cloth description'
cloth_link: 'https://www.example.com'
pattern_description: 'test pattern description'
pattern_link: 'https://www.pattern.com'
author: Thomas Wilson
images:
- cinnamon-dust-linen-shirt/2023-08-14-cinnamon-shirt.jpeg
---
This is a test post.

View file

@ -1,23 +0,0 @@
import type { SnoutStreetStudiosPost } from './SnoutStreetStudiosPost.js';
import type { BlogController } from '$lib/blog/BlogController.js';
export class SnoutStreetStudiosApiGateway {
constructor(private readonly controller: BlogController) {}
async getPostBySlug(slug: string): Promise<SnoutStreetStudiosPost | null> {
const post = await this.controller.getAnyKindOfContentBySlug(slug);
if (!post) {
return null;
}
return {
date: new Date(post.date),
slug: post.slug,
title: post.title,
html: post.content,
toJson: () => JSON.stringify(post),
excerpt: post.
};
}
}

View file

@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest';
import { SnoutStreetStudiosPost } from './SnoutStreetStudiosPost.js';
import { aSnoutStreetStudiosPost } from '$lib/blog/test-builders/snout-street-studios-post-builder.js';
describe('SnoutStreetStudiosPost', () => {
it(`should construct`, () => {
// WHEN
const post = new SnoutStreetStudiosPost({
title: 'the title',
slug: 'the-slug',
date: new Date('2023-09-02T06:58:00Z'),
html: 'the html',
});
// THEN
expect(post).toStrictEqual(
aSnoutStreetStudiosPost()
.withTitle('the title')
.withSlug('the-slug')
.withDate(new Date('2023-09-02T06:58:00Z'))
.withHtml('the html')
.withExcerpt(undefined)
.build()
);
});
});

View file

@ -1,74 +0,0 @@
import { z } from 'zod';
import type { SnoutStreetStudiosPostDto } from './SnoutStreetStudiosPostDto.js';
const SnoutStreetStudiosPostProps = z.object({
slug: z.string(),
title: z.string(),
date: z.date(),
html: z.string(),
});
// Make a props type from the zod schema, where the values are non-optional.
type Props = z.infer<typeof SnoutStreetStudiosPostProps> & {
excerpt: string;
html: string;
};
export class SnoutStreetStudiosPost {
public readonly slug: string;
public readonly title: string;
public readonly date: Date;
public readonly html: string;
public readonly excerpt: string;
constructor(props: Props) {
try {
const { slug, title, date, html } = SnoutStreetStudiosPostProps.parse(props);
this.slug = slug;
this.title = title;
this.date = date;
this.html = html;
this.excerpt = props.excerpt;
} catch (error) {
SnoutStreetStudiosPost.logAndThenThrowError(`Failed to construct post`, 'constructor', { props, error });
throw error;
}
}
private static logAndThenThrowError(errorMessage: string, contextName: string, ...args: any) {
console.error({
info: `Caught error in SnoutStreetStudiosPost::${contextName}`,
errorMessage,
...args,
});
throw new Error(errorMessage);
}
public toJson(): string {
const dto: SnoutStreetStudiosPostDto = {
slug: this.slug,
title: this.title,
date: this.date,
html: this.html,
excerpt: this.excerpt,
};
return JSON.stringify(dto);
}
public static fromJson(json: string): SnoutStreetStudiosPost {
try {
JSON.parse(json);
} catch {
this.logAndThenThrowError('Failed to parse JSON', 'fromJson', { json });
}
try {
const dto: SnoutStreetStudiosPostDto = JSON.parse(json);
return new SnoutStreetStudiosPost(dto);
} catch {
this.logAndThenThrowError(`Failed to construct post from JSON`, 'fromJson', { json });
}
}
}

View file

@ -1,7 +0,0 @@
export interface SnoutStreetStudiosPostDto {
slug: string;
title: string;
html: string;
date: Date;
excerpt: string;
}

View file

@ -1,3 +0,0 @@
export type { SnoutStreetStudiosPostDto } from './SnoutStreetStudiosPostDto.js';
export { SnoutStreetStudiosPost } from './SnoutStreetStudiosPost.js';
export { SnoutStreetStudiosApiGateway } from './ApiGateway.js';

View file

@ -1,25 +1,54 @@
import { BlogController } from '$lib/blog/BlogController.js';
import type { Load } from '@sveltejs/kit';
import { differenceInCalendarDays, getYear } from 'date-fns';
import {
BlogController,
type BlogItem,
type BlogPostListItem,
type BookReviewListItem,
} from "$lib/blog/BlogController.js";
import type { BookReview } from "$lib/blog/BookReview.js";
import type { Load } from "@sveltejs/kit";
import { differenceInCalendarDays, getYear } from "date-fns";
export const prerender = true;
type PostsGroupedByMonth = Array<{
yearDate: string;
posts: (BlogPostListItem | BookReviewListItem)[];
}>;
export const load: Load = async ({}) => {
const controller = await BlogController.singleton();
const posts = await controller.getAllBlogPosts();
const controller = await BlogController.singleton();
const posts = await controller.getAllBlogPosts();
const currentYear = getYear(new Date());
const currentYear = getYear(new Date());
const numberOfPosts = posts.length;
const firstPost = posts[numberOfPosts - 1];
const numberOfBlogPostsThisYear: number = posts.filter(
(post) => getYear(new Date(post.date)) === currentYear
).length;
const numberOfPosts = posts.length;
const firstPost = posts[numberOfPosts - 1];
const numberOfBlogPostsThisYear: number = posts.filter(
(post) => getYear(new Date(post.date)) === currentYear,
).length;
return {
posts,
firstPost,
numberOfPosts,
numberOfBlogPostsThisYear,
};
const postsGroupedByMonth = posts.reduce((grouped, post) => {
const yearDate = Intl.DateTimeFormat("en-gb", {
year: "numeric",
month: "long",
}).format(new Date(post.date));
const index = grouped.findIndex((entry) => entry.yearDate === yearDate);
if (index === -1) {
grouped.push({ yearDate, posts: [post] });
} else {
grouped[index].posts.push(post);
}
return grouped;
}, [] as PostsGroupedByMonth);
return {
posts,
postsGroupedByMonth,
firstPost,
numberOfPosts,
numberOfBlogPostsThisYear,
};
};

View file

@ -12,14 +12,22 @@
const { data }: Props = $props();
const { numberOfBlogPostsThisYear, numberOfPosts, posts } = data;
const {
numberOfBlogPostsThisYear,
numberOfPosts,
posts,
postsGroupedByMonth,
} = data;
const daysSinceFirstPost = $derived(
differenceInCalendarDays(new Date(), new Date(posts[posts.length - 1].date))
differenceInCalendarDays(
new Date(),
new Date(posts[posts.length - 1].date),
),
);
const daysSinceLastPublish = $derived(
differenceInCalendarDays(new Date(), new Date(posts[0].date))
differenceInCalendarDays(new Date(), new Date(posts[0].date)),
);
</script>
@ -40,24 +48,27 @@
/>
<section class="section">
<h2>All Writing</h2>
<ul class="posts">
{#each posts as post, index}
<BlogPostListItem
{index}
content_type={post.content_type}
book_review={post.content_type === "book_review"}
date={post.date}
numberOfPosts={posts.length}
preview={(post as any).preview ?? ""}
slug={post.slug}
title={post.title}
/>
{/each}
</ul>
{#each postsGroupedByMonth as month}
<h3>{month.yearDate}</h3>
<ul class="posts">
{#each month.posts as post, index}
<BlogPostListItem
{index}
content_type={post.content_type}
book_review={post.content_type === "book_review"}
date={post.date}
numberOfPosts={posts.length}
preview={(post as any).preview ?? ""}
slug={post.slug}
title={post.title}
/>
{/each}
</ul>
{/each}
</section>
</main>
<style lang="scss">
<style>
.page-title {
font-size: 2.5rem;
margin: 0;
@ -82,9 +93,10 @@
list-style: none;
margin: 0;
padding: 0;
padding-bottom: 24px;
display: grid;
grid-template-columns: 100%;
gap: var(--spacing-xl);
max-width: 100%;
gap: 12px;
}
</style>

View file

@ -1,11 +0,0 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<h1>Snout St. Studios</h1>
{@render children?.()}

View file

@ -1,10 +0,0 @@
import type { LoadEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
export async function load({ url }: LoadEvent) {
if (url.hostname !== 'localhost') {
return error(404, 'Not found');
}
return {};
}