diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b5045a7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+static/snout-street-studios/** filter=lfs diff=lfs merge=lfs -text
diff --git a/package.json b/package.json
index 6631a61..d153542 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,6 @@
"strip-markdown": "^5.0.0",
"to-vfile": "^7.2.3",
"unified": "^10.1.2",
- "zod": "^3.18.0"
+ "zod": "^3.22.2"
}
}
diff --git a/src/components/salary-calculator/employee.svelte b/src/components/salary-calculator/employee.svelte
index 5035121..2f75a11 100644
--- a/src/components/salary-calculator/employee.svelte
+++ b/src/components/salary-calculator/employee.svelte
@@ -1,69 +1,68 @@
diff --git a/src/content/snout-street-studios/2023-08-cinnamon-shirt.md b/src/content/snout-street-studios/2023-08-cinnamon-shirt.md
new file mode 100644
index 0000000..8608d29
--- /dev/null
+++ b/src/content/snout-street-studios/2023-08-cinnamon-shirt.md
@@ -0,0 +1,42 @@
+---
+title: 'Cinnamon Dust Linen Shirt'
+post_type: 'finished_project'
+date: 2023-08-14T16:54:00.000Z
+garment_birthday: 2023-08-14
+slug: 2023-08-cinnamon-dust-linen-shirt
+labour_hours: '10-15'
+elapsed_time: '1 week'
+cloth_description: 'Cinnamon Dust 185 Linen'
+cloth_link: 'https://merchantandmills.com/uk/cinnamon-dust-185-linen-cloth'
+pattern_description: 'Wardrobe by Me - Jensen Shirt'
+pattern_link: 'https://wardrobebyme.com/products/jensen-shirt-sewing-pattern'
+author: Thomas Wilson
+images:
+ - cinnamon-dust-linen-shirt/2023-08-14-cinnamon-shirt.jpeg
+
+---
+
+This is another step in my Wedding Suit project - where I am making each piece of my wedding outfit. Less than twelve months now. The shirt came out really nicely, pretty clean, and I don't have anything else in my wardrobe that's a similar colour - it's a nice break from both cloth (linen, not cotton) and colour (I've a lot of whites, greys, blues)
+
+This shirt is going to go into summer/autumn rotation - I am excited to wear it. In particular I'm pretty proud of:
+
+- Overall construction of cuffs, collars, and buttons - the details are starting to feel less home-made and more hand-made.
+- The edge stitching around the cuffs and collar: The lines are getting straighter and more consistent !
+- Button positioning and stitching looks nice. I've fluffed this before and you get gathering/bunching of fabric ):
+
+Unfortunately, the garment has come out pretty baggy around the torso, which is great for a breezy summer linen shirt, but I think for a more formal shirt I need to make some more alterations before the next project.
+
+The whole process took about a week, working most evenings and spending a few hours over the weekend to do the hand-finishing details. Long enough that I will appreciate wearing it, but not so long that I got bored.
+
+---
+
+The fit, Good:
+
+1. Using a self-drafted sleeve placket has made for good results, I like the placket size
+2. Length of the piece is basically spot on
+
+The fit, To change:
+
+1. For a formal shirt, the piece is _far_ too big on me, I am but a wee lad. I think I can take 6" out the hips/waist, and 2-3" out of the chest.
+2. Shoulders are _okay_ when the top button is done up, but could be 0.5-1" narrower
+3. Sleeve could be 0.5-1" shorter (cuff comes too far down)
diff --git a/src/lib/blog/BlogController.test.ts b/src/lib/blog/BlogController.test.ts
index 63b8006..f725d54 100644
--- a/src/lib/blog/BlogController.test.ts
+++ b/src/lib/blog/BlogController.test.ts
@@ -10,7 +10,7 @@ describe(`BlogController`, () => {
controller = await BlogController.singleton();
});
- describe(`Getting all blog posts and book reviews`, () => {
+ 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();
@@ -18,16 +18,18 @@ describe(`BlogController`, () => {
// 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();
});
});
- describe(`Finding a blog post or book review by slug`, () => {
+ describe(`Finding content by slug`, () => {
describe(`Finding a blog post`, () => {
// GIVEN
const slugForRealBlogPost = '2023-02-03-vibe-check-10';
@@ -35,7 +37,7 @@ describe(`BlogController`, () => {
it(`should return null if there's no blog post with the slug`, async () => {
// WHEN
- const blogPost = await controller.getBlogOrBookReviewBySlug(slugForFakeBlogPost);
+ const blogPost = await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost);
// THEN
expect(blogPost).toBeNull();
@@ -43,7 +45,7 @@ describe(`BlogController`, () => {
it(`should return the blog post if it exists`, async () => {
// WHEN
- const blogPost = await controller.getBlogOrBookReviewBySlug(slugForRealBlogPost);
+ const blogPost = await controller.getAnyKindOfContentBySlug(slugForRealBlogPost);
// THEN
expect(blogPost).not.toBeNull();
@@ -57,7 +59,7 @@ describe(`BlogController`, () => {
it(`should return null if there's no book review with the slug`, async () => {
// WHEN
- const bookReview = await controller.getBlogOrBookReviewBySlug(fakeSlug);
+ const bookReview = await controller.getAnyKindOfContentBySlug(fakeSlug);
// THEN
expect(bookReview).toBeNull();
@@ -65,13 +67,35 @@ describe(`BlogController`, () => {
it(`should return the book review if it exists`, async () => {
// WHEN
- const bookReview = await controller.getBlogOrBookReviewBySlug(realSlug);
+ 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`, () => {
diff --git a/src/lib/blog/BlogController.ts b/src/lib/blog/BlogController.ts
index 3d40093..9c8ecfc 100644
--- a/src/lib/blog/BlogController.ts
+++ b/src/lib/blog/BlogController.ts
@@ -1,21 +1,25 @@
+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';
-const blogPostMetaGlobImport = import.meta.glob('../../content/blog/*.md', { as: 'raw' });
-const bookReviewsMetaGlobImport = import.meta.glob('../../content/book-reviews/*.md', { as: 'raw' });
+interface BlogItem {
+ title: string;
+ date: string;
+ content: string;
+ slug: string;
+ content_type: 'blog' | 'book_review' | 'snout_street_studios';
+}
-interface BlogPostListItem {
+interface BlogPostListItem extends BlogItem {
title: string;
author: string;
date: string;
book_review: boolean;
preview: string;
- content: string;
- slug: string;
}
-interface BookReviewListItem {
+interface BookReviewListItem extends BlogItem {
book_review: true;
title: string;
author: string;
@@ -23,8 +27,12 @@ interface BookReviewListItem {
slug: string;
score: number;
finished: string;
+}
+
+interface SnoutStreetStudiosPostListItem extends BlogItem {
+ title: string;
+ slug: string;
date: string;
- content: string;
}
export class BlogController {
@@ -52,11 +60,13 @@ export class BlogController {
return createdBlogPost;
}
- async getAllBlogPosts(): Promise> {
+ async getAllBlogPosts(): Promise> {
const blogPosts = await this._markdownRepository.blogPosts;
const bookReviews = await this._markdownRepository.bookReviews;
+ const snoutStreetStudiosPosts = await this._markdownRepository.snoutStreetStudiosPosts;
+
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map((blogPost) => {
return this.blogPostToBlogPostListItem(blogPost);
});
@@ -65,10 +75,16 @@ export class BlogController {
return this.bookReviewToBookReviewListItem(bookReview);
});
- return [...blogPostListItems, ...bookReviewListItems].sort((a, b) => (a.date > b.date ? -1 : 1));
+ const snoutStreetStudiosPostListItems: SnoutStreetStudiosPostListItem[] = snoutStreetStudiosPosts.posts.map(
+ (post) => this.snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(post)
+ );
+
+ return [...blogPostListItems, ...bookReviewListItems, ...snoutStreetStudiosPostListItems].sort((a, b) =>
+ a.date > b.date ? -1 : 1
+ );
}
- private bookReviewToBookReviewListItem(bookReview: BookReview, includeHtml = false): BookReviewListItem {
+ private bookReviewToBookReviewListItem(bookReview: BookReview): BookReviewListItem {
return {
book_review: true,
title: bookReview.title,
@@ -78,31 +94,53 @@ export class BlogController {
image: bookReview.image,
score: bookReview.score,
slug: bookReview.slug,
- content: includeHtml ? bookReview.html : '',
+ content: 'bookReview.html',
+ content_type: 'book_review',
};
}
- private blogPostToBlogPostListItem(blogPost: BlogPost, includeHtml = false): BlogPostListItem {
+ private blogPostToBlogPostListItem(blogPost: BlogPost): BlogPostListItem {
return {
title: blogPost.title,
author: blogPost.author,
book_review: false,
- content: includeHtml ? blogPost.html : '',
+ content: blogPost.html,
date: blogPost.date.toISOString(),
preview: blogPost.excerpt,
slug: blogPost.slug,
+ content_type: 'blog',
};
}
- async getBlogOrBookReviewBySlug(slug: string): Promise {
+ private snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(
+ post: SnoutStreetStudiosPost
+ ): SnoutStreetStudiosPostListItem {
+ return {
+ title: post.title,
+ slug: post.slug,
+ date: post.date.toISOString(),
+ content_type: 'snout_street_studios',
+ content: post.html,
+ };
+ }
+
+ async getAnyKindOfContentBySlug(
+ slug: string
+ ): Promise {
const blogPost = await this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
- return this.blogPostToBlogPostListItem(blogPost, true);
+ return this.blogPostToBlogPostListItem(blogPost);
}
const bookReview = await this._markdownRepository.getBookReviewBySlug(slug);
if (bookReview) {
- return this.bookReviewToBookReviewListItem(bookReview, true);
+ return this.bookReviewToBookReviewListItem(bookReview);
+ }
+
+ const snoutStreetStudiosPost = await this._markdownRepository.getSnoutStreetStudiosPostBySlug(slug);
+
+ if (snoutStreetStudiosPost) {
+ return this.snoutStreetStudiosPostToSnoutStreetStudiosPostListItem(snoutStreetStudiosPost);
}
return null;
diff --git a/src/lib/blog/BlogPost.test.ts b/src/lib/blog/BlogPost.test.ts
index a98e6e8..c03ca09 100644
--- a/src/lib/blog/BlogPost.test.ts
+++ b/src/lib/blog/BlogPost.test.ts
@@ -2,23 +2,6 @@ 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 heading
-
-This is a [link](http://www.bbc.co.uk).
-
-- This is a list item
-- This is another list item
-`;
-
describe('BlogPost', () => {
describe(`Constructing`, () => {
it(`should construct`, async () => {
@@ -28,86 +11,25 @@ describe('BlogPost', () => {
author: 'Test Author',
date: new Date('2022-01-01T00:00Z'),
slug: 'test-slug',
- markdownContent: 'Test Content',
fileName: `the-file-name.md`,
+ html: 'Test Content',
+ excerpt: 'Test Excerpt',
});
- // 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')
+ .withHtml('Test Content')
+ .withExcerpt('Test Excerpt')
.withFileName(`the-file-name.md`)
- .constructAndThenBuild();
+ .build();
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 = await aBlogPost().withMarkdownContent(exampleMarkdownWithFrontMatter).constructAndThenBuild();
-
- // WHEN
- const html = blogPost.html;
-
- // THEN
- expect(html).toStrictEqual(
- [
- `This is the content of the blog post.
`,
- `\This is a heading
`,
- `This is a link.
`,
- ``,
- `- This is a list item
`,
- `- This is another list item
`,
- `
`,
- ].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 4a10672..824d233 100644
--- a/src/lib/blog/BlogPost.ts
+++ b/src/lib/blog/BlogPost.ts
@@ -1,21 +1,11 @@
-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;
fileName: string; // excluding any leading `..`
+ html: string;
+ excerpt: string;
}
export class BlogPost {
@@ -23,85 +13,17 @@ export class BlogPost {
readonly date: Date;
readonly author: string;
readonly slug: string;
- readonly markdownContent: string;
readonly fileName: string;
-
- private _html: string | null = null;
- private _excerpt: string | null = null;
+ public readonly html: string;
+ public readonly excerpt: string;
constructor(params: BlogPostParams) {
this.title = params.title;
this.date = params.date;
this.author = params.author;
this.slug = params.slug;
- this.markdownContent = params.markdownContent;
this.fileName = params.fileName.split(`/`)[-1];
- }
-
- 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);
- this._html = html.toString();
- return this._html;
- }
-
- private markdownToHtmlProcessorFactory(): Processor {
- return unified() //
- .use(markdown)
- .use(markdownFrontmatter)
- .use(remarkStringify)
- .use(remarkRehype, { allowDangerousHtml: true })
- .use(rehypeStringify, {
- allowDangerousHtml: true,
- allowDangerousCharacters: true,
- });
- }
-
- private markdownToExcerptProcessorFactory(): Processor {
- return remark()
- .use(markdown)
- .use(remarkFrontmatter)
- .use(stripMarkdown, { remove: ['list'] });
+ this.html = params.html;
+ this.excerpt = params.excerpt;
}
}
diff --git a/src/lib/blog/BlogPostSet.test.ts b/src/lib/blog/BlogPostSet.test.ts
index 0874ab1..421629b 100644
--- a/src/lib/blog/BlogPostSet.test.ts
+++ b/src/lib/blog/BlogPostSet.test.ts
@@ -1,5 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
-import { BlogPost } from './BlogPost.js';
import { BlogPostSet } from './BlogPostSet.js';
import { aBlogPost } from './test-builders/blog-post-builder.js';
describe(`BlogPostSet`, () => {
@@ -15,19 +14,6 @@ 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 cc1dc5a..3b0a36a 100644
--- a/src/lib/blog/BlogPostSet.ts
+++ b/src/lib/blog/BlogPostSet.ts
@@ -14,8 +14,4 @@ export class BlogPostSet {
getBlogPostWithTitle(title: string): BlogPost | 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/BookReview.test.ts b/src/lib/blog/BookReview.test.ts
index acf3983..98e02ba 100644
--- a/src/lib/blog/BookReview.test.ts
+++ b/src/lib/blog/BookReview.test.ts
@@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest';
import { BookReview } from './BookReview.js';
import { aBookReview } from './test-builders/book-review-builder.js';
-const exampleBookReview = `---
+const exampleBookReviewMarkdown = `---
title: "After"
author: "Dr Bruce Greyson"
score: 3.5
@@ -30,7 +30,7 @@ describe(`BookReview`, () => {
date: new Date('2021-05-05'),
finished: new Date('2021-04-20'),
draft: false,
- markdownContent: exampleBookReview,
+ html: 'the test html',
});
// WHEN
@@ -42,31 +42,10 @@ describe(`BookReview`, () => {
.withSlug('after')
.withDate(new Date('2021-05-05'))
.withFinished(new Date('2021-04-20'))
- .withMarkdownContent(exampleBookReview)
+ .withHtml('the test html')
.build();
// THEN
expect(bookReview).toEqual(expectedBookReview);
});
-
- it(`should build the HTML`, async () => {
- // GIVEN
- const bookReview = aBookReview().withMarkdownContent(exampleBookReview).build();
-
- // WHEN
- await bookReview.build();
-
- // THEN
- expect(bookReview.html).toEqual(
- 'This link a book review written in markdown.
'
- );
- });
-
- it(`should not have the HTML built by default`, () => {
- // GIVEN
- const bookReview = aBookReview().withMarkdownContent(exampleBookReview).build();
-
- // WHEN/THEN
- expect(bookReview.html).toBeNull();
- });
});
diff --git a/src/lib/blog/BookReview.ts b/src/lib/blog/BookReview.ts
index bc79032..e80e405 100644
--- a/src/lib/blog/BookReview.ts
+++ b/src/lib/blog/BookReview.ts
@@ -1,11 +1,3 @@
-import type { Processor } from 'unified';
-import { unified } from 'unified';
-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';
-
interface BookReviewProps {
title: string;
author: string;
@@ -15,7 +7,7 @@ interface BookReviewProps {
date: Date;
finished: Date;
draft: boolean;
- markdownContent: string;
+ html: string;
}
export class BookReview {
@@ -26,8 +18,7 @@ export class BookReview {
readonly slug: string;
readonly date: Date;
readonly finished: Date;
- private readonly markdownContent: string;
- private _html: string | null = null;
+ readonly html: string;
constructor(props: BookReviewProps) {
this.title = props.title;
@@ -37,33 +28,6 @@ export class BookReview {
this.slug = props.slug;
this.date = props.date;
this.finished = props.finished;
- this.markdownContent = props.markdownContent;
- }
-
- private htmlProcessorFactory(): Processor {
- return unified() //
- .use(markdown)
- .use(markdownFrontmatter)
- .use(remarkStringify)
- .use(remarkRehype)
- .use(rehypeStringify);
- }
-
- async build(): Promise {
- await this.getHtml();
- }
-
- async getHtml(): Promise {
- if (this._html === null) {
- const processor = this.htmlProcessorFactory();
- const value = await processor.process(this.markdownContent);
- this._html = value.toString();
- }
-
- return this._html;
- }
-
- get html(): string | null {
- return this._html;
+ this.html = props.html;
}
}
diff --git a/src/lib/blog/BookReviewSet.test.ts b/src/lib/blog/BookReviewSet.test.ts
index 0e25fdf..afcde6c 100644
--- a/src/lib/blog/BookReviewSet.test.ts
+++ b/src/lib/blog/BookReviewSet.test.ts
@@ -17,8 +17,8 @@ describe(`BookReviewSet`, () => {
it(`should build all the HTML contents`, async () => {
// GIVEN
- const bookReview = aBookReview().withTitle(`The title`).withMarkdownContent('test').build();
- const anotherBookReview = aBookReview().withTitle(`Another title`).withMarkdownContent('test').build();
+ const bookReview = aBookReview().withTitle(`The title`).withHtml('test').build();
+ const anotherBookReview = aBookReview().withTitle(`Another title`).withHtml('test').build();
const bookReviewSet = new BookReviewSet([bookReview, anotherBookReview]);
// WHEN
diff --git a/src/lib/blog/MarkdownFile.test.ts b/src/lib/blog/MarkdownFile.test.ts
index a460d99..a8dd145 100644
--- a/src/lib/blog/MarkdownFile.test.ts
+++ b/src/lib/blog/MarkdownFile.test.ts
@@ -11,26 +11,27 @@ This is the content of the blog post.
`;
describe(`MarkdownFile`, () => {
- it(`should construct`, () => {
+ it(`should construct`, async () => {
// GIVEN
const fileName = 'example.md';
const content = 'This is a test';
// WHEN
- const markdownFile = new MarkdownFile({ fileName, content });
+ const markdownFile = await MarkdownFile.build(fileName, content);
// THEN
expect(markdownFile.fileName).toBe(fileName);
expect(markdownFile.content).toBe(content);
+ expect(markdownFile.html).toStrictEqual('This is a test
');
});
- it(`Should get the front matter`, () => {
+ it(`Should get the front matter`, async () => {
// GIVEN
const fileName = 'example.md';
const content = exampleMarkdownWithFrontMatter;
// WHEN
- const markdownFile = new MarkdownFile({ fileName, content });
+ const markdownFile = await MarkdownFile.build(fileName, content);
// THEN
expect(markdownFile.frontmatter).toStrictEqual({
@@ -39,4 +40,16 @@ describe(`MarkdownFile`, () => {
slug: '2023-02-01-test',
});
});
+
+ it(`shoukd get the excerpt`, async () => {
+ // GICEN
+ const fileName = 'example.md';
+ const content = exampleMarkdownWithFrontMatter;
+
+ // WHEN
+ const markdownFile = await MarkdownFile.build(fileName, content);
+
+ // THEN
+ expect(markdownFile.excerpt).toBe('This is the content of the blog post.');
+ });
});
diff --git a/src/lib/blog/MarkdownFile.ts b/src/lib/blog/MarkdownFile.ts
index a6f9b47..9cb6885 100644
--- a/src/lib/blog/MarkdownFile.ts
+++ b/src/lib/blog/MarkdownFile.ts
@@ -1,41 +1,46 @@
-import { unified, type Processor } from 'unified';
-import type { Parent, Node, Literal } from 'unist';
-
-import markdown from 'remark-parse';
-import markdownFrontmatter from 'remark-frontmatter';
-import remarkStringify from 'remark-stringify';
-import { load as loadYaml } from 'js-yaml';
+import { MarkdownBuilder } from './markdown/markdown-builder.js';
interface MarkdownFileProps {
fileName: string;
+ /** The raw contents of the .md file */
content: string;
}
export class MarkdownFile> {
readonly fileName: string;
readonly content: string;
- readonly frontmatter: FrontMatter | undefined = undefined;
+ private _frontmatter: FrontMatter | null = null;
+ private _html: string | null = null;
+ private _excerpt: string | null = null;
- constructor(props: MarkdownFileProps) {
+ private constructor(props: MarkdownFileProps) {
this.fileName = props.fileName;
this.content = props.content;
-
- const processor = this.markdownProcesserFactory();
- const parsedMarkdown: Parent = processor.parse(this.content) as Parent;
-
- const frontmatterNode: Literal | undefined = parsedMarkdown.children.find((node) => node.type === 'yaml');
-
- if (frontmatterNode !== undefined) {
- const frontmatter = loadYaml(frontmatterNode.value as string);
- this.frontmatter = frontmatter as FrontMatter;
- } else {
- console.warn(`Markdown file ${this.fileName} does not contain frontmatter.`);
- }
}
- private markdownProcesserFactory(): Processor {
- return unified() //
- .use(markdown)
- .use(markdownFrontmatter)
- .use(remarkStringify);
+ get html(): string | null {
+ return this._html;
+ }
+
+ get frontmatter(): FrontMatter | null {
+ return this._frontmatter;
+ }
+
+ get excerpt(): string | null {
+ return this._excerpt;
+ }
+
+ static async build>(
+ theFileName: string,
+ theFileContents: string
+ ): Promise> {
+ const markdownFile = new MarkdownFile({ fileName: theFileName, content: theFileContents });
+ await markdownFile.build();
+ return markdownFile;
+ }
+
+ private async build(): Promise {
+ this._html = await MarkdownBuilder.getHtml(this.content);
+ this._excerpt = await MarkdownBuilder.getExcerptFromMarkdown(this.content);
+ this._frontmatter = MarkdownBuilder.getFrontmatter(this.content, this.fileName);
}
}
diff --git a/src/lib/blog/SnoutStreetStudiosPostSet.test.ts b/src/lib/blog/SnoutStreetStudiosPostSet.test.ts
new file mode 100644
index 0000000..2e3d707
--- /dev/null
+++ b/src/lib/blog/SnoutStreetStudiosPostSet.test.ts
@@ -0,0 +1,17 @@
+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]);
+ });
+});
diff --git a/src/lib/blog/SnoutStreetStudiosPostSet.ts b/src/lib/blog/SnoutStreetStudiosPostSet.ts
new file mode 100644
index 0000000..e854e78
--- /dev/null
+++ b/src/lib/blog/SnoutStreetStudiosPostSet.ts
@@ -0,0 +1,13 @@
+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;
+ }
+}
diff --git a/src/lib/blog/markdown-repository.test.ts b/src/lib/blog/markdown-repository.test.ts
index f321d5a..fa94044 100644
--- a/src/lib/blog/markdown-repository.test.ts
+++ b/src/lib/blog/markdown-repository.test.ts
@@ -1,64 +1,63 @@
-import { describe, it, expect, beforeAll } from 'vitest';
+import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { MarkdownRepository } from './markdown-repository.js';
import { resolve, dirname } from 'path';
-import { MarkdownFile } from './MarkdownFile.js';
import { aBlogPost } from './test-builders/blog-post-builder.js';
+import { aSnoutStreetStudiosPost } from './test-builders/snout-street-studios-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 testMarkdownContent = `---
-title: "Test Blog Post"
-author: "Thomas Wilson"
-date: 2023-02-01T08:00:00Z
-slug: "2023-02-01-test"
-draft: false
----
-
-This is a blog post written in markdown.
-
-This is a [link](http://www.bbc.co.uk)
-`;
+const expectedHtml = `This is a blog post written in markdown.
+This is a link
`;
describe(`Blog MarkdownRepository`, () => {
+ let repository: MarkdownRepository;
+
+ beforeEach(async () => {
+ repository = await MarkdownRepository.fromViteGlobImport(
+ blogPostImport,
+ bookReviewImport,
+ snoutStreetPostImport
+ );
+ });
+
it(`should load`, async () => {
// GIVEN
- const repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
-
const expectedBlogPost = await aBlogPost()
.withAuthor('Thomas Wilson')
.withDate(new Date('2023-02-01T08:00:00Z'))
.withSlug('2023-02-01-test')
.withTitle('Test Blog Post')
- .withMarkdownContent(testMarkdownContent)
+ .withExcerpt('This is a blog post written in markdown.')
+ .withHtml(expectedHtml)
.withFileName('blog-2023-02-01-test.md')
- .constructAndThenBuild();
+ .build();
+
+ const expectedSnoutStreetPost = aSnoutStreetStudiosPost()
+ .withSlug('the-test-slug')
+ .withTitle('Test Post')
+ .withDate(new Date('2023-09-02T06:40:00.000Z'))
+ .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]);
});
it(`should automatically build all the blog posts and book reviews`, async () => {
- // GIVEN
- const repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
-
// WHEN/THEN
expect(repository.blogPosts.blogPosts[0].html).not.toBeNull();
expect(repository.bookReviews.bookReviews[0].html).not.toBeNull();
});
describe(`Finding by Slug`, () => {
- let repository: MarkdownRepository;
-
- beforeAll(async () => {
- repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
- });
-
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');
@@ -73,10 +72,14 @@ describe(`Blog MarkdownRepository`, () => {
const currentDirectory = dirname(import.meta.url.replace('file://', ''));
beforeAll(async () => {
- repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
+ repository = await MarkdownRepository.fromViteGlobImport(
+ blogPostImport,
+ bookReviewImport,
+ snoutStreetPostImport
+ );
const resolvedPath = resolve(`${currentDirectory}/test-fixtures/test-file.md`);
- await repository.createBlogPostMarkdownFile(resolvedPath, testMarkdownContent);
+ await repository.createBlogPostMarkdownFile(resolvedPath, expectedHtml);
});
it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => {
diff --git a/src/lib/blog/markdown-repository.ts b/src/lib/blog/markdown-repository.ts
index 89cdde7..7fa9497 100644
--- a/src/lib/blog/markdown-repository.ts
+++ b/src/lib/blog/markdown-repository.ts
@@ -1,4 +1,3 @@
-import { resolve } from 'path';
import { writeFile, unlink, existsSync } from 'fs';
import { BlogPost } from './BlogPost.js';
@@ -6,12 +5,17 @@ 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 { MarkdownBuilder } from './markdown/markdown-builder.js';
// We have to duplicate the `../..` here because import.meta must have a static string,
// and it (rightfully) cannot have dynamic locations
-const blogPostMarkdownDirectory = `../../content/blog`;
const blogPostMetaGlobImport = import.meta.glob(`../../content/blog/*.md`, { as: 'raw' });
-const bookReviewsMetaGlobImport = import.meta.glob('../../content/book-reviews/*.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',
+});
interface BlogPostFrontmatterValues {
title: string;
@@ -30,37 +34,55 @@ interface BookReviewFrontmatterValues {
image: string;
}
+interface SnoutStreetStudiosPostFrontmatterValues {
+ title: string;
+ slug: string;
+ date: string;
+}
+
export class MarkdownRepository {
readonly blogPosts: BlogPostSet;
readonly bookReviews: BookReviewSet;
+ readonly snoutStreetStudiosPosts: SnoutStreetStudiosPostSet;
- private constructor(blogPosts: BlogPost[], bookReviews: BookReview[]) {
+ private constructor(
+ blogPosts: BlogPost[],
+ bookReviews: BookReview[],
+ snoutStreetStudiosPosts: SnoutStreetStudiosPost[]
+ ) {
this.blogPosts = new BlogPostSet(blogPosts);
this.bookReviews = new BookReviewSet(bookReviews);
+ this.snoutStreetStudiosPosts = new SnoutStreetStudiosPostSet(snoutStreetStudiosPosts);
}
public static async singleton(): Promise {
- return await MarkdownRepository.fromViteGlobImport(blogPostMetaGlobImport, bookReviewsMetaGlobImport);
+ return await MarkdownRepository.fromViteGlobImport(
+ blogPostMetaGlobImport,
+ bookReviewsMetaGlobImport,
+ snoutStreetStudiosPostMetaGlobImport
+ );
}
- public static async fromViteGlobImport(blogGlobImport, bookReviewGlobImport): Promise {
+ public static async fromViteGlobImport(
+ blogGlobImport,
+ bookReviewGlobImport,
+ snoutStreetPostGlobImport
+ ): Promise {
let fileImports: MarkdownFile[] = [];
let blogPosts: BlogPost[] = [];
let bookReviews: BookReview[] = [];
+ let snoutStreetPosts: SnoutStreetStudiosPost[] = [];
const blogPostFiles = Object.entries(blogGlobImport);
for (const blogPostFile of blogPostFiles) {
const [filename, module] = blogPostFile as [string, () => Promise];
try {
- const fileContent = await module();
+ const markdownFile = await MarkdownFile.build(filename, await module());
- const markdownFile = new MarkdownFile({
- fileName: filename,
- content: fileContent,
- });
const blogPost = new BlogPost({
- markdownContent: markdownFile.content,
+ excerpt: markdownFile.excerpt,
+ html: markdownFile.html,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author,
@@ -81,12 +103,7 @@ export class MarkdownRepository {
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
const [filename, module] = bookReviewFile as [string, () => Promise];
try {
- const fileContent = await module();
-
- const markdownFile = new MarkdownFile({
- fileName: filename,
- content: fileContent,
- });
+ const markdownFile = await MarkdownFile.build(filename, await module());
const bookReview = new BookReview({
author: markdownFile.frontmatter.author,
@@ -97,7 +114,7 @@ export class MarkdownRepository {
finished: markdownFile.frontmatter.finished,
image: markdownFile.frontmatter.image,
score: markdownFile.frontmatter.score,
- markdownContent: markdownFile.content,
+ html: markdownFile.html,
});
bookReviews = [...bookReviews, bookReview];
@@ -109,14 +126,35 @@ export class MarkdownRepository {
}
}
- const repository = new MarkdownRepository(blogPosts, bookReviews);
- await repository.buildAll();
- return repository;
- }
+ for (const snoutStreetPostFile of Object.entries(snoutStreetPostGlobImport)) {
+ const [filename, module] = snoutStreetPostFile as [string, () => Promise];
+ try {
+ const markdownFile = await MarkdownFile.build(
+ filename,
+ await module()
+ );
- private async buildAll() {
- await Promise.all([this.blogPosts.buildAllBlogPosts(), this.bookReviews.buildAllBookReviews()]);
- return;
+ 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 {
@@ -127,12 +165,16 @@ export class MarkdownRepository {
return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null;
}
- async createBlogPostMarkdownFile(resolvdePath: string, contents: string): Promise {
+ getSnoutStreetStudiosPostBySlug(slug: string): SnoutStreetStudiosPost | null {
+ return this.snoutStreetStudiosPosts.posts.find((post) => post.slug === slug) ?? null;
+ }
+
+ async createBlogPostMarkdownFile(resolvedPath: string, contents: string): Promise {
return new Promise((resolve, reject) => {
- writeFile(resolvdePath, contents, (err) => {
+ writeFile(resolvedPath, contents, (err) => {
if (err) {
console.error({
- message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvdePath}`,
+ message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvedPath}`,
err,
error: JSON.stringify(err),
});
@@ -141,28 +183,21 @@ export class MarkdownRepository {
resolve();
});
- })
- .then(() => {
- const markdownFile = new MarkdownFile({
- fileName: resolvdePath,
- content: contents,
- });
+ }).then(async () => {
+ const markdownFile = await MarkdownFile.build(resolvedPath, contents);
- const blogPost = new BlogPost({
- markdownContent: markdownFile.content,
- title: markdownFile.frontmatter.title,
- slug: markdownFile.frontmatter.slug,
- author: markdownFile.frontmatter.author,
- date: markdownFile.frontmatter.date,
- fileName: resolvdePath,
- });
-
- return blogPost;
- })
- .then(async (blogPost: BlogPost) => {
- blogPost.build();
- return blogPost;
+ const blogPost = new BlogPost({
+ html: markdownFile.html,
+ excerpt: markdownFile.excerpt,
+ title: markdownFile.frontmatter.title,
+ slug: markdownFile.frontmatter.slug,
+ author: markdownFile.frontmatter.author,
+ date: markdownFile.frontmatter.date,
+ fileName: resolvedPath,
});
+
+ return blogPost;
+ });
}
async deleteBlogPostMarkdownFile(resolvedFilePath: string): Promise {
diff --git a/src/lib/blog/markdown/markdown-builder.test.ts b/src/lib/blog/markdown/markdown-builder.test.ts
new file mode 100644
index 0000000..2a1bd09
--- /dev/null
+++ b/src/lib/blog/markdown/markdown-builder.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest';
+import { MarkdownBuilder } from './markdown-builder.js';
+
+const exampleMarkdown = [
+ `---`,
+ `title: "This is a title"`,
+ `---`,
+ `This is a title. This is a body.`,
+ `This is an incredibly long new set`,
+ `of words to read. I hope you`,
+ `enjoy reading them. I hope you`,
+ `enjoy reading them. I hope you`,
+].join('\n');
+
+describe(`MarkdownBuilder`, () => {
+ // const markdownBuilder = new MarkdownBuilder();
+
+ it(`should build an excerpt`, async () => {
+ // GIVEN
+ const markdown = exampleMarkdown;
+
+ // WHEN
+ const excerpt = await MarkdownBuilder.getExcerptFromMarkdown(markdown, 10);
+
+ // THEN
+ expect(excerpt).toBe(`This is a title. This is a body. This is`);
+ });
+});
diff --git a/src/lib/blog/markdown/markdown-builder.ts b/src/lib/blog/markdown/markdown-builder.ts
new file mode 100644
index 0000000..c58766d
--- /dev/null
+++ b/src/lib/blog/markdown/markdown-builder.ts
@@ -0,0 +1,76 @@
+import { unified } from 'unified';
+import type { Processor } from 'unified';
+import remarkParse from 'remark-parse';
+import remarkFrontmatter from 'remark-frontmatter';
+import remarkStringify from 'remark-stringify';
+import remarkRehype from 'remark-rehype';
+import rehypeStringify from 'rehype-stringify';
+import stripMarkdown from 'strip-markdown';
+import type { Parent, Literal } from 'unist';
+import { load as loadYaml } from 'js-yaml';
+
+type MarkdownDocumentType = 'body' | 'excerpt';
+
+export class MarkdownBuilder {
+ static async getHtml(markdownContent: string): Promise {
+ const processor = this.getDocumentProcessor();
+ const value = await processor.process(markdownContent);
+ return value.toString();
+ }
+
+ static getFrontmatter>(markdownContent: string, fileName: string): T | null {
+ const processor = this.getFrontmatterProcessor();
+ const parsedMarkdown: Parent = processor.parse(markdownContent) as Parent;
+
+ const frontmatterNode: Literal | undefined = parsedMarkdown.children.find((node) => node.type === 'yaml');
+
+ if (frontmatterNode !== undefined) {
+ const frontmatter = loadYaml(frontmatterNode.value as string);
+ return frontmatter as T;
+ } else {
+ console.warn(`Markdown file ${fileName} does not contain frontmatter.`);
+ return null;
+ }
+ }
+
+ static async getExcerptFromMarkdown(markdownContent: string, wordLength = 50): Promise {
+ const initialTextContent = await this.getExcerptMarkdownProcessor().process(markdownContent);
+
+ const textValueWithNoLinebreaks = initialTextContent.toString();
+
+ return textValueWithNoLinebreaks
+ .replaceAll('\r', ' ')
+ .replaceAll('\n', ' ')
+ .split(' ')
+ .filter((word) => word !== ' ' && word !== '')
+ .slice(0, wordLength)
+ .join(' ');
+ }
+
+ private static getFrontmatterProcessor(): Processor {
+ return unified() //
+ .use(remarkParse)
+ .use(remarkFrontmatter)
+ .use(remarkStringify);
+ }
+
+ private static getExcerptMarkdownProcessor(): Processor {
+ return unified()
+ .use(remarkParse)
+ .use(remarkStringify)
+ .use(remarkFrontmatter, { type: 'yaml', marker: '-' })
+ .use(stripMarkdown);
+ }
+
+ static getDocumentProcessor(): Processor {
+ return unified() //
+ .use(remarkParse)
+ .use(remarkFrontmatter)
+ .use(remarkStringify)
+ .use(remarkRehype, { allowDangerousHtml: true })
+ .use(rehypeStringify, {
+ allowDangerousHtml: true,
+ allowDangerousCharacters: true,
+ });
+ }
+}
diff --git a/src/lib/blog/test-builders/blog-post-builder.ts b/src/lib/blog/test-builders/blog-post-builder.ts
index 8e91af9..4f15179 100644
--- a/src/lib/blog/test-builders/blog-post-builder.ts
+++ b/src/lib/blog/test-builders/blog-post-builder.ts
@@ -2,20 +2,25 @@ import { BlogPost } from '../BlogPost.js';
class BlogPostBuilder {
private _title = 'default title';
+ private _html = 'default html';
+ private _excerpt = 'default excerpt';
private _author = 'default author';
private _date = new Date('2022-01-01T00:00Z');
private _slug = 'default-slug';
private _fileName = 'default-file-name.md';
- private _markdownContent = 'default markdown content';
-
withTitle(title: string): BlogPostBuilder {
this._title = title;
return this;
}
- withMarkdownContent(markdownContent: string): BlogPostBuilder {
- this._markdownContent = markdownContent;
+ withHtml(markdownContent: string): BlogPostBuilder {
+ this._html = markdownContent;
+ return this;
+ }
+
+ withExcerpt(excerpt: string): BlogPostBuilder {
+ this._excerpt = excerpt;
return this;
}
@@ -39,20 +44,15 @@ class BlogPostBuilder {
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,
+ html: this._html,
author: this._author,
date: this._date,
slug: this._slug,
fileName: this._fileName,
+ excerpt: this._excerpt,
});
}
}
diff --git a/src/lib/blog/test-builders/book-review-builder.ts b/src/lib/blog/test-builders/book-review-builder.ts
index 35fec1a..c06e0ec 100644
--- a/src/lib/blog/test-builders/book-review-builder.ts
+++ b/src/lib/blog/test-builders/book-review-builder.ts
@@ -9,7 +9,7 @@ class BookReviewBuilder {
private image = 'default image';
private score = 0;
private slug = 'default slug';
- private markdownContent = 'default markdown content';
+ private html = 'default markdown content';
withTitle(title: string): BookReviewBuilder {
this.title = title;
@@ -51,8 +51,8 @@ class BookReviewBuilder {
return this;
}
- withMarkdownContent(markdownContent: string): BookReviewBuilder {
- this.markdownContent = markdownContent;
+ withHtml(html: string): BookReviewBuilder {
+ this.html = html;
return this;
}
@@ -66,7 +66,7 @@ class BookReviewBuilder {
image: this.image,
score: this.score,
slug: this.slug,
- markdownContent: this.markdownContent,
+ html: this.html,
});
}
}
diff --git a/src/lib/blog/test-builders/index.ts b/src/lib/blog/test-builders/index.ts
index d713fc2..65498d9 100644
--- a/src/lib/blog/test-builders/index.ts
+++ b/src/lib/blog/test-builders/index.ts
@@ -1 +1,2 @@
export { aBlogPost } from './blog-post-builder.js';
+export { aSnoutStreetStudiosPost } from './snout-street-studios-post-builder.js';
diff --git a/src/lib/blog/test-builders/snout-street-studios-post-builder.ts b/src/lib/blog/test-builders/snout-street-studios-post-builder.ts
new file mode 100644
index 0000000..d45a971
--- /dev/null
+++ b/src/lib/blog/test-builders/snout-street-studios-post-builder.ts
@@ -0,0 +1,48 @@
+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();
+}
diff --git a/src/lib/blog/test-fixtures/snout-street-studio-post-test.md b/src/lib/blog/test-fixtures/snout-street-studio-post-test.md
new file mode 100644
index 0000000..059c80c
--- /dev/null
+++ b/src/lib/blog/test-fixtures/snout-street-studio-post-test.md
@@ -0,0 +1,19 @@
+---
+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.
\ No newline at end of file
diff --git a/src/lib/blog/test-fixtures/test-file.md b/src/lib/blog/test-fixtures/test-file.md
new file mode 100644
index 0000000..32d5068
--- /dev/null
+++ b/src/lib/blog/test-fixtures/test-file.md
@@ -0,0 +1,2 @@
+This is a blog post written in markdown.
+This is a link
\ No newline at end of file
diff --git a/src/lib/snout-street-studios/ApiGateway.ts b/src/lib/snout-street-studios/ApiGateway.ts
new file mode 100644
index 0000000..a238e7d
--- /dev/null
+++ b/src/lib/snout-street-studios/ApiGateway.ts
@@ -0,0 +1,22 @@
+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 {
+ 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),
+ };
+ }
+}
diff --git a/src/lib/snout-street-studios/SnoutStreetStudiosPost.test.ts b/src/lib/snout-street-studios/SnoutStreetStudiosPost.test.ts
new file mode 100644
index 0000000..04913f2
--- /dev/null
+++ b/src/lib/snout-street-studios/SnoutStreetStudiosPost.test.ts
@@ -0,0 +1,26 @@
+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')
+ .build()
+ );
+ });
+});
diff --git a/src/lib/snout-street-studios/SnoutStreetStudiosPost.ts b/src/lib/snout-street-studios/SnoutStreetStudiosPost.ts
new file mode 100644
index 0000000..562f1f9
--- /dev/null
+++ b/src/lib/snout-street-studios/SnoutStreetStudiosPost.ts
@@ -0,0 +1,74 @@
+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 & {
+ 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 });
+ }
+ }
+}
diff --git a/src/lib/snout-street-studios/SnoutStreetStudiosPostDto.ts b/src/lib/snout-street-studios/SnoutStreetStudiosPostDto.ts
new file mode 100644
index 0000000..40e138c
--- /dev/null
+++ b/src/lib/snout-street-studios/SnoutStreetStudiosPostDto.ts
@@ -0,0 +1,7 @@
+export interface SnoutStreetStudiosPostDto {
+ slug: string;
+ title: string;
+ html: string;
+ date: Date;
+ excerpt: string;
+}
diff --git a/src/lib/snout-street-studios/index.ts b/src/lib/snout-street-studios/index.ts
new file mode 100644
index 0000000..ec3bd93
--- /dev/null
+++ b/src/lib/snout-street-studios/index.ts
@@ -0,0 +1,3 @@
+export type { SnoutStreetStudiosPostDto } from './SnoutStreetStudiosPostDto.js';
+export { SnoutStreetStudiosPost } from './SnoutStreetStudiosPost.js';
+export { SnoutStreetStudiosApiGateway } from './ApiGateway.js';
diff --git a/src/routes/api/blog.json/+server.ts b/src/routes/api/blog.json/+server.ts
index 6ca180f..a4a7e7f 100644
--- a/src/routes/api/blog.json/+server.ts
+++ b/src/routes/api/blog.json/+server.ts
@@ -3,11 +3,16 @@ import { BlogController } from '../../../lib/blog/BlogController.js';
export const GET = async () => {
try {
+ console.log(`GET /api/blog.json`);
const controller = await BlogController.singleton();
+ console.log(`Controller instantiated.`);
const blogPosts = await controller.getAllBlogPosts();
return json({ posts: blogPosts });
} catch (error) {
- console.error({ error: JSON.stringify(error) });
+ console.error({
+ message: `Caught error in GET /api/blog.json`,
+ error: JSON.stringify(error),
+ });
return json(
{
error: 'Could not fetch posts. ' + error,
diff --git a/src/routes/api/blog/[slug].json/+server.ts b/src/routes/api/blog/[slug].json/+server.ts
index 812bd90..64cc820 100644
--- a/src/routes/api/blog/[slug].json/+server.ts
+++ b/src/routes/api/blog/[slug].json/+server.ts
@@ -5,7 +5,7 @@ export const GET = async ({ params }: LoadEvent) => {
const controller = await BlogController.singleton();
const { slug } = params;
- const post = await controller.getBlogOrBookReviewBySlug(slug);
+ const post = await controller.getAnyKindOfContentBySlug(slug);
if (!post) {
throw error(404, `Could not find blog post with slug '${slug}'`);
diff --git a/src/routes/api/blog/new.json/+server.ts b/src/routes/api/blog/new.json/+server.ts
index 0b232a1..5df2679 100644
--- a/src/routes/api/blog/new.json/+server.ts
+++ b/src/routes/api/blog/new.json/+server.ts
@@ -51,6 +51,5 @@ export const POST: RequestHandler = async ({ getClientAddress, request }) => {
const resolvedFileName = resolve(thisDirectory, `../../../../content/blog/${fileName}`);
await controller.createBlogPost(resolvedFileName, contentWithFrontmatter);
- console.log({ address });
return json({ address });
};
diff --git a/src/routes/api/snout-street-studios/[slug].json/+server.ts b/src/routes/api/snout-street-studios/[slug].json/+server.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte
index 393fa2b..fb91b17 100644
--- a/src/routes/blog/+page.svelte
+++ b/src/routes/blog/+page.svelte
@@ -1,18 +1,17 @@
@@ -50,7 +49,7 @@
It has been been
{daysSinceLastPublish}
@@ -74,34 +73,17 @@
All Writing
@@ -121,46 +103,6 @@
}
}
- .post {
- border: 1px solid var(--gray-200);
- padding: var(--spacing-md);
- transition: 0.2s;
- border-radius: 8px;
- max-width: 100%;
- }
-
- .post:hover {
- color: var(--brand-orange);
- background-color: white;
- border: 1px solid var(--brand-orange);
- scale: 1.02;
- box-shadow: 10px 10px 10px 10px var(--gray-200);
- }
-
- .post a {
- color: inherit;
- text-decoration: none;
- }
-
- .post-title {
- text-decoration: underline;
- font-family: var(--font-family-title);
- font-weight: 600;
- padding-bottom: 4px;
- font-size: 1.1rem;
- }
-
- .post-date {
- font-size: 0.9rem;
- }
-
- .post-preview {
- font-size: 0.95rem;
- line-height: 140%;
- color: var(--gray-600);
- padding-bottom: 2px;
- }
-
.days-since {
color: var(--brand-orange);
font-weight: 300;
diff --git a/src/routes/blog/+page.ts b/src/routes/blog/+page.ts
index a7b5d18..f9eec05 100644
--- a/src/routes/blog/+page.ts
+++ b/src/routes/blog/+page.ts
@@ -10,6 +10,7 @@ interface BlogPostListItem {
date: string;
preview: string;
slug: string;
+ content_type: 'blog' | 'book_review' | 'snout_street_studios';
}
export async function load({ fetch }: LoadEvent) {
@@ -18,7 +19,9 @@ export async function load({ fetch }: LoadEvent) {
.then((res) => res.posts);
const currentYear = getYear(new Date());
+ console.log({ posts });
const mostRecentPost = posts[0];
+ console.log({ ...mostRecentPost });
const daysSinceLastPublish = differenceInCalendarDays(new Date(), new Date(mostRecentPost.date));
diff --git a/src/routes/blog/BlogPostListItem.svelte b/src/routes/blog/BlogPostListItem.svelte
new file mode 100644
index 0000000..5af04ba
--- /dev/null
+++ b/src/routes/blog/BlogPostListItem.svelte
@@ -0,0 +1,90 @@
+
+
+
+
+
+ {#if content_type === "book_review"}
+ 📚
+ {:else if content_type === "snout_street_studios"}
+ 🪡
+ {/if}
+ {title}
+
+
+
+ {#if preview}
+ {preview}...
+ {:else}
+ No preview available ): Click to read the full post.
+ {/if}
+
+
+
+ {formattedDate}
+
+
+
+
+
diff --git a/src/routes/blog/[slug]/+page.svelte b/src/routes/blog/[slug]/+page.svelte
index 149563c..178cc8e 100644
--- a/src/routes/blog/[slug]/+page.svelte
+++ b/src/routes/blog/[slug]/+page.svelte
@@ -2,43 +2,54 @@
import type { PageData } from "./$types.js";
import { intlFormat } from "date-fns";
import Navbar from "$lib/components/Navbar.svelte";
+ import { onMount } from "svelte";
export let data: PageData;
$: ({ date, post } = data);
+
+ onMount(() => {
+ console.log({ date, post });
+ });
{post.title} | thomaswilson.xyz
-
+
-
-
+
+
-
-
+
+
{post.title}
- {post.author}
+
+ {#if post.autor}
+ {post.author}
+ {:else}
+ Thomas Wilson
+ {/if}
+
{intlFormat(date, {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
- localeMatcher: "best fit"
+ localeMatcher: "best fit",
})}
diff --git a/src/routes/blog/new/+page.svelte b/src/routes/blog/new/+page.svelte
index b1a9b8d..6ebe0ce 100644
--- a/src/routes/blog/new/+page.svelte
+++ b/src/routes/blog/new/+page.svelte
@@ -9,8 +9,6 @@
let slug = "";
let blogPost: BlogPost | null = null;
- $: safeContent = JSON.stringify(content);
-
function slugifyString(originalString: string): string {
return originalString
.toLowerCase()
@@ -25,20 +23,6 @@
slug = `${dateAsString}-${slugifiedTitle}`;
}
- async function onCreatePreviewBlogPost() {
- const _blogPost = new BlogPost({
- author,
- title,
- markdownContent: content,
- slug,
- date,
- fileName: `${slug}.md`
- });
-
- await _blogPost.build();
- blogPost = _blogPost;
- }
-
async function onCreate() {
const requestBody = {
title,
@@ -46,18 +30,18 @@
slug,
markdownContent: content,
fileName: `${slug}.md`,
- date: date.toISOString()
+ date: date.toISOString(),
};
fetch("/api/blog/new.json", {
method: "POST",
headers: {
- "Content-Type": "application/json"
+ "Content-Type": "application/json",
},
- body: JSON.stringify(requestBody)
+ body: JSON.stringify(requestBody),
}).then(async (res) => {
if (res.status === 200) {
- return await goto(`blog/${slug}`);
+ await goto(`/blog/${slug}`);
} else {
alert("Something went wrong");
}
@@ -66,41 +50,36 @@
+ Back to Blog
New Blog Post
-
diff --git a/src/routes/snout-street-studios/[slug]/+layout.svelte b/src/routes/snout-street-studios/[slug]/+layout.svelte
new file mode 100644
index 0000000..da2f715
--- /dev/null
+++ b/src/routes/snout-street-studios/[slug]/+layout.svelte
@@ -0,0 +1,3 @@
+Snout St. Studios
+
+
diff --git a/src/routes/snout-street-studios/[slug]/+page.svelte b/src/routes/snout-street-studios/[slug]/+page.svelte
new file mode 100644
index 0000000..e69de29
diff --git a/src/routes/snout-street-studios/new/+page.svelte b/src/routes/snout-street-studios/new/+page.svelte
new file mode 100644
index 0000000..e69de29
diff --git a/src/routes/snout-street-studios/new/+page.ts b/src/routes/snout-street-studios/new/+page.ts
new file mode 100644
index 0000000..9a45289
--- /dev/null
+++ b/src/routes/snout-street-studios/new/+page.ts
@@ -0,0 +1,10 @@
+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 {};
+}
diff --git a/static/snout-street-studios/cinnamon-dust-linen-shirt/2023-08-14-cinnamon-shirt.jpeg b/static/snout-street-studios/cinnamon-dust-linen-shirt/2023-08-14-cinnamon-shirt.jpeg
new file mode 100644
index 0000000..c0296e1
--- /dev/null
+++ b/static/snout-street-studios/cinnamon-dust-linen-shirt/2023-08-14-cinnamon-shirt.jpeg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b0716be726d41eee483c8291e69f834d4b8d5ba61a64eb4236796c509e3fa8f2
+size 344778
diff --git a/tsconfig.json b/tsconfig.json
index 9c2034c..d968258 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
- "extends": "./.svelte-kit/tsconfig.json",
- "compilerOptions": {
- "moduleResolution": "NodeNext",
- "resolveJsonModule": true
- }
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "moduleResolution": "NodeNext",
+ "resolveJsonModule": true
+ }
}
diff --git a/vite.config.js b/vite.config.js
index 9f361c0..6f34b44 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -3,7 +3,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()],
- resolve: {}
+ resolve: {
+ alias: {
+ $lib: '/src/lib',
+ }
+ }
};
export default config;
\ No newline at end of file
diff --git a/vitest.config.js b/vitest.config.js
index 31a8f3d..016ef30 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -1,5 +1,10 @@
// vitest.config.js
export default {
+ resolve: {
+ alias: {
+ $lib: '/src/lib',
+ }
+ },
test: {
deps: {
inline: [
diff --git a/yarn.lock b/yarn.lock
index 45cd825..75473ed 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3040,10 +3040,10 @@ yocto-queue@^1.0.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
-zod@^3.18.0:
- version "3.19.1"
- resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.1.tgz#112f074a97b50bfc4772d4ad1576814bd8ac4473"
- integrity sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==
+zod@^3.22.2:
+ version "3.22.2"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
+ integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
zwitch@^2.0.0, zwitch@^2.0.4:
version "2.0.4"