From dd7e7e17830153eb40b1243c1f019c398d5734a9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 11 Apr 2024 00:58:22 -0500 Subject: [PATCH] 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 --- .vscode/settings.json | 6 +- README.md | 4 +- docker-compose.prod.yml | 13 + .../Components/Layout/MainLayout.razor.css | 1 - src/Blog/Components/Pages/Post.razor | 82 ++ src/Blog/Components/Pages/Post.razor.css | 42 + src/Blog/Components/Pages/Post.razor.js | 62 + src/Blog/Components/Posts/Feed.razor | 13 +- src/Blog/Posts/FilePostService.cs | 42 + src/Blog/Posts/IPostService.cs | 1 + src/Blog/appsettings.example.json | 7 + src/Blog/example.env.development | 1 + src/Blog/wwwroot/markdown.css | 1190 +++++++++++++++++ src/Blog/wwwroot/posts/test-blog/index.md | 76 ++ src/Blog/wwwroot/prism.css | 2 +- test/Blog.Tests/EndToEnd/PostTests.cs | 48 + .../TestPosts/another-test-blog/index.md | 2 - .../EndToEnd/TestPosts/test-blog/index.md | 2 - test/Blog.Tests/Unit/FilePostServiceTests.cs | 193 +++ 19 files changed, 1774 insertions(+), 13 deletions(-) create mode 100644 docker-compose.prod.yml create mode 100644 src/Blog/Components/Pages/Post.razor create mode 100644 src/Blog/Components/Pages/Post.razor.css create mode 100644 src/Blog/Components/Pages/Post.razor.js create mode 100644 src/Blog/wwwroot/markdown.css create mode 100644 src/Blog/wwwroot/posts/test-blog/index.md create mode 100644 test/Blog.Tests/EndToEnd/PostTests.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index d26908d..035fb60 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,9 @@ "Markdig", "msedge" ], - "dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings" + "dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings", + "markdownlint.config": { + "default": true, + "MD041": false, + } } \ No newline at end of file diff --git a/README.md b/README.md index 2857592..7a4df74 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # blog.stevanfreeborn.com -⚠️ Under Construction ⚠️ +⚠️ Under Construction ⚠️ -The source code for [my blog](https://blog.stevanfreeborn.com). \ No newline at end of file +The source code for [my blog](https://blog.stevanfreeborn.com). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..1ef0b21 --- /dev/null +++ b/docker-compose.prod.yml @@ -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 \ No newline at end of file diff --git a/src/Blog/Components/Layout/MainLayout.razor.css b/src/Blog/Components/Layout/MainLayout.razor.css index b194dfa..6ddc2c9 100644 --- a/src/Blog/Components/Layout/MainLayout.razor.css +++ b/src/Blog/Components/Layout/MainLayout.razor.css @@ -1,5 +1,4 @@ .container { - height: 100%; display: flex; flex-direction: column; align-items: center; diff --git a/src/Blog/Components/Pages/Post.razor b/src/Blog/Components/Pages/Post.razor new file mode 100644 index 0000000..7c4f20a --- /dev/null +++ b/src/Blog/Components/Pages/Post.razor @@ -0,0 +1,82 @@ +@page "/{slug:nonfile}" +@implements IAsyncDisposable +@inject IPostService PostService +@inject NavigationManager NavigationManager +@inject IJSRuntime JSRuntime +@inject ILogger Logger + +@if (BlogPost is null) +{ + return; +} +else +{ + @BlogPost.Title - journal + + + + + + + + + + +
+
+

@BlogPost.Title

+

@BlogPost.PublishedAt.ToString("MMMM d, yyyy")

+
+
+ @((MarkupString)BlogPost.Content) +
+
+} + +@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("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"); + } + } +} \ No newline at end of file diff --git a/src/Blog/Components/Pages/Post.razor.css b/src/Blog/Components/Pages/Post.razor.css new file mode 100644 index 0000000..a910ea2 --- /dev/null +++ b/src/Blog/Components/Pages/Post.razor.css @@ -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; + } + } + } + } +} \ No newline at end of file diff --git a/src/Blog/Components/Pages/Post.razor.js b/src/Blog/Components/Pages/Post.razor.js new file mode 100644 index 0000000..04db9aa --- /dev/null +++ b/src/Blog/Components/Pages/Post.razor.js @@ -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 = ` + + + + ` + 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(); + }); +} \ No newline at end of file diff --git a/src/Blog/Components/Posts/Feed.razor b/src/Blog/Components/Posts/Feed.razor index f4be89a..ae9ed17 100644 --- a/src/Blog/Components/Posts/Feed.razor +++ b/src/Blog/Components/Posts/Feed.razor @@ -2,7 +2,12 @@ @inject IPostService postService @inject IJSRuntime JS - @if (posts.Count == 0) + + @if (Posts is null) + { + return; + } + else if (Posts.Count == 0) {

Looks like writers block. Check back later.

@@ -11,7 +16,7 @@ else {
- @foreach (var post in posts) + @foreach (var post in Posts) {
@@ -25,10 +30,10 @@ } @code { - private List posts = []; + private List? Posts; protected override async Task OnInitializedAsync() { - posts = await postService.GetPostsAsync(); + Posts = await postService.GetPostsAsync(); } } \ No newline at end of file diff --git a/src/Blog/Posts/FilePostService.cs b/src/Blog/Posts/FilePostService.cs index 072969f..449618e 100644 --- a/src/Blog/Posts/FilePostService.cs +++ b/src/Blog/Posts/FilePostService.cs @@ -105,4 +105,46 @@ class FilePostService( return [.. posts.OrderByDescending(x => x.PublishedAt)]; } + + public async Task 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(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; + } + } } \ No newline at end of file diff --git a/src/Blog/Posts/IPostService.cs b/src/Blog/Posts/IPostService.cs index def70c0..ac912ad 100644 --- a/src/Blog/Posts/IPostService.cs +++ b/src/Blog/Posts/IPostService.cs @@ -3,4 +3,5 @@ namespace Blog.Posts; interface IPostService { Task> GetPostsAsync(); + Task GetPostAsync(string slug); } \ No newline at end of file diff --git a/src/Blog/appsettings.example.json b/src/Blog/appsettings.example.json index d4be169..33ba1f0 100644 --- a/src/Blog/appsettings.example.json +++ b/src/Blog/appsettings.example.json @@ -1,5 +1,12 @@ { "Urls": "https://localhost:4500;http://localhost:4501", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", "FilePostServiceOptions": { "PostsDirectory": "wwwroot/posts" }, diff --git a/src/Blog/example.env.development b/src/Blog/example.env.development index d1e6fbc..f647574 100644 --- a/src/Blog/example.env.development +++ b/src/Blog/example.env.development @@ -1,4 +1,5 @@ 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 # is mounted in the container diff --git a/src/Blog/wwwroot/markdown.css b/src/Blog/wwwroot/markdown.css new file mode 100644 index 0000000..34c968e --- /dev/null +++ b/src/Blog/wwwroot/markdown.css @@ -0,0 +1,1190 @@ +@media (prefers-color-scheme: dark) { + .markdown-body, + [data-theme="dark"] { + /*dark*/ + color-scheme: dark; + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-fg-default: #e6edf3; + --color-fg-muted: #848d97; + --color-fg-subtle: #6e7681; + --color-canvas-default: #0d1117; + --color-canvas-subtle: #161b22; + --color-border-default: #30363d; + --color-border-muted: #21262d; + --color-neutral-muted: rgba(110,118,129,0.4); + --color-accent-fg: #2f81f7; + --color-accent-emphasis: #1f6feb; + --color-success-fg: #3fb950; + --color-success-emphasis: #238636; + --color-attention-fg: #d29922; + --color-attention-emphasis: #9e6a03; + --color-attention-subtle: rgba(187,128,9,0.15); + --color-danger-fg: #f85149; + --color-danger-emphasis: #da3633; + --color-done-fg: #a371f7; + --color-done-emphasis: #8957e5; + } +} + +@media (prefers-color-scheme: light) { + .markdown-body, + [data-theme="light"] { + /*light*/ + color-scheme: light; + --color-prettylights-syntax-comment: #57606a; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #6639ba; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-fg-default: #1F2328; + --color-fg-muted: #656d76; + --color-fg-subtle: #6e7781; + --color-canvas-default: #ffffff; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210,18%,87%,1); + --color-neutral-muted: rgba(175,184,193,0.2); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-success-fg: #1a7f37; + --color-success-emphasis: #1f883d; + --color-attention-fg: #9a6700; + --color-attention-emphasis: #9a6700; + --color-attention-subtle: #fff8c5; + --color-danger-fg: #d1242f; + --color-danger-emphasis: #cf222e; + --color-done-fg: #8250df; + --color-done-emphasis: #8250df; + } +} + +.markdown-body { + margin: 0; + color: inherit; + background-color: inherit; + word-wrap: break-word; +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--color-accent-fg); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid var(--color-border-muted); +} + +.markdown-body mark { + background-color: var(--color-attention-subtle); + color: var(--color-fg-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-radius: 0.5rem; + border-style: none; + max-width: 100%; + box-sizing: content-box; + background-color: inherit; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 40px; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--color-border-muted); + height: .25em; + padding: 0; + margin: 24px 0; + background-color: var(--color-border-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--color-fg-subtle); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body details:not([open])>*:not(summary) { + display: none !important; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + line-height: 10px; + color: var(--color-fg-default); + vertical-align: middle; + background-color: var(--color-canvas-subtle); + border: solid 1px var(--color-neutral-muted); + border-bottom-color: var(--color-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--color-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid var(--color-border-muted); +} + +.markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; +} + +.markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; +} + +.markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--color-fg-muted); +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--color-fg-muted); + border-left: .25em solid var(--color-border-default); +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: var(--base-size-8, 8px) !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--color-danger-fg); +} + +.markdown-body .anchor { + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--color-fg-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: 16px; +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--color-border-default); +} + +.markdown-body table td>:last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: inherit; + border-top: 1px solid var(--color-border-muted); +} + +.markdown-body table tr:nth-child(2n) { + background-color: inherit; +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--color-border-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--color-fg-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--color-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: var(--color-fg-default); + background-color: var(--color-canvas-subtle); + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px 8px 9px; + text-align: right; + background: var(--color-canvas-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--color-canvas-subtle); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--color-fg-muted); + border-top: 1px solid var(--color-border-default); +} + +.markdown-body .footnotes ol { + padding-left: 16px; +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 16px; + margin-top: 16px; +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: -8px; + right: -8px; + bottom: -8px; + left: -24px; + pointer-events: none; + content: ""; + border: 2px solid var(--color-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--color-fg-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: 4px; +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list { + position: relative; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: var(--base-size-8) var(--base-size-16); + margin-bottom: 16px; + color: inherit; + border-left: .25em solid var(--color-border-default); +} + +.markdown-body .markdown-alert>:first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert>:last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: var(--color-accent-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--color-accent-fg); +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: var(--color-done-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--color-done-fg); +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: var(--color-attention-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--color-attention-fg); +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: var(--color-success-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--color-success-fg); +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: var(--color-danger-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--color-danger-fg); +} \ No newline at end of file diff --git a/src/Blog/wwwroot/posts/test-blog/index.md b/src/Blog/wwwroot/posts/test-blog/index.md new file mode 100644 index 0000000..f489dac --- /dev/null +++ b/src/Blog/wwwroot/posts/test-blog/index.md @@ -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. diff --git a/src/Blog/wwwroot/prism.css b/src/Blog/wwwroot/prism.css index 80c92ff..e805148 100644 --- a/src/Blog/wwwroot/prism.css +++ b/src/Blog/wwwroot/prism.css @@ -29,7 +29,7 @@ pre[class*=language-] { :not(pre)>code[class*=language-], pre[class*=language-] { - background: #2d2d2d + background: var(--secondary-black); } :not(pre)>code[class*=language-] { diff --git a/test/Blog.Tests/EndToEnd/PostTests.cs b/test/Blog.Tests/EndToEnd/PostTests.cs new file mode 100644 index 0000000..f872f81 --- /dev/null +++ b/test/Blog.Tests/EndToEnd/PostTests.cs @@ -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"); + } +} \ No newline at end of file diff --git a/test/Blog.Tests/EndToEnd/TestPosts/another-test-blog/index.md b/test/Blog.Tests/EndToEnd/TestPosts/another-test-blog/index.md index 307477d..ec854c4 100644 --- a/test/Blog.Tests/EndToEnd/TestPosts/another-test-blog/index.md +++ b/test/Blog.Tests/EndToEnd/TestPosts/another-test-blog/index.md @@ -1,5 +1,3 @@ -# Test Blog - ```json meta { "title": "Another Test Blog", diff --git a/test/Blog.Tests/EndToEnd/TestPosts/test-blog/index.md b/test/Blog.Tests/EndToEnd/TestPosts/test-blog/index.md index dfae732..374e805 100644 --- a/test/Blog.Tests/EndToEnd/TestPosts/test-blog/index.md +++ b/test/Blog.Tests/EndToEnd/TestPosts/test-blog/index.md @@ -1,5 +1,3 @@ -# Test Blog - ```json meta { "title": "Test Blog", diff --git a/test/Blog.Tests/Unit/FilePostServiceTests.cs b/test/Blog.Tests/Unit/FilePostServiceTests.cs index d54dade..cff7740 100644 --- a/test/Blog.Tests/Unit/FilePostServiceTests.cs +++ b/test/Blog.Tests/Unit/FilePostServiceTests.cs @@ -420,4 +420,197 @@ class FilePostServiceTests } ]); } + + [Test] + public async Task GetPostAsync_WhenCalledAndSlugDoesNotExist_ItShouldReturnNull() + { + _mockFileSystem + .Setup(x => x.Directory.Exists(It.IsAny())) + .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())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns("slug/INDEX.md"); + + _mockFileSystem + .Setup(x => x.File.Exists(It.IsAny())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.File.ReadAllTextAsync(It.IsAny(), 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())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns("slug/INDEX.md"); + + _mockFileSystem + .Setup(x => x.File.Exists(It.IsAny())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.File.ReadAllTextAsync(It.IsAny(), 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())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns("slug/INDEX.md"); + + _mockFileSystem + .Setup(x => x.File.Exists(It.IsAny())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.File.ReadAllTextAsync(It.IsAny(), 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())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns("slug/INDEX.md"); + + _mockFileSystem + .Setup(x => x.File.Exists(It.IsAny())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.File.ReadAllTextAsync(It.IsAny(), 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())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.Path.Combine(It.IsAny(), It.IsAny())) + .Returns("slug/INDEX.md"); + + _mockFileSystem + .Setup(x => x.File.Exists(It.IsAny())) + .Returns(true); + + _mockFileSystem + .Setup(x => x.File.ReadAllTextAsync(It.IsAny(), 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 = "

Post Title

\n

Post content

\n", + }); + } } \ No newline at end of file