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,7 +1,18 @@
import { describe, it, beforeEach, afterAll, beforeAll, expect, afterEach } from 'vitest'; import {
import { BlogController } from './BlogController.js'; describe,
import { MarkdownRepository } from './markdown-repository.js'; it,
import { exampleMarkdown, exampleMarkdownFrontmatter } from './test-fixtures/example-markdown.js'; 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`, () => { describe(`BlogController`, () => {
let controller: BlogController; let controller: BlogController;
@ -16,23 +27,27 @@ describe(`BlogController`, () => {
const blogPosts = await controller.getAllBlogPosts(); const blogPosts = await controller.getAllBlogPosts();
// WHEN // WHEN
const aKnownBlogPost = blogPosts.find((post) => post.title === 'Vibe Check #10'); const aKnownBlogPost = blogPosts.find(
const aKnownBookReview = blogPosts.find((post) => post.title === 'After'); (post) => post.title === "Vibe Check #10",
const aKnownSnoutStreetStudiosPost = blogPosts.find((post) => post.title === 'Cinnamon Dust Linen Shirt'); );
const aMadeUpBlogPost = blogPosts.find((post) => post.title === 'Some made up blog post'); const aKnownBookReview = blogPosts.find((post) => post.title === "After");
const aMadeUpBlogPost = blogPosts.find(
(post) => post.title === "Some made up blog post",
);
// then // then
expect(aMadeUpBlogPost).toBeUndefined(); expect(aMadeUpBlogPost).toBeUndefined();
expect(aKnownBlogPost).not.toBeUndefined(); expect(aKnownBlogPost).not.toBeUndefined();
expect(aKnownBookReview).not.toBeUndefined(); expect(aKnownBookReview).not.toBeUndefined();
expect(aKnownSnoutStreetStudiosPost).not.toBeUndefined();
}); });
}); });
describe(`getBlogPostBySlug`, () => { describe(`getBlogPostBySlug`, () => {
it(`should return null when the post doesn't exist`, async () => { it(`should return null when the post doesn't exist`, async () => {
// When // When
const shouldBeNull = await controller.getBlogPostBySlug('some-made-up-blog-post'); const shouldBeNull = await controller.getBlogPostBySlug(
"some-made-up-blog-post",
);
// Then // Then
expect(shouldBeNull).toBeNull(); expect(shouldBeNull).toBeNull();
@ -40,23 +55,26 @@ describe(`BlogController`, () => {
it(`should return the blog post when it exists`, async () => { it(`should return the blog post when it exists`, async () => {
// When // When
const blogPost = await controller.getBlogPostBySlug('2023-02-03-vibe-check-10'); const blogPost = await controller.getBlogPostBySlug(
"2023-02-03-vibe-check-10",
);
// Then // Then
expect(blogPost).not.toBeNull(); expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe('Vibe Check #10'); expect(blogPost.title).toBe("Vibe Check #10");
}); });
}); });
describe(`Finding content by slug`, () => { describe(`Finding content by slug`, () => {
describe(`Finding a blog post`, () => { describe(`Finding a blog post`, () => {
// GIVEN // GIVEN
const slugForRealBlogPost = '2023-02-03-vibe-check-10'; const slugForRealBlogPost = "2023-02-03-vibe-check-10";
const slugForFakeBlogPost = 'some-made-up-blog-post'; const slugForFakeBlogPost = "some-made-up-blog-post";
it(`should return null if there's no blog post with the slug`, async () => { it(`should return null if there's no blog post with the slug`, async () => {
// WHEN // WHEN
const blogPost = await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost); const blogPost =
await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost);
// THEN // THEN
expect(blogPost).toBeNull(); expect(blogPost).toBeNull();
@ -64,17 +82,18 @@ describe(`BlogController`, () => {
it(`should return the blog post if it exists`, async () => { it(`should return the blog post if it exists`, async () => {
// WHEN // WHEN
const blogPost = await controller.getAnyKindOfContentBySlug(slugForRealBlogPost); const blogPost =
await controller.getAnyKindOfContentBySlug(slugForRealBlogPost);
// THEN // THEN
expect(blogPost).not.toBeNull(); expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe('Vibe Check #10'); expect(blogPost.title).toBe("Vibe Check #10");
}); });
}); });
describe(`Finding a book review`, () => { describe(`Finding a book review`, () => {
const realSlug = 'after'; const realSlug = "after";
const fakeSlug = 'some-made-up-book-review'; const fakeSlug = "some-made-up-book-review";
it(`should return null if there's no book review with the slug`, async () => { it(`should return null if there's no book review with the slug`, async () => {
// WHEN // WHEN
@ -90,39 +109,17 @@ describe(`BlogController`, () => {
// THEN // THEN
expect(bookReview).not.toBeNull(); expect(bookReview).not.toBeNull();
expect(bookReview.title).toBe('After'); 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`, () => { describe(`Creating a new blog post as a file`, () => {
const thisDirectory = import.meta.url const thisDirectory = import.meta.url
.replace('file://', '') .replace("file://", "")
.split('/') .split("/")
.filter((part) => part !== 'BlogController.test.ts') .filter((part) => part !== "BlogController.test.ts")
.join('/'); .join("/");
const fileName = `${thisDirectory}/test-fixtures/test-blog-controller.md`; const fileName = `${thisDirectory}/test-fixtures/test-blog-controller.md`;
let controller: BlogController; let controller: BlogController;
@ -139,7 +136,10 @@ describe(`BlogController`, () => {
const markdownContent = exampleMarkdown; const markdownContent = exampleMarkdown;
// WHEN // WHEN
const blogPost = await controller.createBlogPost(fileName, markdownContent); const blogPost = await controller.createBlogPost(
fileName,
markdownContent,
);
// THEN // THEN
expect(blogPost).not.toBeNull(); expect(blogPost).not.toBeNull();

View file

@ -1,18 +1,17 @@
import type { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js'; import type { BlogPost } from "./BlogPost.js";
import type { BlogPost } from './BlogPost.js'; import type { BookReview } from "./BookReview.js";
import type { BookReview } from './BookReview.js'; import { MarkdownRepository } from "./markdown-repository.js";
import { MarkdownRepository } from './markdown-repository.js';
interface BlogItem { export interface BlogItem {
title: string; title: string;
date: string; date: string;
content: string; content: string;
slug: string; slug: string;
content_type: 'blog' | 'book_review' | 'snout_street_studios'; content_type: "blog" | "book_review" | "snout_street_studios";
tags?: string[]; tags?: string[];
} }
interface BlogPostListItem extends BlogItem { export interface BlogPostListItem extends BlogItem {
title: string; title: string;
author: string; author: string;
date: string; date: string;
@ -21,7 +20,7 @@ interface BlogPostListItem extends BlogItem {
tags: string[]; tags: string[];
} }
interface BookReviewListItem extends BlogItem { export interface BookReviewListItem extends BlogItem {
book_review: true; book_review: true;
title: string; title: string;
author: string; author: string;
@ -31,12 +30,6 @@ interface BookReviewListItem extends BlogItem {
finished: string; finished: string;
} }
interface SnoutStreetStudiosPostListItem extends BlogItem {
title: string;
slug: string;
date: string;
}
export class BlogController { export class BlogController {
private _markdownRepository: MarkdownRepository; private _markdownRepository: MarkdownRepository;
@ -53,38 +46,39 @@ export class BlogController {
return this._markdownRepository; return this._markdownRepository;
} }
async createBlogPost(resolvedFileName: string, markdownContent: string): Promise<BlogPost> { async createBlogPost(
const createdBlogPost = await this._markdownRepository.createBlogPostMarkdownFile( resolvedFileName: string,
markdownContent: string,
): Promise<BlogPost> {
const createdBlogPost =
await this._markdownRepository.createBlogPostMarkdownFile(
resolvedFileName, resolvedFileName,
markdownContent markdownContent,
); );
this._markdownRepository = await MarkdownRepository.singleton(true); this._markdownRepository = await MarkdownRepository.singleton(true);
return createdBlogPost; return createdBlogPost;
} }
async getAllBlogPosts( async getAllBlogPosts(
pageSize?: number pageSize?: number,
): Promise<Array<BlogPostListItem | BookReviewListItem | SnoutStreetStudiosPostListItem>> { ): Promise<Array<BlogPostListItem | BookReviewListItem>> {
const blogPosts = this._markdownRepository.blogPosts; const blogPosts = this._markdownRepository.blogPosts;
const bookReviews = this._markdownRepository.bookReviews; const bookReviews = this._markdownRepository.bookReviews;
const snoutStreetStudiosPosts = this._markdownRepository.snoutStreetStudiosPosts; const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map(
(blogPost) => {
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map((blogPost) => {
return this.blogPostToBlogPostListItem(blogPost); return this.blogPostToBlogPostListItem(blogPost);
}); },
);
const bookReviewListItems: BookReviewListItem[] = bookReviews.bookReviews.map((bookReview) => { const bookReviewListItems: BookReviewListItem[] =
bookReviews.bookReviews.map((bookReview) => {
return this.bookReviewToBookReviewListItem(bookReview); return this.bookReviewToBookReviewListItem(bookReview);
}); });
const snoutStreetStudiosPostListItems: SnoutStreetStudiosPostListItem[] = snoutStreetStudiosPosts.posts.map( const allBlogPosts = [...blogPostListItems, ...bookReviewListItems].sort(
(post) => this.snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(post) (a, b) => (a.date > b.date ? -1 : 1),
);
const allBlogPosts = [...blogPostListItems, ...bookReviewListItems, ...snoutStreetStudiosPostListItems].sort(
(a, b) => (a.date > b.date ? -1 : 1)
); );
if (pageSize === undefined) { if (pageSize === undefined) {
@ -95,7 +89,7 @@ export class BlogController {
} }
async getBlogPostBySlug(slug: string): Promise<BlogPostListItem | null> { async getBlogPostBySlug(slug: string): Promise<BlogPostListItem | null> {
const blogPost = await this._markdownRepository.getBlogPostBySlug(slug); const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) { if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost); return this.blogPostToBlogPostListItem(blogPost);
} }
@ -105,35 +99,35 @@ export class BlogController {
async getBlogPostsByTags(tags: string[]): Promise<BlogPostListItem[]> { async getBlogPostsByTags(tags: string[]): Promise<BlogPostListItem[]> {
const posts = await this.getAllBlogPosts(); const posts = await this.getAllBlogPosts();
const blogPosts = posts.filter((post) => post.content_type === 'blog') as BlogPostListItem[]; const blogPosts = posts.filter(
(post) => post.content_type === "blog",
) as BlogPostListItem[];
return blogPosts return blogPosts
.filter((post: BlogPostListItem) => post['tags']?.length > 0) .filter((post: BlogPostListItem) => post["tags"]?.length > 0)
.filter((post: BlogPostListItem) => (post.tags as string[]).some((tag) => tags.includes(tag))); .filter((post: BlogPostListItem) =>
(post.tags as string[]).some((tag) => tags.includes(tag)),
);
} }
async getAnyKindOfContentBySlug( async getAnyKindOfContentBySlug(
slug: string slug: string,
): Promise<BookReviewListItem | BlogPostListItem | SnoutStreetStudiosPostListItem | null> { ): Promise<BookReviewListItem | BlogPostListItem | null> {
const blogPost = await this._markdownRepository.getBlogPostBySlug(slug); const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) { if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost); return this.blogPostToBlogPostListItem(blogPost);
} }
const bookReview = await this._markdownRepository.getBookReviewBySlug(slug); const bookReview = this._markdownRepository.getBookReviewBySlug(slug);
if (bookReview) { if (bookReview) {
return this.bookReviewToBookReviewListItem(bookReview); return this.bookReviewToBookReviewListItem(bookReview);
} }
const snoutStreetStudiosPost = await this._markdownRepository.getSnoutStreetStudiosPostBySlug(slug);
if (snoutStreetStudiosPost) {
return this.snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(snoutStreetStudiosPost);
}
return null; return null;
} }
private bookReviewToBookReviewListItem(bookReview: BookReview): BookReviewListItem { private bookReviewToBookReviewListItem(
bookReview: BookReview,
): BookReviewListItem {
return { return {
book_review: true, book_review: true,
title: bookReview.title, title: bookReview.title,
@ -144,7 +138,7 @@ export class BlogController {
score: bookReview.score, score: bookReview.score,
slug: bookReview.slug, slug: bookReview.slug,
content: bookReview.html, content: bookReview.html,
content_type: 'book_review', content_type: "book_review",
}; };
} }
@ -157,20 +151,8 @@ export class BlogController {
date: blogPost.date.toISOString(), date: blogPost.date.toISOString(),
preview: blogPost.excerpt, preview: blogPost.excerpt,
slug: blogPost.slug, slug: blogPost.slug,
content_type: 'blog', content_type: "blog",
tags: blogPost.tags, 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,
};
}
} }

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,13 +1,19 @@
import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { MarkdownRepository } from './markdown-repository.js'; import { MarkdownRepository } from "./markdown-repository.js";
import { resolve, dirname } from 'path'; import { resolve, dirname } from "path";
import { aBlogPost } from './test-builders/blog-post-builder.js'; import { aBlogPost } from "./test-builders/blog-post-builder.js";
import { aSnoutStreetStudiosPost } from './test-builders/snout-street-studios-post-builder.js';
const blogPostImport = import.meta.glob(`./test-fixtures/blog-*.md`, { as: 'raw' }); const blogPostImport = import.meta.glob(`./test-fixtures/blog-*.md`, {
const bookReviewImport = import.meta.glob(`./test-fixtures/book-review-*.md`, { as: 'raw' }); as: "raw",
const snoutStreetPostImport = import.meta.glob(`./test-fixtures/snout-street-studio-*.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> 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>`; <p>This is a <a href="http://www.bbc.co.uk">link</a></p>`;
@ -19,38 +25,28 @@ describe(`Blog MarkdownRepository`, () => {
repository = await MarkdownRepository.fromViteGlobImport( repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport, blogPostImport,
bookReviewImport, bookReviewImport,
snoutStreetPostImport
); );
}); });
it(`should load`, async () => { it(`should load`, async () => {
// GIVEN // GIVEN
const expectedBlogPost = await aBlogPost() const expectedBlogPost = aBlogPost()
.withAuthor('Thomas Wilson') .withAuthor("Thomas Wilson")
.withDate(new Date('2023-02-01T08:00:00Z')) .withDate(new Date("2023-02-01T08:00:00Z"))
.withSlug('2023-02-01-test') .withSlug("2023-02-01-test")
.withTitle('Test Blog Post') .withTitle("Test Blog Post")
.withExcerpt('This is a blog post written in markdown. This is a link') .withExcerpt("This is a blog post written in markdown. This is a link")
.withHtml(expectedHtml) .withHtml(expectedHtml)
.withFileName('blog-2023-02-01-test.md') .withFileName("blog-2023-02-01-test.md")
.build();
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(); .build();
// WHEN // WHEN
const blogPost = repository.blogPosts.getBlogPostWithTitle('Test Blog Post'); const blogPost =
const snoutStreetPosts = repository.snoutStreetStudiosPosts.posts; repository.blogPosts.getBlogPostWithTitle("Test Blog Post");
// THEN // THEN
expect(repository).toBeDefined(); expect(repository).toBeDefined();
expect(blogPost).toStrictEqual(expectedBlogPost); expect(blogPost).toStrictEqual(expectedBlogPost);
expect(snoutStreetPosts).toStrictEqual([expectedSnoutStreetPost]);
}); });
it(`should automatically build all the blog posts and book reviews`, async () => { it(`should automatically build all the blog posts and book reviews`, async () => {
@ -62,7 +58,7 @@ describe(`Blog MarkdownRepository`, () => {
describe(`Finding by Slug`, () => { describe(`Finding by Slug`, () => {
it(`should return null if there's neither a blog post nor a book review with the slug`, async () => { it(`should return null if there's neither a blog post nor a book review with the slug`, async () => {
// WHEN // WHEN
const markdownFile = repository.getBlogPostBySlug('non-existent-slug'); const markdownFile = repository.getBlogPostBySlug("non-existent-slug");
// THEN // THEN
expect(markdownFile).toBeNull(); expect(markdownFile).toBeNull();
@ -71,27 +67,29 @@ describe(`Blog MarkdownRepository`, () => {
describe(`Deleting markdown files`, () => { describe(`Deleting markdown files`, () => {
let repository: MarkdownRepository; let repository: MarkdownRepository;
const currentDirectory = dirname(import.meta.url.replace('file://', '')); const currentDirectory = dirname(import.meta.url.replace("file://", ""));
beforeAll(async () => { beforeAll(async () => {
repository = await MarkdownRepository.fromViteGlobImport( repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport, blogPostImport,
bookReviewImport, bookReviewImport,
snoutStreetPostImport snoutStreetPostImport,
); );
const resolvedPath = resolve(`${currentDirectory}/test-fixtures/test-file.md`); const resolvedPath = resolve(
`${currentDirectory}/test-fixtures/test-file.md`,
);
await repository.createBlogPostMarkdownFile(resolvedPath, expectedHtml); await repository.createBlogPostMarkdownFile(resolvedPath, expectedHtml);
}); });
it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => { it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => {
// GIVEN // GIVEN
const theFileName = 'non-existent-file.md'; const theFileName = "non-existent-file.md";
// WHEN/THEN // WHEN/THEN
expect(async () => repository.deleteBlogPostMarkdownFile(theFileName)).rejects.toThrowError( expect(async () =>
`File 'non-existent-file.md' not found.` repository.deleteBlogPostMarkdownFile(theFileName),
); ).rejects.toThrowError(`File 'non-existent-file.md' not found.`);
}); });
it(`should successfully delete a file when it does exist`, async () => { it(`should successfully delete a file when it does exist`, async () => {

View file

@ -1,18 +1,20 @@
import { writeFile, unlink, existsSync } from 'fs'; import { writeFile, unlink, existsSync } from "fs";
import { BlogPost } from './BlogPost.js'; import { BlogPost } from "./BlogPost.js";
import { MarkdownFile } from './MarkdownFile.js'; import { MarkdownFile } from "./MarkdownFile.js";
import { BlogPostSet } from './BlogPostSet.js'; import { BlogPostSet } from "./BlogPostSet.js";
import { BookReviewSet } from './BookReviewSet.js'; import { BookReviewSet } from "./BookReviewSet.js";
import { BookReview } from './BookReview.js'; import { BookReview } from "./BookReview.js";
import { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js';
import { SnoutStreetStudiosPostSet } from './SnoutStreetStudiosPostSet.js';
// We have to duplicate the `../..` here because import.meta must have a static string, // We have to duplicate the `../..` here because import.meta must have a static string,
// and it (rightfully) cannot have dynamic locations // and it (rightfully) cannot have dynamic locations
const blogPostMetaGlobImport = import.meta.glob(`../../content/blog/*.md`, { as: 'raw' }); const blogPostMetaGlobImport = import.meta.glob(`../../content/blog/*.md`, {
const bookReviewsMetaGlobImport = import.meta.glob(`../../content/book-reviews/*.md`, { as: 'raw' }); as: "raw",
const snoutStreetStudiosPostMetaGlobImport = import.meta.glob('../../content/snout-street-studios/*.md', { as: 'raw' }); });
const bookReviewsMetaGlobImport = import.meta.glob(
`../../content/book-reviews/*.md`,
{ as: "raw" },
);
interface BlogPostFrontmatterValues { interface BlogPostFrontmatterValues {
title: string; title: string;
@ -32,35 +34,26 @@ interface BookReviewFrontmatterValues {
image: string; image: string;
} }
interface SnoutStreetStudiosPostFrontmatterValues {
title: string;
slug: string;
date: string;
}
export class MarkdownRepository { export class MarkdownRepository {
readonly blogPosts: BlogPostSet; readonly blogPosts: BlogPostSet;
readonly bookReviews: BookReviewSet; readonly bookReviews: BookReviewSet;
readonly snoutStreetStudiosPosts: SnoutStreetStudiosPostSet;
private static _singleton: MarkdownRepository; private static _singleton: MarkdownRepository;
private constructor( private constructor(blogPosts: BlogPost[], bookReviews: BookReview[]) {
blogPosts: BlogPost[],
bookReviews: BookReview[],
snoutStreetStudiosPosts: SnoutStreetStudiosPost[]
) {
this.blogPosts = new BlogPostSet(blogPosts); this.blogPosts = new BlogPostSet(blogPosts);
this.bookReviews = new BookReviewSet(bookReviews); this.bookReviews = new BookReviewSet(bookReviews);
this.snoutStreetStudiosPosts = new SnoutStreetStudiosPostSet(snoutStreetStudiosPosts);
} }
public static async singleton(forceRefresh = false): Promise<MarkdownRepository> { public static async singleton(
forceRefresh = false,
): Promise<MarkdownRepository> {
if (forceRefresh || !this._singleton) { if (forceRefresh || !this._singleton) {
console.log(`[MarkdownRepository::singleton] Building MarkdownRepository singleton.`); console.log(
`[MarkdownRepository::singleton] Building MarkdownRepository singleton.`,
);
this._singleton = await MarkdownRepository.fromViteGlobImport( this._singleton = await MarkdownRepository.fromViteGlobImport(
blogPostMetaGlobImport, blogPostMetaGlobImport,
bookReviewsMetaGlobImport, bookReviewsMetaGlobImport,
snoutStreetStudiosPostMetaGlobImport
); );
} }
@ -70,19 +63,24 @@ export class MarkdownRepository {
public static async fromViteGlobImport( public static async fromViteGlobImport(
blogGlobImport: any, blogGlobImport: any,
bookReviewGlobImport: any, bookReviewGlobImport: any,
snoutStreetPostGlobImport: any
): Promise<MarkdownRepository> { ): Promise<MarkdownRepository> {
let fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = []; let fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = [];
let blogPosts: BlogPost[] = []; let blogPosts: BlogPost[] = [];
let bookReviews: BookReview[] = []; let bookReviews: BookReview[] = [];
let snoutStreetPosts: SnoutStreetStudiosPost[] = [];
const blogPostFiles = Object.entries(blogGlobImport); const blogPostFiles = Object.entries(blogGlobImport);
for (const blogPostFile of blogPostFiles) { for (const blogPostFile of blogPostFiles) {
const [filename, module] = blogPostFile as [string, () => Promise<string>]; const [filename, module] = blogPostFile as [
string,
() => Promise<string>,
];
try { try {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(filename, await module()); const markdownFile =
await MarkdownFile.build<BlogPostFrontmatterValues>(
filename,
await module(),
);
const blogPost = new BlogPost({ const blogPost = new BlogPost({
excerpt: markdownFile.excerpt, excerpt: markdownFile.excerpt,
@ -92,7 +90,7 @@ export class MarkdownRepository {
author: markdownFile.frontmatter.author, author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date, date: markdownFile.frontmatter.date,
fileName: filename, fileName: filename,
tags: markdownFile.frontmatter.tags, tags: markdownFile.frontmatter.tags ?? [],
}); });
fileImports = [...fileImports, markdownFile]; fileImports = [...fileImports, markdownFile];
@ -106,9 +104,16 @@ export class MarkdownRepository {
} }
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) { for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
const [filename, module] = bookReviewFile as [string, () => Promise<string>]; const [filename, module] = bookReviewFile as [
string,
() => Promise<string>,
];
try { try {
const markdownFile = await MarkdownFile.build<BookReviewFrontmatterValues>(filename, await module()); const markdownFile =
await MarkdownFile.build<BookReviewFrontmatterValues>(
filename,
await module(),
);
const bookReview = new BookReview({ const bookReview = new BookReview({
author: markdownFile.frontmatter.author, author: markdownFile.frontmatter.author,
@ -131,50 +136,33 @@ export class MarkdownRepository {
} }
} }
for (const snoutStreetPostFile of Object.entries(snoutStreetPostGlobImport)) { console.log(
const [filename, module] = snoutStreetPostFile as [string, () => Promise<string>]; `[MarkdownRepository::fromViteGlobImport] Loaded ${fileImports.length} files.`,
try {
const markdownFile = await MarkdownFile.build<SnoutStreetStudiosPostFrontmatterValues>(
filename,
await module()
); );
const repository = new MarkdownRepository(blogPosts, bookReviews);
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.`); console.log(`[MarkdownRepository::fromViteGlobImport] Built all posts.`);
return repository; return repository;
} }
getBlogPostBySlug(slug: string): BlogPost | null { getBlogPostBySlug(slug: string): BlogPost | null {
return this.blogPosts.blogPosts.find((blogPost) => blogPost.slug === slug) ?? null; return (
this.blogPosts.blogPosts.find((blogPost) => blogPost.slug === slug) ??
null
);
} }
getBookReviewBySlug(slug: string): BookReview | null { getBookReviewBySlug(slug: string): BookReview | null {
return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null; return (
this.bookReviews.bookReviews.find(
(bookReview) => bookReview.slug === slug,
) ?? null
);
} }
getSnoutStreetStudiosPostBySlug(slug: string): SnoutStreetStudiosPost | null { async createBlogPostMarkdownFile(
return this.snoutStreetStudiosPosts.posts.find((post) => post.slug === slug) ?? null; resolvedPath: string,
} contents: string,
): Promise<BlogPost> {
async createBlogPostMarkdownFile(resolvedPath: string, contents: string): Promise<BlogPost> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
writeFile(resolvedPath, contents, (err) => { writeFile(resolvedPath, contents, (err) => {
if (err) { if (err) {
@ -189,7 +177,10 @@ export class MarkdownRepository {
resolve(); resolve();
}); });
}).then(async () => { }).then(async () => {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(resolvedPath, contents); const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(
resolvedPath,
contents,
);
const blogPost = new BlogPost({ const blogPost = new BlogPost({
html: markdownFile.html, html: markdownFile.html,

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,9 +1,20 @@
import { BlogController } from '$lib/blog/BlogController.js'; import {
import type { Load } from '@sveltejs/kit'; BlogController,
import { differenceInCalendarDays, getYear } from 'date-fns'; 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; export const prerender = true;
type PostsGroupedByMonth = Array<{
yearDate: string;
posts: (BlogPostListItem | BookReviewListItem)[];
}>;
export const load: Load = async ({}) => { export const load: Load = async ({}) => {
const controller = await BlogController.singleton(); const controller = await BlogController.singleton();
const posts = await controller.getAllBlogPosts(); const posts = await controller.getAllBlogPosts();
@ -13,11 +24,29 @@ export const load: Load = async ({}) => {
const numberOfPosts = posts.length; const numberOfPosts = posts.length;
const firstPost = posts[numberOfPosts - 1]; const firstPost = posts[numberOfPosts - 1];
const numberOfBlogPostsThisYear: number = posts.filter( const numberOfBlogPostsThisYear: number = posts.filter(
(post) => getYear(new Date(post.date)) === currentYear (post) => getYear(new Date(post.date)) === currentYear,
).length; ).length;
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 { return {
posts, posts,
postsGroupedByMonth,
firstPost, firstPost,
numberOfPosts, numberOfPosts,
numberOfBlogPostsThisYear, numberOfBlogPostsThisYear,

View file

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