refactor how Markdown is converted to HTML; introduce sewn garments to blog

This commit is contained in:
Thomas 2023-09-08 22:31:00 +01:00
parent d0afe72966
commit 6ddcb7d9b0
50 changed files with 897 additions and 578 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
static/snout-street-studios/** filter=lfs diff=lfs merge=lfs -text

View file

@ -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"
}
}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher } from "svelte";
export let id: string;
export let name: string;
@ -12,13 +12,12 @@
}>();
function handleChange() {
console.log('handleChange', { name, salary, count });
dispatch('change', { name, salary, count });
dispatch("change", { name, salary, count });
}
function handleRemove() {
console.log('handleRemove', { id });
dispatch('remove', { id });
console.log("handleRemove", { id });
dispatch("remove", { id });
}
</script>
@ -28,8 +27,8 @@
<input
type="text"
placeholder="Junior Software Engineer"
bind:value={name}
on:input={handleChange}
bind:value="{name}"
on:input="{handleChange}"
/>
</div>
<div class="form__field">
@ -38,8 +37,8 @@
type="number"
step="1"
placeholder="30,000"
bind:value={salary}
on:change={handleChange}
bind:value="{salary}"
on:change="{handleChange}"
/>
</div>
<div class="form__field">
@ -48,11 +47,11 @@
type="number"
step="1"
placeholder="30,000"
bind:value={count}
on:change={handleChange}
bind:value="{count}"
on:change="{handleChange}"
/>
</div>
<button type="button" on:click={handleRemove}> Remove </button>
<button type="button" on:click="{handleRemove}"> Remove </button>
</form>
<style>

View file

@ -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)

View file

@ -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`, () => {

View file

@ -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<Array<BlogPostListItem | BookReviewListItem>> {
async getAllBlogPosts(): Promise<Array<BlogPostListItem | BookReviewListItem | SnoutStreetStudiosPostListItem>> {
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<BookReviewListItem | BlogPostListItem | null> {
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<BookReviewListItem | BlogPostListItem | SnoutStreetStudiosPostListItem | null> {
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;

View file

@ -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.
<h2 id="known-id">This is a heading</h2>
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(
[
`<p>This is the content of the blog post.</p>`,
`\<h2 id="known-id">This is a heading</h2>`,
`<p>This is a <a href="http://www.bbc.co.uk">link</a>.</p>`,
`<ul>`,
`<li>This is a list item</li>`,
`<li>This is another list item</li>`,
`</ul>`,
].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.');
});
});

View file

@ -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<void> {
await this.getHtml();
await this.getExcerpt();
}
async getExcerpt(wordLength = 50): Promise<string> {
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<string> {
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;
}
}

View file

@ -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();

View file

@ -14,8 +14,4 @@ export class BlogPostSet {
getBlogPostWithTitle(title: string): BlogPost | null {
return this._blogPosts.find((post) => post.title === title) ?? null;
}
async buildAllBlogPosts(): Promise<void> {
await Promise.all(this.blogPosts.map((post) => post.build()));
}
}

View file

@ -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(
'<p>This <a href="https://www.example.com">link</a> a book review written in <em>markdown</em>.</p>'
);
});
it(`should not have the HTML built by default`, () => {
// GIVEN
const bookReview = aBookReview().withMarkdownContent(exampleBookReview).build();
// WHEN/THEN
expect(bookReview.html).toBeNull();
});
});

View file

@ -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<void> {
await this.getHtml();
}
async getHtml(): Promise<string> {
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;
}
}

View file

@ -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

View file

@ -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('<p>This is a test</p>');
});
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.');
});
});

View file

@ -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<FrontMatter = Record<string, string>> {
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<Literal> = processor.parse(this.content) as Parent<Literal>;
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<Frontmatter extends Record<string, any>>(
theFileName: string,
theFileContents: string
): Promise<MarkdownFile<Frontmatter>> {
const markdownFile = new MarkdownFile<Frontmatter>({ fileName: theFileName, content: theFileContents });
await markdownFile.build();
return markdownFile;
}
private async build(): Promise<void> {
this._html = await MarkdownBuilder.getHtml(this.content);
this._excerpt = await MarkdownBuilder.getExcerptFromMarkdown(this.content);
this._frontmatter = MarkdownBuilder.getFrontmatter(this.content, this.fileName);
}
}

View file

@ -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]);
});
});

View file

@ -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;
}
}

View file

@ -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 = `<p>This is a blog post written in markdown.</p>
<p>This is a <a href="http://www.bbc.co.uk">link</a></p>`;
describe(`Blog MarkdownRepository`, () => {
let repository: MarkdownRepository;
beforeEach(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
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 () => {

View file

@ -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<MarkdownRepository> {
return await MarkdownRepository.fromViteGlobImport(blogPostMetaGlobImport, bookReviewsMetaGlobImport);
return await MarkdownRepository.fromViteGlobImport(
blogPostMetaGlobImport,
bookReviewsMetaGlobImport,
snoutStreetStudiosPostMetaGlobImport
);
}
public static async fromViteGlobImport(blogGlobImport, bookReviewGlobImport): Promise<MarkdownRepository> {
public static async fromViteGlobImport(
blogGlobImport,
bookReviewGlobImport,
snoutStreetPostGlobImport
): Promise<MarkdownRepository> {
let fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = [];
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<string>];
try {
const fileContent = await module();
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(filename, await module());
const markdownFile = new MarkdownFile<BlogPostFrontmatterValues>({
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<string>];
try {
const fileContent = await module();
const markdownFile = new MarkdownFile<BookReviewFrontmatterValues>({
fileName: filename,
content: fileContent,
});
const markdownFile = await MarkdownFile.build<BookReviewFrontmatterValues>(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<string>];
try {
const markdownFile = await MarkdownFile.build<SnoutStreetStudiosPostFrontmatterValues>(
filename,
await module()
);
const snoutStreetPost = new SnoutStreetStudiosPost({
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
date: new Date(markdownFile.frontmatter.date),
html: markdownFile.html,
excerpt: markdownFile.excerpt,
});
snoutStreetPosts = [...snoutStreetPosts, snoutStreetPost];
} catch (e: any) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
private async buildAll() {
await Promise.all([this.blogPosts.buildAllBlogPosts(), this.bookReviews.buildAllBookReviews()]);
return;
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<BlogPost> {
getSnoutStreetStudiosPostBySlug(slug: string): SnoutStreetStudiosPost | null {
return this.snoutStreetStudiosPosts.posts.find((post) => post.slug === slug) ?? null;
}
async createBlogPostMarkdownFile(resolvedPath: string, contents: string): Promise<BlogPost> {
return new Promise<void>((resolve, reject) => {
writeFile(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,26 +183,19 @@ export class MarkdownRepository {
resolve();
});
})
.then(() => {
const markdownFile = new MarkdownFile<BlogPostFrontmatterValues>({
fileName: resolvdePath,
content: contents,
});
}).then(async () => {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(resolvedPath, contents);
const blogPost = new BlogPost({
markdownContent: markdownFile.content,
html: markdownFile.html,
excerpt: markdownFile.excerpt,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date,
fileName: resolvdePath,
fileName: resolvedPath,
});
return blogPost;
})
.then(async (blogPost: BlogPost) => {
blogPost.build();
return blogPost;
});
}

View file

@ -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`);
});
});

View file

@ -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<string> {
const processor = this.getDocumentProcessor();
const value = await processor.process(markdownContent);
return value.toString();
}
static getFrontmatter<T extends Record<string, any>>(markdownContent: string, fileName: string): T | null {
const processor = this.getFrontmatterProcessor();
const parsedMarkdown: Parent<Literal> = processor.parse(markdownContent) as Parent<Literal>;
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<string> {
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,
});
}
}

View file

@ -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<BlogPost> {
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,
});
}
}

View file

@ -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,
});
}
}

View file

@ -1 +1,2 @@
export { aBlogPost } from './blog-post-builder.js';
export { aSnoutStreetStudiosPost } from './snout-street-studios-post-builder.js';

View file

@ -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();
}

View file

@ -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.

View file

@ -0,0 +1,2 @@
<p>This is a blog post written in markdown.</p>
<p>This is a <a href="http://www.bbc.co.uk">link</a></p>

View file

@ -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<SnoutStreetStudiosPost | null> {
const post = await this.controller.getAnyKindOfContentBySlug(slug);
if (!post) {
return null;
}
return {
date: new Date(post.date),
slug: post.slug,
title: post.title,
html: post.content,
toJson: () => JSON.stringify(post),
};
}
}

View file

@ -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()
);
});
});

View file

@ -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<typeof SnoutStreetStudiosPostProps> & {
excerpt: string;
html: string;
};
export class SnoutStreetStudiosPost {
public readonly slug: string;
public readonly title: string;
public readonly date: Date;
public readonly html: string;
public readonly excerpt: string;
constructor(props: Props) {
try {
const { slug, title, date, html } = SnoutStreetStudiosPostProps.parse(props);
this.slug = slug;
this.title = title;
this.date = date;
this.html = html;
this.excerpt = props.excerpt;
} catch (error) {
SnoutStreetStudiosPost.logAndThenThrowError(`Failed to construct post`, 'constructor', { props, error });
throw error;
}
}
private static logAndThenThrowError(errorMessage: string, contextName: string, ...args: any) {
console.error({
info: `Caught error in SnoutStreetStudiosPost::${contextName}`,
errorMessage,
...args,
});
throw new Error(errorMessage);
}
public toJson(): string {
const dto: SnoutStreetStudiosPostDto = {
slug: this.slug,
title: this.title,
date: this.date,
html: this.html,
excerpt: this.excerpt,
};
return JSON.stringify(dto);
}
public static fromJson(json: string): SnoutStreetStudiosPost {
try {
JSON.parse(json);
} catch {
this.logAndThenThrowError('Failed to parse JSON', 'fromJson', { json });
}
try {
const dto: SnoutStreetStudiosPostDto = JSON.parse(json);
return new SnoutStreetStudiosPost(dto);
} catch {
this.logAndThenThrowError(`Failed to construct post from JSON`, 'fromJson', { json });
}
}
}

View file

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

View file

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

View file

@ -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,

View file

@ -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}'`);

View file

@ -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 });
};

View file

@ -1,18 +1,17 @@
<script lang="ts">
import type { PageData } from "./$types.js";
import Navbar from "$lib/components/Navbar.svelte";
import { intlFormat } from "date-fns";
import BlogPostListItem from "./BlogPostListItem.svelte";
export let data: PageData;
$: ({
posts,
firstPost,
numberOfPosts,
daysSinceLastPublish,
daysSinceFirstPost,
averageDaysBetweenPosts,
numberOfBlogPostsThisYear
numberOfBlogPostsThisYear,
} = data);
</script>
@ -50,7 +49,7 @@
It has been been
<span
class="days-since"
class:days-since-success={daysSinceLastPublish === 0}
class:days-since-success="{daysSinceLastPublish === 0}"
>
{daysSinceLastPublish}
</span>
@ -74,34 +73,17 @@
<h2>All Writing</h2>
<ul class="posts">
{#each posts as post, index}
<li
class="post"
role="article"
aria-posinset={index + 1}
aria-setsize={posts.length}
>
<a href={`/blog/${post.slug}`}>
<div class="post-title">
{#if post.book_review} 📚 {/if}{post.title}
</div>
<div class="post-preview">
{#if post.preview}
{post.preview}...
{:else}
No preview available ): Click to read the full post.
{/if}
</div>
<div class="post-date">
{intlFormat(
new Date(post.date),
{ day: "2-digit", month: "long", year: "numeric" },
{ locale: "en-GB" }
)}
</div>
</a>
</li>{/each}
<BlogPostListItem
index="{index}"
content_type="{post.content_type}"
book_review="{post.book_review}"
date="{post.date}"
numberOfPosts="{posts.length}"
preview="{post.preview}"
slug="{post.slug}"
title="{post.title}"
/>
{/each}
</ul>
</section>
</main>
@ -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;

View file

@ -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));

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { intlFormat } from "date-fns";
export let index: number;
export let numberOfPosts: number;
export let book_review: boolean;
export let title: string;
export let preview: string;
export let slug: string;
export let date: string;
export let content_type: "blog" | "book_review" | "snout_street_studios";
$: formattedDate = intlFormat(
new Date(date),
{ day: "2-digit", month: "long", year: "numeric" },
{ locale: "en-GB" }
);
</script>
<li
class="post"
role="article"
aria-posinset="{index + 1}"
aria-setsize="{numberOfPosts}"
>
<a href="{`/blog/${slug}`}">
<div class="post__title">
{#if content_type === "book_review"}
📚
{:else if content_type === "snout_street_studios"}
🪡
{/if}
{title}
</div>
<div class="post__preview">
{#if preview}
{preview}...
{:else}
No preview available ): Click to read the full post.
{/if}
</div>
<div class="post__date">
{formattedDate}
</div>
</a>
</li>
<style>
.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;
}
</style>

View file

@ -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 });
});
</script>
<svelte:head>
<!-- Primary Meta Tags -->
<title>{post.title} | thomaswilson.xyz</title>
<meta name="title" content="Blog | thomaswilson.xyz" />
<meta name="description" content={post.preview} />
<meta name="description" content="{post.preview}" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta
property="og:url"
content={`https://www.thomaswilson.xyz/blog/${post.slug}`}
content="{`https://www.thomaswilson.xyz/blog/${post.slug}`}"
/>
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.preview} />
<meta property="og:title" content="{post.title}" />
<meta property="og:description" content="{post.preview}" />
<!-- Twitter -->
<meta property="twitter:title" content={post.title} />
<meta property="twitter:description" content={post.preview} />
<meta property="twitter:title" content="{post.title}" />
<meta property="twitter:description" content="{post.preview}" />
</svelte:head>
<Navbar />
<main class="thomaswilson-container">
<header class="section">
<h1 class="title post-title">{post.title}</h1>
<p class="post-author">{post.author}</p>
<p class="post-author">
{#if post.autor}
{post.author}
{:else}
Thomas Wilson
{/if}
</p>
<p class="post-date">
{intlFormat(date, {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
localeMatcher: "best fit"
localeMatcher: "best fit",
})}
</p>
</header>

View file

@ -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 @@
</script>
<section class="new-blog-post">
<a href="/blog">Back to Blog</a>
<h1>New Blog Post</h1>
<form on:submit|preventDefault={onCreate}>
<form on:submit|preventDefault="{onCreate}">
<div class="field">
<label class="field__label" for="title">Title</label>
<input
type="text"
id="title"
required
bind:value={title}
on:change={handleTitleChange}
bind:value="{title}"
on:change="{handleTitleChange}"
/>
</div>
<div class="field">
<label class="field__label" for="author">Author</label>
<input type="text" id="author" required bind:value={author} />
<input type="text" id="author" required bind:value="{author}" />
</div>
<div class="field">
<label class="field__label" for="slug">Slug</label>
<input type="text" id="slug" required bind:value={slug} />
<input type="text" id="slug" required bind:value="{slug}" />
</div>
<div class="field">
<label class="field__label" for="content">Content</label>
<textarea id="content" rows="10" cols="50" bind:value={content} />
<textarea id="content" rows="10" cols="50" bind:value="{content}"
></textarea>
</div>
<div class="submit">
<button
class="preview-button"
type="button"
on:click|preventDefault={onCreatePreviewBlogPost}
>
Preview
</button>
<button class="create-button">Create</button>
</div>
</form>

View file

@ -0,0 +1,3 @@
<h1>Snout St. Studios</h1>
<slot />

View file

@ -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 {};
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b0716be726d41eee483c8291e69f834d4b8d5ba61a64eb4236796c509e3fa8f2
size 344778

View file

@ -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;

View file

@ -1,5 +1,10 @@
// vitest.config.js
export default {
resolve: {
alias: {
$lib: '/src/lib',
}
},
test: {
deps: {
inline: [

View file

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