From 6ddcb7d9b0171034670e0e8c54ec832b4c594332 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 8 Sep 2023 22:31:00 +0100 Subject: [PATCH] refactor how Markdown is converted to HTML; introduce sewn garments to blog --- .gitattributes | 1 + package.json | 2 +- .../salary-calculator/employee.svelte | 111 ++++++++------- .../2023-08-cinnamon-shirt.md | 42 ++++++ src/lib/blog/BlogController.test.ts | 36 ++++- src/lib/blog/BlogController.ts | 70 ++++++--- src/lib/blog/BlogPost.test.ts | 88 +----------- src/lib/blog/BlogPost.ts | 90 +----------- src/lib/blog/BlogPostSet.test.ts | 14 -- src/lib/blog/BlogPostSet.ts | 4 - src/lib/blog/BookReview.test.ts | 27 +--- src/lib/blog/BookReview.ts | 42 +----- src/lib/blog/BookReviewSet.test.ts | 4 +- src/lib/blog/MarkdownFile.test.ts | 21 ++- src/lib/blog/MarkdownFile.ts | 57 ++++---- .../blog/SnoutStreetStudiosPostSet.test.ts | 17 +++ src/lib/blog/SnoutStreetStudiosPostSet.ts | 13 ++ src/lib/blog/markdown-repository.test.ts | 61 ++++---- src/lib/blog/markdown-repository.ts | 133 +++++++++++------- .../blog/markdown/markdown-builder.test.ts | 28 ++++ src/lib/blog/markdown/markdown-builder.ts | 76 ++++++++++ .../blog/test-builders/blog-post-builder.ts | 22 +-- .../blog/test-builders/book-review-builder.ts | 8 +- src/lib/blog/test-builders/index.ts | 1 + .../snout-street-studios-post-builder.ts | 48 +++++++ .../snout-street-studio-post-test.md | 19 +++ src/lib/blog/test-fixtures/test-file.md | 2 + src/lib/snout-street-studios/ApiGateway.ts | 22 +++ .../SnoutStreetStudiosPost.test.ts | 26 ++++ .../SnoutStreetStudiosPost.ts | 74 ++++++++++ .../SnoutStreetStudiosPostDto.ts | 7 + src/lib/snout-street-studios/index.ts | 3 + src/routes/api/blog.json/+server.ts | 7 +- src/routes/api/blog/[slug].json/+server.ts | 2 +- src/routes/api/blog/new.json/+server.ts | 1 - .../[slug].json/+server.ts | 0 src/routes/blog/+page.svelte | 86 ++--------- src/routes/blog/+page.ts | 3 + src/routes/blog/BlogPostListItem.svelte | 90 ++++++++++++ src/routes/blog/[slug]/+page.svelte | 27 ++-- src/routes/blog/new/+page.svelte | 45 ++---- .../[slug]/+layout.svelte | 3 + .../snout-street-studios/[slug]/+page.svelte | 0 .../snout-street-studios/new/+page.svelte | 0 src/routes/snout-street-studios/new/+page.ts | 10 ++ .../2023-08-14-cinnamon-shirt.jpeg | 3 + tsconfig.json | 10 +- vite.config.js | 6 +- vitest.config.js | 5 + yarn.lock | 8 +- 50 files changed, 897 insertions(+), 578 deletions(-) create mode 100644 .gitattributes create mode 100644 src/content/snout-street-studios/2023-08-cinnamon-shirt.md create mode 100644 src/lib/blog/SnoutStreetStudiosPostSet.test.ts create mode 100644 src/lib/blog/SnoutStreetStudiosPostSet.ts create mode 100644 src/lib/blog/markdown/markdown-builder.test.ts create mode 100644 src/lib/blog/markdown/markdown-builder.ts create mode 100644 src/lib/blog/test-builders/snout-street-studios-post-builder.ts create mode 100644 src/lib/blog/test-fixtures/snout-street-studio-post-test.md create mode 100644 src/lib/blog/test-fixtures/test-file.md create mode 100644 src/lib/snout-street-studios/ApiGateway.ts create mode 100644 src/lib/snout-street-studios/SnoutStreetStudiosPost.test.ts create mode 100644 src/lib/snout-street-studios/SnoutStreetStudiosPost.ts create mode 100644 src/lib/snout-street-studios/SnoutStreetStudiosPostDto.ts create mode 100644 src/lib/snout-street-studios/index.ts create mode 100644 src/routes/api/snout-street-studios/[slug].json/+server.ts create mode 100644 src/routes/blog/BlogPostListItem.svelte create mode 100644 src/routes/snout-street-studios/[slug]/+layout.svelte create mode 100644 src/routes/snout-street-studios/[slug]/+page.svelte create mode 100644 src/routes/snout-street-studios/new/+page.svelte create mode 100644 src/routes/snout-street-studios/new/+page.ts create mode 100644 static/snout-street-studios/cinnamon-dust-linen-shirt/2023-08-14-cinnamon-shirt.jpeg 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} +
    + + +
    +
  • + + 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}

    - +
    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"