feat: add individual post page (#4)

* chore: add markdown lint to ignore no h1 heading in markdown files

* chore: update example settings

* chore: update example development env file

* fix: remove fixed 100% height on container

* feat: add individual post page

* chore: update README.md

* feat: add links to headings in post and add copy button to code blocks

* tests: add tests for get post async method

* tests: remove heading for test blogs

* fix: only add links to headings in markdown body

* tests: add tests for post page

* tests: add test to check for blog content

* docs: add jsdoc comments

* chore: fix incorrect env variable format in example

* fix: don't render anything if blog post is null

* fix: don't render anything until posts are fetched

* chore: add docker compose file for prod
This commit is contained in:
Stevan Freeborn
2024-04-11 00:58:22 -05:00
committed by GitHub
parent 75831e5f45
commit dd7e7e1783
19 changed files with 1774 additions and 13 deletions
+5 -1
View File
@@ -6,5 +6,9 @@
"Markdig", "Markdig",
"msedge" "msedge"
], ],
"dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings" "dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings",
"markdownlint.config": {
"default": true,
"MD041": false,
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
# blog.stevanfreeborn.com # blog.stevanfreeborn.com
⚠️ Under Construction ⚠️ ⚠️ Under Construction ⚠️
The source code for [my blog](https://blog.stevanfreeborn.com). The source code for [my blog](https://blog.stevanfreeborn.com).
+13
View File
@@ -0,0 +1,13 @@
version: '3.8'
name: blog-production
services:
blog:
build:
target: production-stage
context: ./src/Blog/
dockerfile: Dockerfile
ports:
- 8080
restart: always
environment:
- FilePostServiceOptions__PostsDirectory=wwwroot/posts
@@ -1,5 +1,4 @@
.container { .container {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
+82
View File
@@ -0,0 +1,82 @@
@page "/{slug:nonfile}"
@implements IAsyncDisposable
@inject IPostService PostService
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
@inject ILogger<Post> Logger
@if (BlogPost is null)
{
return;
}
else
{
<PageTitle>@BlogPost.Title - journal</PageTitle>
<HeadContent>
<meta name="description" content="@BlogPost.Lead">
<link rel="stylesheet" href="markdown.css">
<link rel="stylesheet" href="prism.css">
<script src="prism.js"></script>
<script src="https://cdn.jsdelivr.net/npm/anchor-js/anchor.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
</HeadContent>
<article>
<div class="heading-container">
<h2>@BlogPost.Title</h2>
<p>@BlogPost.PublishedAt.ToString("MMMM d, yyyy")</p>
</div>
<div class="markdown-body">
@((MarkupString)BlogPost.Content)
</div>
</article>
}
@code {
[Parameter]
public string Slug { get; set; } = string.Empty;
private PostWithContent? BlogPost;
private IJSObjectReference? module;
protected override async Task OnInitializedAsync()
{
var post = await PostService.GetPostAsync(Slug);
if (post is null)
{
NavigationManager.NavigateTo("/Error/404");
return;
}
BlogPost = post;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./Components/Pages/Post.razor.js");
await module.InvokeVoidAsync("addAnchors");
await module.InvokeVoidAsync("addClipboard");
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
try
{
if (module is not null)
{
await module.DisposeAsync();
}
}
catch (Exception ex) when (ex is JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error disposing of the module");
}
}
}
+42
View File
@@ -0,0 +1,42 @@
article {
display: flex;
flex-direction: column;
gap: 2rem;
& .heading-container {
& h2 {
font-size: 2.5rem;
font-weight: 900;
}
& p {
font-size: 0.85rem;
}
}
.markdown-body {
& pre {
position: relative;
& .copy-button {
position: absolute;
top: 0;
right: 0;
cursor: pointer;
color: var(--primary-white);
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
transition-property: color;
transition-duration: 0.3s;
transition-timing-function: ease-in-out;
& svg {
width: 1rem;
height: 1rem;
}
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @summary Adds anchors to each heading in the post
* so that users can link directly to them.
*/
export function addAnchors() {
const selectors = [
'.markdown-body h2',
'.markdown-body h3',
'.markdown-body h4',
'.markdown-body h5',
'.markdown-body h6'
];
// relies on AnchorJS library
// being loaded in Post.razor
anchors.add(selectors.join(','));
}
/**
* @summary Adds a button to each code block that
* allows users to copy the code to their clipboard.
*/
export function addClipboard() {
const codeBlocks = document.querySelectorAll('pre');
codeBlocks.forEach((block) => {
const button = document.createElement('button');
button.className = 'copy-button';
button.type = 'button';
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor">
<path d="M384 336H192c-8.8 0-16-7.2-16-16V64c0-8.8 7.2-16 16-16l140.1 0L400 115.9V320c0 8.8-7.2 16-16 16zM192 384H384c35.3 0 64-28.7 64-64V115.9c0-12.7-5.1-24.9-14.1-33.9L366.1 14.1c-9-9-21.2-14.1-33.9-14.1H192c-35.3 0-64 28.7-64 64V320c0 35.3 28.7 64 64 64zM64 128c-35.3 0-64 28.7-64 64V448c0 35.3 28.7 64 64 64H256c35.3 0 64-28.7 64-64V416H272v32c0 8.8-7.2 16-16 16H64c-8.8 0-16-7.2-16-16V192c0-8.8 7.2-16 16-16H96V128H64z"/>
</svg>
`
button.onclick = () => {
button.style.color = 'green';
setTimeout(() => {
button.style.color = '';
}, 1000);
};
const buttonText = document.createElement('span');
buttonText.className = 'sr-only';
buttonText.textContent = 'Copy';
button.appendChild(buttonText);
block.appendChild(button);
});
// relies on ClipboardJS library
// being loaded in Post.razor
var clipboard = new ClipboardJS('.copy-button', {
target: (trigger) => {
return trigger.previousElementSibling;
}
});
clipboard.on('success', function(e) {
e.clearSelection();
});
}
+9 -4
View File
@@ -2,7 +2,12 @@
@inject IPostService postService @inject IPostService postService
@inject IJSRuntime JS @inject IJSRuntime JS
@if (posts.Count == 0)
@if (Posts is null)
{
return;
}
else if (Posts.Count == 0)
{ {
<div class="container"> <div class="container">
<p>Looks like writers block. Check back later.</p> <p>Looks like writers block. Check back later.</p>
@@ -11,7 +16,7 @@
else else
{ {
<section role="feed" class="container"> <section role="feed" class="container">
@foreach (var post in posts) @foreach (var post in Posts)
{ {
<a href="/@post.Slug"> <a href="/@post.Slug">
<article> <article>
@@ -25,10 +30,10 @@
} }
@code { @code {
private List<Post> posts = []; private List<Post>? Posts;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
posts = await postService.GetPostsAsync(); Posts = await postService.GetPostsAsync();
} }
} }
+42
View File
@@ -105,4 +105,46 @@ class FilePostService(
return [.. posts.OrderByDescending(x => x.PublishedAt)]; return [.. posts.OrderByDescending(x => x.PublishedAt)];
} }
public async Task<PostWithContent?> GetPostAsync(string slug)
{
try
{
var postPath = _fileSystem.Path.Combine(_options.PostsDirectory, slug, IndexFile);
if (_fileSystem.File.Exists(postPath) is false)
{
return null;
}
var postText = await _fileSystem.File.ReadAllTextAsync(postPath);
var (metaContent, document) = ParsePost(postText);
if (metaContent is null || document.Count is 0)
{
return null;
}
var post = JsonSerializer.Deserialize<PostWithContent>(metaContent, JsonSerializerOptions);
if (post is null || post.IsPublished is false)
{
return null;
}
var postWithContent = post with
{
Slug = slug,
Content = document.ToHtml(MarkdownPipeline)
};
return postWithContent;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while getting post {Slug}", slug);
return null;
}
}
} }
+1
View File
@@ -3,4 +3,5 @@ namespace Blog.Posts;
interface IPostService interface IPostService
{ {
Task<List<Post>> GetPostsAsync(); Task<List<Post>> GetPostsAsync();
Task<PostWithContent?> GetPostAsync(string slug);
} }
+7
View File
@@ -1,5 +1,12 @@
{ {
"Urls": "https://localhost:4500;http://localhost:4501", "Urls": "https://localhost:4500;http://localhost:4501",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"FilePostServiceOptions": { "FilePostServiceOptions": {
"PostsDirectory": "wwwroot/posts" "PostsDirectory": "wwwroot/posts"
}, },
+1
View File
@@ -1,4 +1,5 @@
URLS=https://0.0.0.0:4500;http://0.0.0.0:4501 URLS=https://0.0.0.0:4500;http://0.0.0.0:4501
FilePostServiceOptions__PostsDirectory=wwwroot/posts
# Path needs to match the path where the cert file # Path needs to match the path where the cert file
# is mounted in the container # is mounted in the container
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
```json meta
{
"title": "Test Blog",
"lead": "This is a test blog post.",
"isPublished": true,
"publishedAt": "2024-04-10"
}
```
Welcome to my Markdown blog post! In this post, I'll cover various Markdown elements to help you test styling.
## Text Formatting
This is a ""citation of someone""
Here are some examples of text formatting:
- *Italic Text*
- **Bold Text**
- ***Bold Italic Text***
- ~~Strikethrough Text~~
## Lists
### Unordered List
- Item 1
- Item 2
- Subitem A
- Subitem B
- Item 3
### Ordered List
1. First item
2. Second item
3. Third item
## Links and Images
### Link
[OpenAI](https://openai.com) - An amazing AI research organization.
### Image
![Cute Dog](https://placedog.net/672?random)
## Blockquotes
> Markdown is a lightweight markup language with plain-text formatting syntax.
## Code Blocks
```python
def greet(name):
print(f"Hello, {name}!")
greet("World")
```
## Tables
| Name | Age |
|-------|-----|
| Alice | 30 |
| Bob | 25 |
| Carol | 35 |
## Horizontal Rule
---
## Conclusion
That's it for this Markdown blog post! Feel free to experiment with styling these elements further.
+1 -1
View File
@@ -29,7 +29,7 @@ pre[class*=language-] {
:not(pre)>code[class*=language-], :not(pre)>code[class*=language-],
pre[class*=language-] { pre[class*=language-] {
background: #2d2d2d background: var(--secondary-black);
} }
:not(pre)>code[class*=language-] { :not(pre)>code[class*=language-] {
+48
View File
@@ -0,0 +1,48 @@
namespace Blog.Tests.EndToEnd;
[TestFixture]
public class PostTests : BlogTest
{
private const string TestPostSlug = "test-blog";
private const string TestPostTitle = "Test Blog";
private const string TestPostDate = "January 1, 2021";
[Test]
public async Task Post_WhenNavigatedTo_ItShouldDisplayCorrectPageTitle()
{
await Page.GotoAsync($"/{TestPostSlug}");
var title = await Page.TitleAsync();
title.Should().Be($"{TestPostTitle} - journal");
}
[Test]
public async Task Post_WhenNavigatedTo_ItShouldPostTitle()
{
await Page.GotoAsync($"/{TestPostSlug}");
var heading = Page.GetByRole(AriaRole.Heading, new() { Name = TestPostTitle });
await Expect(heading).ToBeVisibleAsync();
}
[Test]
public async Task Post_WhenNavigatedTo_ItShouldDisplayPostDate()
{
await Page.GotoAsync($"/{TestPostSlug}");
var date = Page.GetByText(TestPostDate);
await Expect(date).ToBeVisibleAsync();
}
[Test]
public async Task Post_WhenNavigatedTo_ItShouldDisplayContent()
{
await Page.GotoAsync($"/{TestPostSlug}");
var content = Page.GetByRole(AriaRole.Article);
await Expect(content).ToBeVisibleAsync();
}
[Test]
public async Task Post_WhenNavigatedToAndPostDoesNotExist_ItShouldDisplay404()
{
await Page.GotoAsync("/non-existent-post");
Page.Url.Should().Contain("/Error/404");
}
}
@@ -1,5 +1,3 @@
# Test Blog
```json meta ```json meta
{ {
"title": "Another Test Blog", "title": "Another Test Blog",
@@ -1,5 +1,3 @@
# Test Blog
```json meta ```json meta
{ {
"title": "Test Blog", "title": "Test Blog",
@@ -420,4 +420,197 @@ class FilePostServiceTests
} }
]); ]);
} }
[Test]
public async Task GetPostAsync_WhenCalledAndSlugDoesNotExist_ItShouldReturnNull()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(false);
var result = await _sut.GetPostAsync("slug");
result.Should().BeNull();
}
[Test]
public async Task GetPostAsync_WhenCalledAndPostExistsButContainsNoMetaData_ItShouldReturnNull()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("slug/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync("# Post Title");
var result = await _sut.GetPostAsync("slug");
result.Should().BeNull();
}
[Test]
public async Task GetPostAsync_WhenCalledAndPostContainsNoContent_ItShouldReturnNull()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("slug/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
```json meta
{
"title": "Post Title",
"date": "2021-01-01"
}
```
"""
);
var result = await _sut.GetPostAsync("slug");
result.Should().BeNull();
}
[Test]
public async Task GetPostAsync_WhenCalledAndPostExistsButContainsInvalidMetaData_ItShouldReturnNull()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("slug/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"isPublished": "true",
"date": "2021-01-01"
}
```
"""
);
var result = await _sut.GetPostAsync("slug");
result.Should().BeNull();
}
[Test]
public async Task GetPostAsync_WhenCalledAndPostExistsButIsNotPublished_ItShouldReturnNull()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("slug/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"lead": "Post lead",
"isPublished": false,
"publishedAt": "2021-01-01"
}
```
Post content
"""
);
var result = await _sut.GetPostAsync("slug");
result.Should().BeNull();
}
[Test]
public async Task GetPostAsync_WhenCalledAndPostExists_ItShouldReturnPostWithContent()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("slug/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"lead": "Post lead",
"isPublished": true,
"publishedAt": "2021-01-01",
"slug": "slug"
}
```
Post content
"""
);
var result = await _sut.GetPostAsync("slug");
result.Should().NotBeNull();
result.Should().BeEquivalentTo(new PostWithContent
{
Title = "Post Title",
Lead = "Post lead",
IsPublished = true,
PublishedAt = new DateTime(2021, 1, 1),
Slug = "slug",
Content = "<h1 id=\"post-title\">Post Title</h1>\n<p>Post content</p>\n",
});
}
} }