refactor how Markdown is converted to HTML; introduce sewn garments to blog
This commit is contained in:
parent
d0afe72966
commit
6ddcb7d9b0
50 changed files with 897 additions and 578 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
static/snout-street-studios/** filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
@ -56,6 +56,6 @@
|
||||||
"strip-markdown": "^5.0.0",
|
"strip-markdown": "^5.0.0",
|
||||||
"to-vfile": "^7.2.3",
|
"to-vfile": "^7.2.3",
|
||||||
"unified": "^10.1.2",
|
"unified": "^10.1.2",
|
||||||
"zod": "^3.18.0"
|
"zod": "^3.22.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,68 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
export let id: string;
|
export let id: string;
|
||||||
export let name: string;
|
export let name: string;
|
||||||
export let salary: number;
|
export let salary: number;
|
||||||
export let count: number;
|
export let count: number;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
change: { name: string; salary: number; count: number };
|
change: { name: string; salary: number; count: number };
|
||||||
remove: { id: string };
|
remove: { id: string };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function handleChange() {
|
function handleChange() {
|
||||||
console.log('handleChange', { name, salary, count });
|
dispatch("change", { name, salary, count });
|
||||||
dispatch('change', { name, salary, count });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove() {
|
function handleRemove() {
|
||||||
console.log('handleRemove', { id });
|
console.log("handleRemove", { id });
|
||||||
dispatch('remove', { id });
|
dispatch("remove", { id });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form>
|
<form>
|
||||||
<div class="form__field">
|
<div class="form__field">
|
||||||
<label for="name">Job Title</label>
|
<label for="name">Job Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Junior Software Engineer"
|
placeholder="Junior Software Engineer"
|
||||||
bind:value={name}
|
bind:value="{name}"
|
||||||
on:input={handleChange}
|
on:input="{handleChange}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form__field">
|
<div class="form__field">
|
||||||
<label for="name">Salary (Year)</label>
|
<label for="name">Salary (Year)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="30,000"
|
placeholder="30,000"
|
||||||
bind:value={salary}
|
bind:value="{salary}"
|
||||||
on:change={handleChange}
|
on:change="{handleChange}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form__field">
|
<div class="form__field">
|
||||||
<label for="name"># of them </label>
|
<label for="name"># of them </label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="30,000"
|
placeholder="30,000"
|
||||||
bind:value={count}
|
bind:value="{count}"
|
||||||
on:change={handleChange}
|
on:change="{handleChange}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" on:click={handleRemove}> Remove </button>
|
<button type="button" on:click="{handleRemove}"> Remove </button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
form {
|
form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
row-gap: 8px;
|
row-gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form__field {
|
.form__field {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
42
src/content/snout-street-studios/2023-08-cinnamon-shirt.md
Normal file
42
src/content/snout-street-studios/2023-08-cinnamon-shirt.md
Normal 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)
|
||||||
|
|
@ -10,7 +10,7 @@ describe(`BlogController`, () => {
|
||||||
controller = await BlogController.singleton();
|
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 () => {
|
it(`should load blogs from the content folder`, async () => {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
const blogPosts = await controller.getAllBlogPosts();
|
const blogPosts = await controller.getAllBlogPosts();
|
||||||
|
|
@ -18,16 +18,18 @@ describe(`BlogController`, () => {
|
||||||
// WHEN
|
// WHEN
|
||||||
const aKnownBlogPost = blogPosts.find((post) => post.title === 'Vibe Check #10');
|
const aKnownBlogPost = blogPosts.find((post) => post.title === 'Vibe Check #10');
|
||||||
const aKnownBookReview = blogPosts.find((post) => post.title === 'After');
|
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');
|
const aMadeUpBlogPost = blogPosts.find((post) => post.title === 'Some made up blog post');
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(aMadeUpBlogPost).toBeUndefined();
|
expect(aMadeUpBlogPost).toBeUndefined();
|
||||||
expect(aKnownBlogPost).not.toBeUndefined();
|
expect(aKnownBlogPost).not.toBeUndefined();
|
||||||
expect(aKnownBookReview).not.toBeUndefined();
|
expect(aKnownBookReview).not.toBeUndefined();
|
||||||
|
expect(aKnownSnoutStreetStudiosPost).not.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`Finding a blog post or book review by slug`, () => {
|
describe(`Finding content by slug`, () => {
|
||||||
describe(`Finding a blog post`, () => {
|
describe(`Finding a blog post`, () => {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
const slugForRealBlogPost = '2023-02-03-vibe-check-10';
|
const slugForRealBlogPost = '2023-02-03-vibe-check-10';
|
||||||
|
|
@ -35,7 +37,7 @@ describe(`BlogController`, () => {
|
||||||
|
|
||||||
it(`should return null if there's no blog post with the slug`, async () => {
|
it(`should return null if there's no blog post with the slug`, async () => {
|
||||||
// WHEN
|
// WHEN
|
||||||
const blogPost = await controller.getBlogOrBookReviewBySlug(slugForFakeBlogPost);
|
const blogPost = await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost);
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(blogPost).toBeNull();
|
expect(blogPost).toBeNull();
|
||||||
|
|
@ -43,7 +45,7 @@ describe(`BlogController`, () => {
|
||||||
|
|
||||||
it(`should return the blog post if it exists`, async () => {
|
it(`should return the blog post if it exists`, async () => {
|
||||||
// WHEN
|
// WHEN
|
||||||
const blogPost = await controller.getBlogOrBookReviewBySlug(slugForRealBlogPost);
|
const blogPost = await controller.getAnyKindOfContentBySlug(slugForRealBlogPost);
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(blogPost).not.toBeNull();
|
expect(blogPost).not.toBeNull();
|
||||||
|
|
@ -57,7 +59,7 @@ describe(`BlogController`, () => {
|
||||||
|
|
||||||
it(`should return null if there's no book review with the slug`, async () => {
|
it(`should return null if there's no book review with the slug`, async () => {
|
||||||
// WHEN
|
// WHEN
|
||||||
const bookReview = await controller.getBlogOrBookReviewBySlug(fakeSlug);
|
const bookReview = await controller.getAnyKindOfContentBySlug(fakeSlug);
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(bookReview).toBeNull();
|
expect(bookReview).toBeNull();
|
||||||
|
|
@ -65,13 +67,35 @@ describe(`BlogController`, () => {
|
||||||
|
|
||||||
it(`should return the book review if it exists`, async () => {
|
it(`should return the book review if it exists`, async () => {
|
||||||
// WHEN
|
// WHEN
|
||||||
const bookReview = await controller.getBlogOrBookReviewBySlug(realSlug);
|
const bookReview = await controller.getAnyKindOfContentBySlug(realSlug);
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(bookReview).not.toBeNull();
|
expect(bookReview).not.toBeNull();
|
||||||
expect(bookReview.title).toBe('After');
|
expect(bookReview.title).toBe('After');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(`Finding a Snout Street Studios post`, () => {
|
||||||
|
const realSlug = '2023-08-cinnamon-dust-linen-shirt';
|
||||||
|
const fakeSlug = 'some-made-up-snout-street-studios-post';
|
||||||
|
|
||||||
|
it(`should return null if there's no Snout Street Studios post with the slug`, async () => {
|
||||||
|
// WHEN
|
||||||
|
const snoutStreetStudiosPost = await controller.getAnyKindOfContentBySlug(fakeSlug);
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
expect(snoutStreetStudiosPost).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should return the Snout Street Studios post if it exists`, async () => {
|
||||||
|
// WHEN
|
||||||
|
const snoutStreetStudiosPost = await controller.getAnyKindOfContentBySlug(realSlug);
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
expect(snoutStreetStudiosPost).not.toBeNull();
|
||||||
|
expect(snoutStreetStudiosPost.title).toBe('Cinnamon Dust Linen Shirt');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`Creating a new blog post as a file`, () => {
|
describe(`Creating a new blog post as a file`, () => {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
|
import type { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js';
|
||||||
import type { BlogPost } from './BlogPost.js';
|
import type { BlogPost } from './BlogPost.js';
|
||||||
import type { BookReview } from './BookReview.js';
|
import type { BookReview } from './BookReview.js';
|
||||||
import { MarkdownRepository } from './markdown-repository.js';
|
import { MarkdownRepository } from './markdown-repository.js';
|
||||||
|
|
||||||
const blogPostMetaGlobImport = import.meta.glob('../../content/blog/*.md', { as: 'raw' });
|
interface BlogItem {
|
||||||
const bookReviewsMetaGlobImport = import.meta.glob('../../content/book-reviews/*.md', { as: 'raw' });
|
title: string;
|
||||||
|
date: string;
|
||||||
|
content: string;
|
||||||
|
slug: string;
|
||||||
|
content_type: 'blog' | 'book_review' | 'snout_street_studios';
|
||||||
|
}
|
||||||
|
|
||||||
interface BlogPostListItem {
|
interface BlogPostListItem extends BlogItem {
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
date: string;
|
date: string;
|
||||||
book_review: boolean;
|
book_review: boolean;
|
||||||
preview: string;
|
preview: string;
|
||||||
content: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookReviewListItem {
|
interface BookReviewListItem extends BlogItem {
|
||||||
book_review: true;
|
book_review: true;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|
@ -23,8 +27,12 @@ interface BookReviewListItem {
|
||||||
slug: string;
|
slug: string;
|
||||||
score: number;
|
score: number;
|
||||||
finished: string;
|
finished: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnoutStreetStudiosPostListItem extends BlogItem {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
date: string;
|
date: string;
|
||||||
content: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BlogController {
|
export class BlogController {
|
||||||
|
|
@ -52,11 +60,13 @@ export class BlogController {
|
||||||
return createdBlogPost;
|
return createdBlogPost;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllBlogPosts(): Promise<Array<BlogPostListItem | BookReviewListItem>> {
|
async getAllBlogPosts(): Promise<Array<BlogPostListItem | BookReviewListItem | SnoutStreetStudiosPostListItem>> {
|
||||||
const blogPosts = await this._markdownRepository.blogPosts;
|
const blogPosts = await this._markdownRepository.blogPosts;
|
||||||
|
|
||||||
const bookReviews = await this._markdownRepository.bookReviews;
|
const bookReviews = await this._markdownRepository.bookReviews;
|
||||||
|
|
||||||
|
const snoutStreetStudiosPosts = await this._markdownRepository.snoutStreetStudiosPosts;
|
||||||
|
|
||||||
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map((blogPost) => {
|
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map((blogPost) => {
|
||||||
return this.blogPostToBlogPostListItem(blogPost);
|
return this.blogPostToBlogPostListItem(blogPost);
|
||||||
});
|
});
|
||||||
|
|
@ -65,10 +75,16 @@ export class BlogController {
|
||||||
return this.bookReviewToBookReviewListItem(bookReview);
|
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 {
|
return {
|
||||||
book_review: true,
|
book_review: true,
|
||||||
title: bookReview.title,
|
title: bookReview.title,
|
||||||
|
|
@ -78,31 +94,53 @@ export class BlogController {
|
||||||
image: bookReview.image,
|
image: bookReview.image,
|
||||||
score: bookReview.score,
|
score: bookReview.score,
|
||||||
slug: bookReview.slug,
|
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 {
|
return {
|
||||||
title: blogPost.title,
|
title: blogPost.title,
|
||||||
author: blogPost.author,
|
author: blogPost.author,
|
||||||
book_review: false,
|
book_review: false,
|
||||||
content: includeHtml ? blogPost.html : '',
|
content: blogPost.html,
|
||||||
date: blogPost.date.toISOString(),
|
date: blogPost.date.toISOString(),
|
||||||
preview: blogPost.excerpt,
|
preview: blogPost.excerpt,
|
||||||
slug: blogPost.slug,
|
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);
|
const blogPost = await this._markdownRepository.getBlogPostBySlug(slug);
|
||||||
if (blogPost) {
|
if (blogPost) {
|
||||||
return this.blogPostToBlogPostListItem(blogPost, true);
|
return this.blogPostToBlogPostListItem(blogPost);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookReview = await this._markdownRepository.getBookReviewBySlug(slug);
|
const bookReview = await this._markdownRepository.getBookReviewBySlug(slug);
|
||||||
if (bookReview) {
|
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;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,6 @@ import { describe, it, expect } from 'vitest';
|
||||||
import { BlogPost } from './BlogPost.js';
|
import { BlogPost } from './BlogPost.js';
|
||||||
import { aBlogPost } from './test-builders/blog-post-builder.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('BlogPost', () => {
|
||||||
describe(`Constructing`, () => {
|
describe(`Constructing`, () => {
|
||||||
it(`should construct`, async () => {
|
it(`should construct`, async () => {
|
||||||
|
|
@ -28,86 +11,25 @@ describe('BlogPost', () => {
|
||||||
author: 'Test Author',
|
author: 'Test Author',
|
||||||
date: new Date('2022-01-01T00:00Z'),
|
date: new Date('2022-01-01T00:00Z'),
|
||||||
slug: 'test-slug',
|
slug: 'test-slug',
|
||||||
markdownContent: 'Test Content',
|
|
||||||
fileName: `the-file-name.md`,
|
fileName: `the-file-name.md`,
|
||||||
|
html: 'Test Content',
|
||||||
|
excerpt: 'Test Excerpt',
|
||||||
});
|
});
|
||||||
|
|
||||||
// WHEN
|
|
||||||
await blogPost.build();
|
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
const expectedBlogPost = await aBlogPost()
|
const expectedBlogPost = await aBlogPost()
|
||||||
.withTitle('Test Title')
|
.withTitle('Test Title')
|
||||||
.withAuthor('Test Author')
|
.withAuthor('Test Author')
|
||||||
.withDate(new Date('2022-01-01T00:00Z'))
|
.withDate(new Date('2022-01-01T00:00Z'))
|
||||||
.withSlug('test-slug')
|
.withSlug('test-slug')
|
||||||
.withMarkdownContent('Test Content')
|
.withHtml('Test Content')
|
||||||
|
.withExcerpt('Test Excerpt')
|
||||||
.withFileName(`the-file-name.md`)
|
.withFileName(`the-file-name.md`)
|
||||||
.constructAndThenBuild();
|
.build();
|
||||||
|
|
||||||
expect(blogPost).toStrictEqual(expectedBlogPost);
|
expect(blogPost).toStrictEqual(expectedBlogPost);
|
||||||
expect(blogPost.html).toBeDefined();
|
expect(blogPost.html).toBeDefined();
|
||||||
expect(blogPost.excerpt).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.');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface BlogPostParams {
|
||||||
title: string;
|
title: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
author: string;
|
author: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
markdownContent: string;
|
|
||||||
fileName: string; // excluding any leading `..`
|
fileName: string; // excluding any leading `..`
|
||||||
|
html: string;
|
||||||
|
excerpt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BlogPost {
|
export class BlogPost {
|
||||||
|
|
@ -23,85 +13,17 @@ export class BlogPost {
|
||||||
readonly date: Date;
|
readonly date: Date;
|
||||||
readonly author: string;
|
readonly author: string;
|
||||||
readonly slug: string;
|
readonly slug: string;
|
||||||
readonly markdownContent: string;
|
|
||||||
readonly fileName: string;
|
readonly fileName: string;
|
||||||
|
public readonly html: string;
|
||||||
private _html: string | null = null;
|
public readonly excerpt: string;
|
||||||
private _excerpt: string | null = null;
|
|
||||||
|
|
||||||
constructor(params: BlogPostParams) {
|
constructor(params: BlogPostParams) {
|
||||||
this.title = params.title;
|
this.title = params.title;
|
||||||
this.date = params.date;
|
this.date = params.date;
|
||||||
this.author = params.author;
|
this.author = params.author;
|
||||||
this.slug = params.slug;
|
this.slug = params.slug;
|
||||||
this.markdownContent = params.markdownContent;
|
|
||||||
this.fileName = params.fileName.split(`/`)[-1];
|
this.fileName = params.fileName.split(`/`)[-1];
|
||||||
}
|
this.html = params.html;
|
||||||
|
this.excerpt = params.excerpt;
|
||||||
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'] });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { BlogPost } from './BlogPost.js';
|
|
||||||
import { BlogPostSet } from './BlogPostSet.js';
|
import { BlogPostSet } from './BlogPostSet.js';
|
||||||
import { aBlogPost } from './test-builders/blog-post-builder.js';
|
import { aBlogPost } from './test-builders/blog-post-builder.js';
|
||||||
describe(`BlogPostSet`, () => {
|
describe(`BlogPostSet`, () => {
|
||||||
|
|
@ -15,19 +14,6 @@ describe(`BlogPostSet`, () => {
|
||||||
expect(blogPostSet.blogPosts).toStrictEqual([blogPostOne, blogPostTwo]);
|
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`, () => {
|
describe(`Finding a blog post by title`, () => {
|
||||||
const blogPostOne = aBlogPost().withTitle('Blog Post One').build();
|
const blogPostOne = aBlogPost().withTitle('Blog Post One').build();
|
||||||
const blogPostTwo = aBlogPost().withTitle('Blog Post Two').build();
|
const blogPostTwo = aBlogPost().withTitle('Blog Post Two').build();
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,4 @@ export class BlogPostSet {
|
||||||
getBlogPostWithTitle(title: string): BlogPost | null {
|
getBlogPostWithTitle(title: string): BlogPost | null {
|
||||||
return this._blogPosts.find((post) => post.title === title) ?? null;
|
return this._blogPosts.find((post) => post.title === title) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildAllBlogPosts(): Promise<void> {
|
|
||||||
await Promise.all(this.blogPosts.map((post) => post.build()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest';
|
||||||
import { BookReview } from './BookReview.js';
|
import { BookReview } from './BookReview.js';
|
||||||
import { aBookReview } from './test-builders/book-review-builder.js';
|
import { aBookReview } from './test-builders/book-review-builder.js';
|
||||||
|
|
||||||
const exampleBookReview = `---
|
const exampleBookReviewMarkdown = `---
|
||||||
title: "After"
|
title: "After"
|
||||||
author: "Dr Bruce Greyson"
|
author: "Dr Bruce Greyson"
|
||||||
score: 3.5
|
score: 3.5
|
||||||
|
|
@ -30,7 +30,7 @@ describe(`BookReview`, () => {
|
||||||
date: new Date('2021-05-05'),
|
date: new Date('2021-05-05'),
|
||||||
finished: new Date('2021-04-20'),
|
finished: new Date('2021-04-20'),
|
||||||
draft: false,
|
draft: false,
|
||||||
markdownContent: exampleBookReview,
|
html: 'the test html',
|
||||||
});
|
});
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
|
|
@ -42,31 +42,10 @@ describe(`BookReview`, () => {
|
||||||
.withSlug('after')
|
.withSlug('after')
|
||||||
.withDate(new Date('2021-05-05'))
|
.withDate(new Date('2021-05-05'))
|
||||||
.withFinished(new Date('2021-04-20'))
|
.withFinished(new Date('2021-04-20'))
|
||||||
.withMarkdownContent(exampleBookReview)
|
.withHtml('the test html')
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(bookReview).toEqual(expectedBookReview);
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface BookReviewProps {
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|
@ -15,7 +7,7 @@ interface BookReviewProps {
|
||||||
date: Date;
|
date: Date;
|
||||||
finished: Date;
|
finished: Date;
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
markdownContent: string;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookReview {
|
export class BookReview {
|
||||||
|
|
@ -26,8 +18,7 @@ export class BookReview {
|
||||||
readonly slug: string;
|
readonly slug: string;
|
||||||
readonly date: Date;
|
readonly date: Date;
|
||||||
readonly finished: Date;
|
readonly finished: Date;
|
||||||
private readonly markdownContent: string;
|
readonly html: string;
|
||||||
private _html: string | null = null;
|
|
||||||
|
|
||||||
constructor(props: BookReviewProps) {
|
constructor(props: BookReviewProps) {
|
||||||
this.title = props.title;
|
this.title = props.title;
|
||||||
|
|
@ -37,33 +28,6 @@ export class BookReview {
|
||||||
this.slug = props.slug;
|
this.slug = props.slug;
|
||||||
this.date = props.date;
|
this.date = props.date;
|
||||||
this.finished = props.finished;
|
this.finished = props.finished;
|
||||||
this.markdownContent = props.markdownContent;
|
this.html = props.html;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ describe(`BookReviewSet`, () => {
|
||||||
|
|
||||||
it(`should build all the HTML contents`, async () => {
|
it(`should build all the HTML contents`, async () => {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
const bookReview = aBookReview().withTitle(`The title`).withMarkdownContent('test').build();
|
const bookReview = aBookReview().withTitle(`The title`).withHtml('test').build();
|
||||||
const anotherBookReview = aBookReview().withTitle(`Another title`).withMarkdownContent('test').build();
|
const anotherBookReview = aBookReview().withTitle(`Another title`).withHtml('test').build();
|
||||||
const bookReviewSet = new BookReviewSet([bookReview, anotherBookReview]);
|
const bookReviewSet = new BookReviewSet([bookReview, anotherBookReview]);
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
|
|
|
||||||
|
|
@ -11,26 +11,27 @@ This is the content of the blog post.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
describe(`MarkdownFile`, () => {
|
describe(`MarkdownFile`, () => {
|
||||||
it(`should construct`, () => {
|
it(`should construct`, async () => {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
const fileName = 'example.md';
|
const fileName = 'example.md';
|
||||||
const content = 'This is a test';
|
const content = 'This is a test';
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
const markdownFile = new MarkdownFile({ fileName, content });
|
const markdownFile = await MarkdownFile.build(fileName, content);
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(markdownFile.fileName).toBe(fileName);
|
expect(markdownFile.fileName).toBe(fileName);
|
||||||
expect(markdownFile.content).toBe(content);
|
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
|
// GIVEN
|
||||||
const fileName = 'example.md';
|
const fileName = 'example.md';
|
||||||
const content = exampleMarkdownWithFrontMatter;
|
const content = exampleMarkdownWithFrontMatter;
|
||||||
|
|
||||||
// WHEN
|
// WHEN
|
||||||
const markdownFile = new MarkdownFile({ fileName, content });
|
const markdownFile = await MarkdownFile.build(fileName, content);
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(markdownFile.frontmatter).toStrictEqual({
|
expect(markdownFile.frontmatter).toStrictEqual({
|
||||||
|
|
@ -39,4 +40,16 @@ describe(`MarkdownFile`, () => {
|
||||||
slug: '2023-02-01-test',
|
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.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,46 @@
|
||||||
import { unified, type Processor } from 'unified';
|
import { MarkdownBuilder } from './markdown/markdown-builder.js';
|
||||||
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';
|
|
||||||
|
|
||||||
interface MarkdownFileProps {
|
interface MarkdownFileProps {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
/** The raw contents of the .md file */
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
export class MarkdownFile<FrontMatter = Record<string, string>> {
|
export class MarkdownFile<FrontMatter = Record<string, string>> {
|
||||||
readonly fileName: string;
|
readonly fileName: string;
|
||||||
readonly content: 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.fileName = props.fileName;
|
||||||
this.content = props.content;
|
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 {
|
get html(): string | null {
|
||||||
return unified() //
|
return this._html;
|
||||||
.use(markdown)
|
}
|
||||||
.use(markdownFrontmatter)
|
|
||||||
.use(remarkStringify);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/lib/blog/SnoutStreetStudiosPostSet.test.ts
Normal file
17
src/lib/blog/SnoutStreetStudiosPostSet.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/lib/blog/SnoutStreetStudiosPostSet.ts
Normal file
13
src/lib/blog/SnoutStreetStudiosPostSet.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 { MarkdownRepository } from './markdown-repository.js';
|
||||||
import { resolve, dirname } from 'path';
|
import { resolve, dirname } from 'path';
|
||||||
|
|
||||||
import { MarkdownFile } from './MarkdownFile.js';
|
|
||||||
import { aBlogPost } from './test-builders/blog-post-builder.js';
|
import { aBlogPost } from './test-builders/blog-post-builder.js';
|
||||||
|
import { aSnoutStreetStudiosPost } from './test-builders/snout-street-studios-post-builder.js';
|
||||||
|
|
||||||
const blogPostImport = import.meta.glob(`./test-fixtures/blog-*.md`, { as: 'raw' });
|
const blogPostImport = import.meta.glob(`./test-fixtures/blog-*.md`, { as: 'raw' });
|
||||||
const bookReviewImport = import.meta.glob(`./test-fixtures/book-review-*.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 = `---
|
const expectedHtml = `<p>This is a blog post written in markdown.</p>
|
||||||
title: "Test Blog Post"
|
<p>This is a <a href="http://www.bbc.co.uk">link</a></p>`;
|
||||||
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)
|
|
||||||
`;
|
|
||||||
|
|
||||||
describe(`Blog MarkdownRepository`, () => {
|
describe(`Blog MarkdownRepository`, () => {
|
||||||
|
let repository: MarkdownRepository;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
repository = await MarkdownRepository.fromViteGlobImport(
|
||||||
|
blogPostImport,
|
||||||
|
bookReviewImport,
|
||||||
|
snoutStreetPostImport
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it(`should load`, async () => {
|
it(`should load`, async () => {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
const repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
|
|
||||||
|
|
||||||
const expectedBlogPost = await aBlogPost()
|
const expectedBlogPost = await aBlogPost()
|
||||||
.withAuthor('Thomas Wilson')
|
.withAuthor('Thomas Wilson')
|
||||||
.withDate(new Date('2023-02-01T08:00:00Z'))
|
.withDate(new Date('2023-02-01T08:00:00Z'))
|
||||||
.withSlug('2023-02-01-test')
|
.withSlug('2023-02-01-test')
|
||||||
.withTitle('Test Blog Post')
|
.withTitle('Test Blog Post')
|
||||||
.withMarkdownContent(testMarkdownContent)
|
.withExcerpt('This is a blog post written in markdown.')
|
||||||
|
.withHtml(expectedHtml)
|
||||||
.withFileName('blog-2023-02-01-test.md')
|
.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
|
// WHEN
|
||||||
const blogPost = repository.blogPosts.getBlogPostWithTitle('Test Blog Post');
|
const blogPost = repository.blogPosts.getBlogPostWithTitle('Test Blog Post');
|
||||||
|
const snoutStreetPosts = repository.snoutStreetStudiosPosts.posts;
|
||||||
|
|
||||||
// THEN
|
// THEN
|
||||||
expect(repository).toBeDefined();
|
expect(repository).toBeDefined();
|
||||||
expect(blogPost).toStrictEqual(expectedBlogPost);
|
expect(blogPost).toStrictEqual(expectedBlogPost);
|
||||||
|
expect(snoutStreetPosts).toStrictEqual([expectedSnoutStreetPost]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should automatically build all the blog posts and book reviews`, async () => {
|
it(`should automatically build all the blog posts and book reviews`, async () => {
|
||||||
// GIVEN
|
|
||||||
const repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
|
|
||||||
|
|
||||||
// WHEN/THEN
|
// WHEN/THEN
|
||||||
expect(repository.blogPosts.blogPosts[0].html).not.toBeNull();
|
expect(repository.blogPosts.blogPosts[0].html).not.toBeNull();
|
||||||
expect(repository.bookReviews.bookReviews[0].html).not.toBeNull();
|
expect(repository.bookReviews.bookReviews[0].html).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`Finding by Slug`, () => {
|
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 () => {
|
it(`should return null if there's neither a blog post nor a book review with the slug`, async () => {
|
||||||
// WHEN
|
// WHEN
|
||||||
const markdownFile = repository.getBlogPostBySlug('non-existent-slug');
|
const markdownFile = repository.getBlogPostBySlug('non-existent-slug');
|
||||||
|
|
@ -73,10 +72,14 @@ describe(`Blog MarkdownRepository`, () => {
|
||||||
const currentDirectory = dirname(import.meta.url.replace('file://', ''));
|
const currentDirectory = dirname(import.meta.url.replace('file://', ''));
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
|
repository = await MarkdownRepository.fromViteGlobImport(
|
||||||
|
blogPostImport,
|
||||||
|
bookReviewImport,
|
||||||
|
snoutStreetPostImport
|
||||||
|
);
|
||||||
|
|
||||||
const resolvedPath = resolve(`${currentDirectory}/test-fixtures/test-file.md`);
|
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 () => {
|
it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { resolve } from 'path';
|
|
||||||
import { writeFile, unlink, existsSync } from 'fs';
|
import { writeFile, unlink, existsSync } from 'fs';
|
||||||
|
|
||||||
import { BlogPost } from './BlogPost.js';
|
import { BlogPost } from './BlogPost.js';
|
||||||
|
|
@ -6,12 +5,17 @@ import { MarkdownFile } from './MarkdownFile.js';
|
||||||
import { BlogPostSet } from './BlogPostSet.js';
|
import { BlogPostSet } from './BlogPostSet.js';
|
||||||
import { BookReviewSet } from './BookReviewSet.js';
|
import { BookReviewSet } from './BookReviewSet.js';
|
||||||
import { BookReview } from './BookReview.js';
|
import { BookReview } from './BookReview.js';
|
||||||
|
import { SnoutStreetStudiosPost } from '$lib/snout-street-studios/SnoutStreetStudiosPost.js';
|
||||||
|
import { SnoutStreetStudiosPostSet } from './SnoutStreetStudiosPostSet.js';
|
||||||
|
import { MarkdownBuilder } from './markdown/markdown-builder.js';
|
||||||
|
|
||||||
// We have to duplicate the `../..` here because import.meta must have a static string,
|
// We have to duplicate the `../..` here because import.meta must have a static string,
|
||||||
// and it (rightfully) cannot have dynamic locations
|
// and it (rightfully) cannot have dynamic locations
|
||||||
const blogPostMarkdownDirectory = `../../content/blog`;
|
|
||||||
const blogPostMetaGlobImport = import.meta.glob(`../../content/blog/*.md`, { as: 'raw' });
|
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 {
|
interface BlogPostFrontmatterValues {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -30,37 +34,55 @@ interface BookReviewFrontmatterValues {
|
||||||
image: string;
|
image: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SnoutStreetStudiosPostFrontmatterValues {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class MarkdownRepository {
|
export class MarkdownRepository {
|
||||||
readonly blogPosts: BlogPostSet;
|
readonly blogPosts: BlogPostSet;
|
||||||
readonly bookReviews: BookReviewSet;
|
readonly bookReviews: BookReviewSet;
|
||||||
|
readonly snoutStreetStudiosPosts: SnoutStreetStudiosPostSet;
|
||||||
|
|
||||||
private constructor(blogPosts: BlogPost[], bookReviews: BookReview[]) {
|
private constructor(
|
||||||
|
blogPosts: BlogPost[],
|
||||||
|
bookReviews: BookReview[],
|
||||||
|
snoutStreetStudiosPosts: SnoutStreetStudiosPost[]
|
||||||
|
) {
|
||||||
this.blogPosts = new BlogPostSet(blogPosts);
|
this.blogPosts = new BlogPostSet(blogPosts);
|
||||||
this.bookReviews = new BookReviewSet(bookReviews);
|
this.bookReviews = new BookReviewSet(bookReviews);
|
||||||
|
this.snoutStreetStudiosPosts = new SnoutStreetStudiosPostSet(snoutStreetStudiosPosts);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async singleton(): Promise<MarkdownRepository> {
|
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 fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = [];
|
||||||
let blogPosts: BlogPost[] = [];
|
let blogPosts: BlogPost[] = [];
|
||||||
let bookReviews: BookReview[] = [];
|
let bookReviews: BookReview[] = [];
|
||||||
|
let snoutStreetPosts: SnoutStreetStudiosPost[] = [];
|
||||||
|
|
||||||
const blogPostFiles = Object.entries(blogGlobImport);
|
const blogPostFiles = Object.entries(blogGlobImport);
|
||||||
|
|
||||||
for (const blogPostFile of blogPostFiles) {
|
for (const blogPostFile of blogPostFiles) {
|
||||||
const [filename, module] = blogPostFile as [string, () => Promise<string>];
|
const [filename, module] = blogPostFile as [string, () => Promise<string>];
|
||||||
try {
|
try {
|
||||||
const 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({
|
const blogPost = new BlogPost({
|
||||||
markdownContent: markdownFile.content,
|
excerpt: markdownFile.excerpt,
|
||||||
|
html: markdownFile.html,
|
||||||
title: markdownFile.frontmatter.title,
|
title: markdownFile.frontmatter.title,
|
||||||
slug: markdownFile.frontmatter.slug,
|
slug: markdownFile.frontmatter.slug,
|
||||||
author: markdownFile.frontmatter.author,
|
author: markdownFile.frontmatter.author,
|
||||||
|
|
@ -81,12 +103,7 @@ export class MarkdownRepository {
|
||||||
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
|
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
|
||||||
const [filename, module] = bookReviewFile as [string, () => Promise<string>];
|
const [filename, module] = bookReviewFile as [string, () => Promise<string>];
|
||||||
try {
|
try {
|
||||||
const fileContent = await module();
|
const markdownFile = await MarkdownFile.build<BookReviewFrontmatterValues>(filename, await module());
|
||||||
|
|
||||||
const markdownFile = new MarkdownFile<BookReviewFrontmatterValues>({
|
|
||||||
fileName: filename,
|
|
||||||
content: fileContent,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookReview = new BookReview({
|
const bookReview = new BookReview({
|
||||||
author: markdownFile.frontmatter.author,
|
author: markdownFile.frontmatter.author,
|
||||||
|
|
@ -97,7 +114,7 @@ export class MarkdownRepository {
|
||||||
finished: markdownFile.frontmatter.finished,
|
finished: markdownFile.frontmatter.finished,
|
||||||
image: markdownFile.frontmatter.image,
|
image: markdownFile.frontmatter.image,
|
||||||
score: markdownFile.frontmatter.score,
|
score: markdownFile.frontmatter.score,
|
||||||
markdownContent: markdownFile.content,
|
html: markdownFile.html,
|
||||||
});
|
});
|
||||||
|
|
||||||
bookReviews = [...bookReviews, bookReview];
|
bookReviews = [...bookReviews, bookReview];
|
||||||
|
|
@ -109,14 +126,35 @@ export class MarkdownRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const repository = new MarkdownRepository(blogPosts, bookReviews);
|
for (const snoutStreetPostFile of Object.entries(snoutStreetPostGlobImport)) {
|
||||||
await repository.buildAll();
|
const [filename, module] = snoutStreetPostFile as [string, () => Promise<string>];
|
||||||
return repository;
|
try {
|
||||||
}
|
const markdownFile = await MarkdownFile.build<SnoutStreetStudiosPostFrontmatterValues>(
|
||||||
|
filename,
|
||||||
|
await module()
|
||||||
|
);
|
||||||
|
|
||||||
private async buildAll() {
|
const snoutStreetPost = new SnoutStreetStudiosPost({
|
||||||
await Promise.all([this.blogPosts.buildAllBlogPosts(), this.bookReviews.buildAllBookReviews()]);
|
title: markdownFile.frontmatter.title,
|
||||||
return;
|
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 {
|
getBlogPostBySlug(slug: string): BlogPost | null {
|
||||||
|
|
@ -127,12 +165,16 @@ export class MarkdownRepository {
|
||||||
return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null;
|
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) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
writeFile(resolvdePath, contents, (err) => {
|
writeFile(resolvedPath, contents, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error({
|
console.error({
|
||||||
message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvdePath}`,
|
message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvedPath}`,
|
||||||
err,
|
err,
|
||||||
error: JSON.stringify(err),
|
error: JSON.stringify(err),
|
||||||
});
|
});
|
||||||
|
|
@ -141,28 +183,21 @@ export class MarkdownRepository {
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
})
|
}).then(async () => {
|
||||||
.then(() => {
|
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(resolvedPath, contents);
|
||||||
const markdownFile = new MarkdownFile<BlogPostFrontmatterValues>({
|
|
||||||
fileName: resolvdePath,
|
|
||||||
content: contents,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blogPost = new BlogPost({
|
const blogPost = new BlogPost({
|
||||||
markdownContent: markdownFile.content,
|
html: markdownFile.html,
|
||||||
title: markdownFile.frontmatter.title,
|
excerpt: markdownFile.excerpt,
|
||||||
slug: markdownFile.frontmatter.slug,
|
title: markdownFile.frontmatter.title,
|
||||||
author: markdownFile.frontmatter.author,
|
slug: markdownFile.frontmatter.slug,
|
||||||
date: markdownFile.frontmatter.date,
|
author: markdownFile.frontmatter.author,
|
||||||
fileName: resolvdePath,
|
date: markdownFile.frontmatter.date,
|
||||||
});
|
fileName: resolvedPath,
|
||||||
|
|
||||||
return blogPost;
|
|
||||||
})
|
|
||||||
.then(async (blogPost: BlogPost) => {
|
|
||||||
blogPost.build();
|
|
||||||
return blogPost;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return blogPost;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBlogPostMarkdownFile(resolvedFilePath: string): Promise<void> {
|
async deleteBlogPostMarkdownFile(resolvedFilePath: string): Promise<void> {
|
||||||
|
|
|
||||||
28
src/lib/blog/markdown/markdown-builder.test.ts
Normal file
28
src/lib/blog/markdown/markdown-builder.test.ts
Normal 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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
src/lib/blog/markdown/markdown-builder.ts
Normal file
76
src/lib/blog/markdown/markdown-builder.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,20 +2,25 @@ import { BlogPost } from '../BlogPost.js';
|
||||||
|
|
||||||
class BlogPostBuilder {
|
class BlogPostBuilder {
|
||||||
private _title = 'default title';
|
private _title = 'default title';
|
||||||
|
private _html = 'default html';
|
||||||
|
private _excerpt = 'default excerpt';
|
||||||
private _author = 'default author';
|
private _author = 'default author';
|
||||||
private _date = new Date('2022-01-01T00:00Z');
|
private _date = new Date('2022-01-01T00:00Z');
|
||||||
private _slug = 'default-slug';
|
private _slug = 'default-slug';
|
||||||
private _fileName = 'default-file-name.md';
|
private _fileName = 'default-file-name.md';
|
||||||
|
|
||||||
private _markdownContent = 'default markdown content';
|
|
||||||
|
|
||||||
withTitle(title: string): BlogPostBuilder {
|
withTitle(title: string): BlogPostBuilder {
|
||||||
this._title = title;
|
this._title = title;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
withMarkdownContent(markdownContent: string): BlogPostBuilder {
|
withHtml(markdownContent: string): BlogPostBuilder {
|
||||||
this._markdownContent = markdownContent;
|
this._html = markdownContent;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withExcerpt(excerpt: string): BlogPostBuilder {
|
||||||
|
this._excerpt = excerpt;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,20 +44,15 @@ class BlogPostBuilder {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async constructAndThenBuild(): Promise<BlogPost> {
|
|
||||||
const blogPost = this.build();
|
|
||||||
await blogPost.build();
|
|
||||||
return blogPost;
|
|
||||||
}
|
|
||||||
|
|
||||||
build(): BlogPost {
|
build(): BlogPost {
|
||||||
return new BlogPost({
|
return new BlogPost({
|
||||||
title: this._title,
|
title: this._title,
|
||||||
markdownContent: this._markdownContent,
|
html: this._html,
|
||||||
author: this._author,
|
author: this._author,
|
||||||
date: this._date,
|
date: this._date,
|
||||||
slug: this._slug,
|
slug: this._slug,
|
||||||
fileName: this._fileName,
|
fileName: this._fileName,
|
||||||
|
excerpt: this._excerpt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class BookReviewBuilder {
|
||||||
private image = 'default image';
|
private image = 'default image';
|
||||||
private score = 0;
|
private score = 0;
|
||||||
private slug = 'default slug';
|
private slug = 'default slug';
|
||||||
private markdownContent = 'default markdown content';
|
private html = 'default markdown content';
|
||||||
|
|
||||||
withTitle(title: string): BookReviewBuilder {
|
withTitle(title: string): BookReviewBuilder {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
|
|
@ -51,8 +51,8 @@ class BookReviewBuilder {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
withMarkdownContent(markdownContent: string): BookReviewBuilder {
|
withHtml(html: string): BookReviewBuilder {
|
||||||
this.markdownContent = markdownContent;
|
this.html = html;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ class BookReviewBuilder {
|
||||||
image: this.image,
|
image: this.image,
|
||||||
score: this.score,
|
score: this.score,
|
||||||
slug: this.slug,
|
slug: this.slug,
|
||||||
markdownContent: this.markdownContent,
|
html: this.html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { aBlogPost } from './blog-post-builder.js';
|
export { aBlogPost } from './blog-post-builder.js';
|
||||||
|
export { aSnoutStreetStudiosPost } from './snout-street-studios-post-builder.js';
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
19
src/lib/blog/test-fixtures/snout-street-studio-post-test.md
Normal file
19
src/lib/blog/test-fixtures/snout-street-studio-post-test.md
Normal 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.
|
||||||
2
src/lib/blog/test-fixtures/test-file.md
Normal file
2
src/lib/blog/test-fixtures/test-file.md
Normal 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>
|
||||||
22
src/lib/snout-street-studios/ApiGateway.ts
Normal file
22
src/lib/snout-street-studios/ApiGateway.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/lib/snout-street-studios/SnoutStreetStudiosPost.test.ts
Normal file
26
src/lib/snout-street-studios/SnoutStreetStudiosPost.test.ts
Normal 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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/lib/snout-street-studios/SnoutStreetStudiosPost.ts
Normal file
74
src/lib/snout-street-studios/SnoutStreetStudiosPost.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface SnoutStreetStudiosPostDto {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
date: Date;
|
||||||
|
excerpt: string;
|
||||||
|
}
|
||||||
3
src/lib/snout-street-studios/index.ts
Normal file
3
src/lib/snout-street-studios/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type { SnoutStreetStudiosPostDto } from './SnoutStreetStudiosPostDto.js';
|
||||||
|
export { SnoutStreetStudiosPost } from './SnoutStreetStudiosPost.js';
|
||||||
|
export { SnoutStreetStudiosApiGateway } from './ApiGateway.js';
|
||||||
|
|
@ -3,11 +3,16 @@ import { BlogController } from '../../../lib/blog/BlogController.js';
|
||||||
|
|
||||||
export const GET = async () => {
|
export const GET = async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log(`GET /api/blog.json`);
|
||||||
const controller = await BlogController.singleton();
|
const controller = await BlogController.singleton();
|
||||||
|
console.log(`Controller instantiated.`);
|
||||||
const blogPosts = await controller.getAllBlogPosts();
|
const blogPosts = await controller.getAllBlogPosts();
|
||||||
return json({ posts: blogPosts });
|
return json({ posts: blogPosts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error({ error: JSON.stringify(error) });
|
console.error({
|
||||||
|
message: `Caught error in GET /api/blog.json`,
|
||||||
|
error: JSON.stringify(error),
|
||||||
|
});
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
error: 'Could not fetch posts. ' + error,
|
error: 'Could not fetch posts. ' + error,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export const GET = async ({ params }: LoadEvent) => {
|
||||||
const controller = await BlogController.singleton();
|
const controller = await BlogController.singleton();
|
||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
|
|
||||||
const post = await controller.getBlogOrBookReviewBySlug(slug);
|
const post = await controller.getAnyKindOfContentBySlug(slug);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
throw error(404, `Could not find blog post with slug '${slug}'`);
|
throw error(404, `Could not find blog post with slug '${slug}'`);
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,5 @@ export const POST: RequestHandler = async ({ getClientAddress, request }) => {
|
||||||
const resolvedFileName = resolve(thisDirectory, `../../../../content/blog/${fileName}`);
|
const resolvedFileName = resolve(thisDirectory, `../../../../content/blog/${fileName}`);
|
||||||
|
|
||||||
await controller.createBlogPost(resolvedFileName, contentWithFrontmatter);
|
await controller.createBlogPost(resolvedFileName, contentWithFrontmatter);
|
||||||
console.log({ address });
|
|
||||||
return json({ address });
|
return json({ address });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from "./$types.js";
|
import type { PageData } from "./$types.js";
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
import { intlFormat } from "date-fns";
|
import BlogPostListItem from "./BlogPostListItem.svelte";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
$: ({
|
$: ({
|
||||||
posts,
|
posts,
|
||||||
firstPost,
|
|
||||||
numberOfPosts,
|
numberOfPosts,
|
||||||
daysSinceLastPublish,
|
daysSinceLastPublish,
|
||||||
daysSinceFirstPost,
|
daysSinceFirstPost,
|
||||||
averageDaysBetweenPosts,
|
averageDaysBetweenPosts,
|
||||||
numberOfBlogPostsThisYear
|
numberOfBlogPostsThisYear,
|
||||||
} = data);
|
} = data);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -50,7 +49,7 @@
|
||||||
It has been been
|
It has been been
|
||||||
<span
|
<span
|
||||||
class="days-since"
|
class="days-since"
|
||||||
class:days-since-success={daysSinceLastPublish === 0}
|
class:days-since-success="{daysSinceLastPublish === 0}"
|
||||||
>
|
>
|
||||||
{daysSinceLastPublish}
|
{daysSinceLastPublish}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -74,34 +73,17 @@
|
||||||
<h2>All Writing</h2>
|
<h2>All Writing</h2>
|
||||||
<ul class="posts">
|
<ul class="posts">
|
||||||
{#each posts as post, index}
|
{#each posts as post, index}
|
||||||
<li
|
<BlogPostListItem
|
||||||
class="post"
|
index="{index}"
|
||||||
role="article"
|
content_type="{post.content_type}"
|
||||||
aria-posinset={index + 1}
|
book_review="{post.book_review}"
|
||||||
aria-setsize={posts.length}
|
date="{post.date}"
|
||||||
>
|
numberOfPosts="{posts.length}"
|
||||||
<a href={`/blog/${post.slug}`}>
|
preview="{post.preview}"
|
||||||
<div class="post-title">
|
slug="{post.slug}"
|
||||||
{#if post.book_review} 📚 {/if}{post.title}
|
title="{post.title}"
|
||||||
</div>
|
/>
|
||||||
|
{/each}
|
||||||
<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}
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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 {
|
.days-since {
|
||||||
color: var(--brand-orange);
|
color: var(--brand-orange);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface BlogPostListItem {
|
||||||
date: string;
|
date: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
content_type: 'blog' | 'book_review' | 'snout_street_studios';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function load({ fetch }: LoadEvent) {
|
export async function load({ fetch }: LoadEvent) {
|
||||||
|
|
@ -18,7 +19,9 @@ export async function load({ fetch }: LoadEvent) {
|
||||||
.then((res) => res.posts);
|
.then((res) => res.posts);
|
||||||
|
|
||||||
const currentYear = getYear(new Date());
|
const currentYear = getYear(new Date());
|
||||||
|
console.log({ posts });
|
||||||
const mostRecentPost = posts[0];
|
const mostRecentPost = posts[0];
|
||||||
|
console.log({ ...mostRecentPost });
|
||||||
|
|
||||||
const daysSinceLastPublish = differenceInCalendarDays(new Date(), new Date(mostRecentPost.date));
|
const daysSinceLastPublish = differenceInCalendarDays(new Date(), new Date(mostRecentPost.date));
|
||||||
|
|
||||||
|
|
|
||||||
90
src/routes/blog/BlogPostListItem.svelte
Normal file
90
src/routes/blog/BlogPostListItem.svelte
Normal 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>
|
||||||
|
|
@ -2,43 +2,54 @@
|
||||||
import type { PageData } from "./$types.js";
|
import type { PageData } from "./$types.js";
|
||||||
import { intlFormat } from "date-fns";
|
import { intlFormat } from "date-fns";
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
$: ({ date, post } = data);
|
$: ({ date, post } = data);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
console.log({ date, post });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>{post.title} | thomaswilson.xyz</title>
|
<title>{post.title} | thomaswilson.xyz</title>
|
||||||
<meta name="title" content="Blog | thomaswilson.xyz" />
|
<meta name="title" content="Blog | thomaswilson.xyz" />
|
||||||
<meta name="description" content={post.preview} />
|
<meta name="description" content="{post.preview}" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta
|
<meta
|
||||||
property="og:url"
|
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:title" content="{post.title}" />
|
||||||
<meta property="og:description" content={post.preview} />
|
<meta property="og:description" content="{post.preview}" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:title" content={post.title} />
|
<meta property="twitter:title" content="{post.title}" />
|
||||||
<meta property="twitter:description" content={post.preview} />
|
<meta property="twitter:description" content="{post.preview}" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main class="thomaswilson-container">
|
<main class="thomaswilson-container">
|
||||||
<header class="section">
|
<header class="section">
|
||||||
<h1 class="title post-title">{post.title}</h1>
|
<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">
|
<p class="post-date">
|
||||||
{intlFormat(date, {
|
{intlFormat(date, {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
localeMatcher: "best fit"
|
localeMatcher: "best fit",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@
|
||||||
let slug = "";
|
let slug = "";
|
||||||
let blogPost: BlogPost | null = null;
|
let blogPost: BlogPost | null = null;
|
||||||
|
|
||||||
$: safeContent = JSON.stringify(content);
|
|
||||||
|
|
||||||
function slugifyString(originalString: string): string {
|
function slugifyString(originalString: string): string {
|
||||||
return originalString
|
return originalString
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
@ -25,20 +23,6 @@
|
||||||
slug = `${dateAsString}-${slugifiedTitle}`;
|
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() {
|
async function onCreate() {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
title,
|
title,
|
||||||
|
|
@ -46,18 +30,18 @@
|
||||||
slug,
|
slug,
|
||||||
markdownContent: content,
|
markdownContent: content,
|
||||||
fileName: `${slug}.md`,
|
fileName: `${slug}.md`,
|
||||||
date: date.toISOString()
|
date: date.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
fetch("/api/blog/new.json", {
|
fetch("/api/blog/new.json", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody)
|
body: JSON.stringify(requestBody),
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
return await goto(`blog/${slug}`);
|
await goto(`/blog/${slug}`);
|
||||||
} else {
|
} else {
|
||||||
alert("Something went wrong");
|
alert("Something went wrong");
|
||||||
}
|
}
|
||||||
|
|
@ -66,41 +50,36 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="new-blog-post">
|
<section class="new-blog-post">
|
||||||
|
<a href="/blog">Back to Blog</a>
|
||||||
<h1>New Blog Post</h1>
|
<h1>New Blog Post</h1>
|
||||||
<form on:submit|preventDefault={onCreate}>
|
<form on:submit|preventDefault="{onCreate}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="title">Title</label>
|
<label class="field__label" for="title">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
required
|
required
|
||||||
bind:value={title}
|
bind:value="{title}"
|
||||||
on:change={handleTitleChange}
|
on:change="{handleTitleChange}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="author">Author</label>
|
<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>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="slug">Slug</label>
|
<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>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="content">Content</label>
|
<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>
|
||||||
|
|
||||||
<div class="submit">
|
<div class="submit">
|
||||||
<button
|
|
||||||
class="preview-button"
|
|
||||||
type="button"
|
|
||||||
on:click|preventDefault={onCreatePreviewBlogPost}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
<button class="create-button">Create</button>
|
<button class="create-button">Create</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
3
src/routes/snout-street-studios/[slug]/+layout.svelte
Normal file
3
src/routes/snout-street-studios/[slug]/+layout.svelte
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<h1>Snout St. Studios</h1>
|
||||||
|
|
||||||
|
<slot />
|
||||||
0
src/routes/snout-street-studios/[slug]/+page.svelte
Normal file
0
src/routes/snout-street-studios/[slug]/+page.svelte
Normal file
0
src/routes/snout-street-studios/new/+page.svelte
Normal file
0
src/routes/snout-street-studios/new/+page.svelte
Normal file
10
src/routes/snout-street-studios/new/+page.ts
Normal file
10
src/routes/snout-street-studios/new/+page.ts
Normal 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 {};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b0716be726d41eee483c8291e69f834d4b8d5ba61a64eb4236796c509e3fa8f2
|
||||||
|
size 344778
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
/** @type {import('vite').UserConfig} */
|
/** @type {import('vite').UserConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
resolve: {}
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
$lib: '/src/lib',
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
// vitest.config.js
|
// vitest.config.js
|
||||||
export default {
|
export default {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
$lib: '/src/lib',
|
||||||
|
}
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
deps: {
|
deps: {
|
||||||
inline: [
|
inline: [
|
||||||
|
|
|
||||||
|
|
@ -3040,10 +3040,10 @@ yocto-queue@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
||||||
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
||||||
|
|
||||||
zod@^3.18.0:
|
zod@^3.22.2:
|
||||||
version "3.19.1"
|
version "3.22.2"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.1.tgz#112f074a97b50bfc4772d4ad1576814bd8ac4473"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
|
||||||
integrity sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==
|
integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
|
||||||
|
|
||||||
zwitch@^2.0.0, zwitch@^2.0.4:
|
zwitch@^2.0.0, zwitch@^2.0.4:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue