35 Commits

Author SHA1 Message Date
Tommy Parnell
a74dec639a push filter 2023-10-11 15:38:53 -04:00
Tommy Parnell
f93aea2696 rm old versions 2023-08-10 07:29:16 -04:00
Tommy Parnell
63828b3003 Create an auto-deploy file 2023-08-09 11:09:27 -04:00
Tommy Parnell
1b372b3501 reduce log 2022-11-14 17:08:59 -05:00
Tommy Parnell
5edba65844 rm some script 2022-11-14 16:41:42 -05:00
Tommy Parnell
afcae26bc1 push js 2022-11-14 16:13:23 -05:00
Tommy Parnell
5a253f6ada missed a dockerfile 2022-11-09 11:47:09 -05:00
Tommy Parnell
e8c548ab20 set target 2022-11-09 11:46:17 -05:00
Tommy Parnell
0871e037d8 dotnet 7 2022-11-09 11:43:42 -05:00
Tommy Parnell
a5931f48c6 sitemap 2022-08-26 13:35:50 -04:00
Tommy Parnell
2cff53f5d3 case insensitive url on post 2022-08-26 13:28:41 -04:00
Tommy Parnell
e065871145 revert some things 2022-08-21 22:40:44 -04:00
Tommy Parnell
5cf4086872 Revert "add disable gtm to home"
This reverts commit d3638b10c0.
2022-08-18 19:30:41 -04:00
Tommy Parnell
d3638b10c0 add disable gtm to home 2022-08-18 19:23:06 -04:00
Tommy Parnell
bd790926b3 GTM again 💣 2022-08-11 15:29:54 -04:00
Tommy Parnell
2753099f72 plausable, no amp, no google analytics 2022-08-11 13:59:47 -04:00
Tommy Parnell
20dc7ad932 fix example 2022-07-23 17:29:31 -04:00
Tommy Parnell
3892cb578e commander 2022-07-08 22:07:01 -04:00
Tommy Parnell
c6ee8f8193 no vary by user agent 2022-06-28 17:06:14 -04:00
Tommy Parnell
6147b840f2 Merge branch 'etags' 2022-06-28 16:40:33 -04:00
Tommy Parnell
d032ffcf82 etags 2022-06-28 16:40:28 -04:00
Tommy Parnell
ac28c642f8 Revert "better etag filter"
This reverts commit e8e9a1caa7.
2022-06-25 12:43:27 -04:00
Tommy Parnell
078da9731b etags 2022-06-25 12:43:17 -04:00
Tommy Parnell
e8e9a1caa7 better etag filter 2022-06-25 10:26:41 -04:00
Tommy Parnell
cde154ee3b strong etag 2022-06-19 14:52:52 -04:00
Tommy Parnell
f97bc8d938 make etag a middleware before output cache 2022-06-19 14:29:01 -04:00
Tommy Parnell
72824b70a0 add hit/miss 2022-06-19 13:55:29 -04:00
Tommy Parnell
87f50e1324 Merge branch 'master' of github.com:TerribleDev/blog.terribledev.io.dotnet 2022-06-19 13:48:55 -04:00
Tommy Parnell
b316cc7e8e etag all pages 2022-06-19 13:48:48 -04:00
Tommy Parnell
cc34f198a8 Delete captain-definition 2022-06-15 18:13:15 -04:00
Tommy Parnell
c1687cccf5 Create an auto-deploy file 2022-06-15 18:04:21 -04:00
Tommy Parnell
e6d7240996 Unlink the containerApp tparnellblogcontainerapp from this repo 2022-06-15 18:01:44 -04:00
Tommy Parnell
6b9e0c8fe3 Create an auto-deploy file 2022-06-15 17:55:24 -04:00
Tommy Parnell
910a5fee16 Unlink the containerApp tparnellblogcontainerapp from this repo 2022-06-15 17:54:26 -04:00
Tommy Parnell
6aec3294dc Create an auto-deploy file 2022-06-15 12:23:37 -04:00
50 changed files with 666 additions and 339 deletions

View File

@@ -4,6 +4,6 @@
.gitignore
.vs
.vscode
*/bin
*/obj
**/bin
**/obj
**/.toolstarget

View File

@@ -0,0 +1,42 @@
name: Trigger auto deployment for blogcontainergroup
# When this action will be executed
on:
# Automatically trigger it when detected changes in repo
push:
branches:
[ master ]
paths:
- '**'
- '.github/workflows/blogcontainergroup-AutoDeployTrigger-ab8fcfc6-eced-47ac-8584-4f5a983b4ee2.yml'
# Allow mannually trigger
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout to the branch
uses: actions/checkout@v2
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.BLOGCONTAINERGROUP_AZURE_CREDENTIALS }}
- name: Build and push container image to registry
uses: azure/container-apps-deploy-action@v1
with:
appSourcePath: ${{ github.workspace }}
registryUrl: terribledevreg.azurecr.io
registryUsername: ${{ secrets.BLOGCONTAINERGROUP_REGISTRY_USERNAME }}
registryPassword: ${{ secrets.BLOGCONTAINERGROUP_REGISTRY_PASSWORD }}
containerAppName: blogcontainergroup
resourceGroup: ContainerGroup
imageToBuild: terribledevreg.azurecr.io/blogcontainergroup:${{ github.sha }}

View File

@@ -1,51 +0,0 @@
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
name: Build and deploy container app to Azure Web App - tparnellbloglinux
on:
push:
branches:
- master
workflow_dispatch:
jobs:
build:
runs-on: 'ubuntu-latest'
steps:
- uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to registry
uses: docker/login-action@v1
with:
registry: https://terribledevreg.azurecr.io/
username: ${{ secrets.AzureAppService_ContainerUsername_aec4619fe53744eab156fa2356a5e1e4 }}
password: ${{ secrets.AzureAppService_ContainerPassword_1a9d2b89f86245f982b0fbfc81951798 }}
- name: Build and push container image to registry
uses: docker/build-push-action@v2
with:
push: true
tags: terribledevreg.azurecr.io/${{ secrets.AzureAppService_ContainerUsername_aec4619fe53744eab156fa2356a5e1e4 }}/tparnellbloglinux-img:${{ github.sha }}
file: ./Dockerfile
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'production'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
steps:
- name: Deploy to Azure Web App
id: deploy-to-webapp
uses: azure/webapps-deploy@v2
with:
app-name: 'tparnellbloglinux'
slot-name: 'production'
publish-profile: ${{ secrets.AzureAppService_PublishProfile_110e4da4c4b44f4fbd30c6811b6cb64c }}
images: 'terribledevreg.azurecr.io/${{ secrets.AzureAppService_ContainerUsername_aec4619fe53744eab156fa2356a5e1e4 }}/tparnellbloglinux-img:${{ github.sha }}'

2
.vscode/launch.json vendored
View File

@@ -9,7 +9,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/TerribleDev.Blog.Web/bin/Debug/netcoreapp3.1/TerribleDev.Blog.Web.dll",
"program": "${workspaceFolder}/src/TerribleDev.Blog.Web/bin/Debug/net7.0/TerribleDev.Blog.Web.dll",
"args": [],
"cwd": "${workspaceFolder}/src/TerribleDev.Blog.Web",
"stopAtEntry": false,

View File

@@ -1,17 +1,26 @@
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
WORKDIR /source
# copy csproj and restore as distinct layers
COPY ./src/TerribleDev.Blog.Web/*.csproj .
RUN dotnet restore -r linux-musl-x64 /p:PublishReadyToRunComposite=true
# copy everything else and build app
COPY ./src/TerribleDev.Blog.Web/ .
RUN dotnet publish -c release -o /app -r linux-musl-x64 --self-contained true --no-restore /p:PublishTrimmed=true /p:PublishReadyToRunComposite=true /p:PublishSingleFile=true
RUN date +%s > /app/buildtime.txt
# final stage/image
FROM mcr.microsoft.com/dotnet/runtime-deps:7.0-alpine-amd64
WORKDIR /app
COPY --from=build /app ./
# Copy csproj and restore as distinct layers
COPY *.sln .
COPY . .
RUN dotnet restore
# See: https://github.com/dotnet/announcements/issues/20
# Uncomment to enable globalization APIs (or delete)
# ENV \
# DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
# LC_ALL=en_US.UTF-8 \
# LANG=en_US.UTF-8
# RUN apk add --no-cache icu-libs
# Copy everything else and build
COPY . .
RUN dotnet publish -c Release -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:6.0
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "TerribleDev.Blog.Web.dll"]
ENTRYPOINT ["./TerribleDev.Blog.Web"]

12
Dockerfile.old Normal file
View File

@@ -0,0 +1,12 @@
FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
WORKDIR /app
# Copy everything else and build
COPY /src/TerribleDev.Blog.Web .
RUN dotnet publish -c release -o /out -r linux-musl-x64 --self-contained true /p:PublishTrimmed=true /p:PublishReadyToRunComposite=true /p:PublishSingleFile=true
RUN date +%s > /out/buildtime.txt
# Build runtime image
FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-alpine-amd64
WORKDIR /app
COPY --from=build /app/out .
ENTRYPOINT ["./TerribleDev.Blog.Web"]

View File

@@ -1,4 +0,0 @@
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}

38
fly.toml Normal file
View File

@@ -0,0 +1,38 @@
# fly.toml file generated for dry-meadow-9911 on 2022-11-09T12:09:05-05:00
app = "dry-meadow-9911"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
[env]
[experimental]
allowed_public_ports = []
auto_rollback = true
[[services]]
http_checks = []
internal_port = 80
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
force_https = true
handlers = ["http"]
port = 80
# [[services.ports]]
# handlers = ["tls", "http"]
# port = 443
[[services.tcp_checks]]
grace_period = "2s"
interval = "3s"
restart_limit = 0
timeout = "2s"

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp6.0</TargetFramework>
<TargetFramework>netcoreapp7.0</TargetFramework>
<IsPackable>true</IsPackable>
<PackAsTool>true</PackAsTool>
<ToolCommandName>tempo</ToolCommandName>

View File

@@ -9,16 +9,19 @@ using System.IO;
using Microsoft.AspNetCore.Html;
using TerribleDev.Blog.Web.Filters;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.OutputCaching;
namespace TerribleDev.Blog.Web.Controllers
{
[Http2PushFilter]
public class HomeController : Controller
{
private readonly ILogger<HomeController> logger;
private readonly PostCache postCache;
public HomeController(PostCache postCache)
public HomeController(PostCache postCache, ILogger<HomeController> logger)
{
this.logger = logger;
this.postCache = postCache;
}
@@ -27,18 +30,26 @@ namespace TerribleDev.Blog.Web.Controllers
[Route("/index.html", Order = 2)]
[Route("/")]
[Route("/page/{pageNumber:required:int:min(1)}")]
[OutputCache(Duration = 31536000, VaryByParam = "pageNumber")]
[OutputCache(Duration = 31536000, VaryByRouteValueNames = new string[] { "pageNumber" })]
[ResponseCache(Duration = 900)]
public IActionResult Index(int pageNumber = 1)
{
if(!postCache.PostsByPage.TryGetValue(pageNumber, out var result))
this.logger.LogWarning("Viewing page", pageNumber);
if (!postCache.PostsByPage.TryGetValue(pageNumber, out var result))
{
return Redirect($"/404/?from=/page/{pageNumber}/");
}
return View(new HomeViewModel() { Posts = result, Page = pageNumber, HasNext = postCache.PostsByPage.ContainsKey(pageNumber + 1), HasPrevious = postCache.PostsByPage.ContainsKey(pageNumber - 1),
BlogLD = postCache.BlogLD,
SiteLD = postCache.SiteLD,
BlogLDString = postCache.BlogLDString, SiteLDString = postCache.SiteLDString });
return View(new HomeViewModel()
{
Posts = result,
Page = pageNumber,
HasNext = postCache.PostsByPage.ContainsKey(pageNumber + 1),
HasPrevious = postCache.PostsByPage.ContainsKey(pageNumber - 1),
BlogLD = postCache.BlogLD,
SiteLD = postCache.SiteLD,
BlogLDString = postCache.BlogLDString,
SiteLDString = postCache.SiteLDString
});
}
[Route("/theme/{postName?}")]
public IActionResult Theme(string postName)
@@ -60,29 +71,34 @@ namespace TerribleDev.Blog.Web.Controllers
}
[Route("{postUrl}/{amp?}")]
[OutputCache(Duration = 31536000, VaryByParam = "postUrl,amp")]
[OutputCache(Duration = 31536000, VaryByRouteValueNames = new string[] { "postUrl", "amp" })]
[ResponseCache(Duration = 900)]
public IActionResult Post(string postUrl, string amp = "")
{
if(!String.IsNullOrEmpty(amp) && amp != "amp")
if (!String.IsNullOrEmpty(amp) && amp != "amp")
{
return Redirect($"/404/?from=/{postUrl}/{amp}/");
}
var isAmp = amp == "amp";
this.ViewData["amp"] = isAmp;
if(postCache.UrlToPost.TryGetValue(postUrl, out var currentPost))
if (isAmp)
{
if(isAmp && !currentPost.isAmp)
{
return Redirect($"/{postUrl}/");
}
return View("Post", model: new PostViewModel() { Post = currentPost });
return this.RedirectPermanent($"/{postUrl}");
}
if(postCache.LandingPagesUrl.TryGetValue(postUrl, out var landingPage))
// case sensitive lookup
if (postCache.UrlToPost.TryGetValue(postUrl, out var currentPost))
{
return View("Post", model: new PostViewModel() { Post = landingPage });
return View("Post", model: new PostViewModel() { Post = currentPost });
}
// case insensitive lookup on post
if (postCache.CaseInsensitiveUrlToPost.TryGetValue(postUrl, out var caseInsensitivePost))
{
return View("Post", model: new PostViewModel() { Post = caseInsensitivePost });
}
if (postCache.LandingPagesUrl.TryGetValue(postUrl, out var landingPage))
{
return View("Post", model: new PostViewModel() { Post = landingPage });
}
this.StatusCode(404);
return View(nameof(FourOhFour));
}

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.SyndicationFeed;
using Microsoft.SyndicationFeed.Rss;
using TerribleDev.Blog.Web.Models;
@@ -66,7 +67,6 @@ namespace TerribleDev.Blog.Web.Controllers
};
sitemap.Urls.AddRange(postCache.TagsToPosts.Keys.Select(i => new SiteMapItem() { LastModified = DateTime.UtcNow, Location = $"https://blog.terrible.dev/search?q={i}" }));
sitemap.Urls.AddRange(sitewideLinks);
sitemap.Urls.AddRange(postCache.PostsAsLists.Where(i => i.isAmp).Select(a => new SiteMapItem() { LastModified = DateTime.UtcNow, Location = a.AMPUrl }).ToList());
ser.Serialize(this.Response.Body, sitemap);
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using TerribleDev.Blog.Web.Filters;
using TerribleDev.Blog.Web.Models;
@@ -24,7 +25,7 @@ namespace TerribleDev.Blog.Web.Controllers
return View(postCache.TagsToPosts);
}
[Route("/tags/{tagName}")]
[OutputCache(Duration = 31536000, VaryByParam = "tagName")]
[OutputCache(Duration = 31536000, VaryByRouteValueNames = new string[]{"tagName"})]
public IActionResult TagPluralRedirect(string tagName)
{
if(string.IsNullOrEmpty(tagName))
@@ -34,7 +35,7 @@ namespace TerribleDev.Blog.Web.Controllers
return Redirect($"/tag/{tagName}/");
}
[Route("/tag/{tagName}")]
[OutputCache(Duration = 31536000, VaryByParam = "tagName")]
[OutputCache(Duration = 31536000, VaryByRouteValueNames = new string[] {"tagName"})]
public IActionResult GetTag(string tagName)
{
if(!postCache.TagsToPosts.TryGetValue(tagName.ToLower(), out var models))

View File

@@ -1,5 +1,5 @@
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
WORKDIR /source
# copy csproj and restore as distinct layers
@@ -11,7 +11,7 @@ COPY . .
RUN dotnet publish -c release -o /app -r linux-musl-x64 --self-contained true --no-restore /p:PublishTrimmed=true /p:PublishReadyToRunComposite=true /p:PublishSingleFile=true
# final stage/image
FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-alpine-amd64
FROM mcr.microsoft.com/dotnet/runtime-deps:7.0-alpine-amd64
WORKDIR /app
COPY --from=build /app ./

View File

@@ -0,0 +1,12 @@
using System;
namespace TerribleDev.Blog.Web
{
public static class ArrayExtensions
{
public static string ToHexString(this byte[] bytes)
{
return Convert.ToHexString(bytes);
}
}
}

View File

@@ -18,6 +18,7 @@ namespace TerribleDev.Blog.Web.Factories
var orderedPosts = rawPosts.OrderByDescending(a => a.PublishDate);
var posts = new List<IPost>(orderedPosts);
var urlToPosts = new Dictionary<string, IPost>();
var caseInsensitiveUrlToPost = new Dictionary<string, IPost>(StringComparer.OrdinalIgnoreCase);
var tagsToPost = new Dictionary<string, IList<Post>>();
var postsByPage = new Dictionary<int, IList<Post>>();
var syndicationPosts = new List<SyndicationItem>();
@@ -30,6 +31,7 @@ namespace TerribleDev.Blog.Web.Factories
{
var castedPost = post as Post;
urlToPosts.Add(post.UrlWithoutPath, castedPost);
caseInsensitiveUrlToPost.Add(post.UrlWithoutPath.ToLower(), castedPost);
syndicationPosts.Add(castedPost.ToSyndicationItem());
blogPostsLD.Add(post.Content.JsonLD);
foreach (var tag in castedPost.ToNormalizedTagList())
@@ -122,8 +124,8 @@ namespace TerribleDev.Blog.Web.Factories
BlogLD = ld,
SiteLD = website,
BlogLDString = ld.ToHtmlEscapedString().Replace("https://schema.org", "https://schema.org/true"),
SiteLDString = website.ToHtmlEscapedString().Replace("https://schema.org", "https://schema.org/true")
SiteLDString = website.ToHtmlEscapedString().Replace("https://schema.org", "https://schema.org/true"),
CaseInsensitiveUrlToPost = caseInsensitiveUrlToPost
};
}
}

View File

@@ -15,6 +15,8 @@ using System.Collections.Concurrent;
using Schema.NET;
using System.Text.RegularExpressions;
using TerribleDev.Blog.Web.Factories;
using System.Text;
using System.Security.Cryptography;
namespace TerribleDev.Blog.Web
{
@@ -74,11 +76,10 @@ namespace TerribleDev.Blog.Web
var postSettings = ParseYaml(ymlRaw);
var resolvedUrl = !string.IsNullOrWhiteSpace(postSettings.permalink) ? postSettings.permalink : fileName.Split('.')[0].Replace(' ', '-').WithoutSpecialCharacters();
var canonicalUrl = $"https://blog.terrible.dev/{resolvedUrl}/";
var ampUrl = $"https://blog.terrible.dev/{resolvedUrl}/amp/";
return postSettings.isLanding ? await BuildLandingPage(fileName, domain, markdownText, postSettings, resolvedUrl, canonicalUrl, ampUrl) : await BuildPost(fileName, domain, markdownText, postSettings, resolvedUrl, canonicalUrl, ampUrl);
return postSettings.isLanding ? await BuildLandingPage(fileName, domain, markdownText, postSettings, resolvedUrl, canonicalUrl) : await BuildPost(fileName, domain, markdownText, postSettings, resolvedUrl, canonicalUrl);
}
private async Task<Post> BuildPost(string fileName, string domain, string markdownText, PostSettings postSettings, string resolvedUrl, string canonicalUrl, string ampUrl)
private async Task<Post> BuildPost(string fileName, string domain, string markdownText, PostSettings postSettings, string resolvedUrl, string canonicalUrl)
{
(string postContent, string postContentPlain, string summary, string postSummaryPlain, IList<string> postImages, bool hasCode) = await ResolveContentForPost(markdownText, fileName, resolvedUrl, domain);
@@ -112,7 +113,6 @@ namespace TerribleDev.Blog.Web
var postContentClean = Regex.Replace(postContent, "<picture.*?>|</picture>|<source.*?>|</source>", "", RegexOptions.Singleline);
var content = new PostContent()
{
AmpContent = new HtmlString(postContentClean),
Content = new HtmlString(postContent),
Images = postImages,
ContentPlain = postContentPlain,
@@ -123,7 +123,8 @@ namespace TerribleDev.Blog.Web
JsonLDString = ld.ToHtmlEscapedString().Replace("https://schema.org", "https://schema.org/true"),
JsonLDBreadcrumb = breadcrumb,
JsonLDBreadcrumbString = breadcrumb.ToHtmlEscapedString().Replace("https://schema.org", "https://schema.org/true"),
HasCode = hasCode
HasCode = hasCode,
MarkdownMD5 = MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(markdownText)).ToHexString()
};
var thumbNailUrl = string.IsNullOrWhiteSpace(postSettings.thumbnailImage) ?
postImages?.FirstOrDefault() ?? "https://www.gravatar.com/avatar/333e3cea32cd17ff2007d131df336061?s=640" :
@@ -136,15 +137,13 @@ namespace TerribleDev.Blog.Web
Title = postSettings.title,
RelativeUrl = $"/{resolvedUrl}/",
CanonicalUrl = canonicalUrl,
AMPUrl = ampUrl,
UrlWithoutPath = resolvedUrl,
isLanding = postSettings.isLanding,
Content = content,
isAmp = postSettings.isAmp,
ThumbnailImage = thumbNailUrl,
};
}
private async Task<LandingPage> BuildLandingPage(string fileName, string domain, string markdownText, PostSettings postSettings, string resolvedUrl, string canonicalUrl, string ampUrl)
private async Task<LandingPage> BuildLandingPage(string fileName, string domain, string markdownText, PostSettings postSettings, string resolvedUrl, string canonicalUrl)
{
(string postContent, string postContentPlain, string summary, string postSummaryPlain, IList<string> postImages, bool hasCode) = await ResolveContentForPost(markdownText, fileName, resolvedUrl, domain);
var breadcrumb = new Schema.NET.BreadcrumbList()
@@ -164,10 +163,8 @@ namespace TerribleDev.Blog.Web
},
};
// regex remove picture and source tags but not the child elements
var postContentClean = Regex.Replace(postContent, "<picture.*?>|</picture>|<source.*?>|</source>", "", RegexOptions.Singleline);
var content = new PostContent()
{
AmpContent = new HtmlString(postContentClean),
Content = new HtmlString(postContent),
Images = postImages,
ContentPlain = postContentPlain,
@@ -176,7 +173,8 @@ namespace TerribleDev.Blog.Web
SummaryPlainShort = (postContentPlain.Length <= 147 ? postContentPlain : postContentPlain.Substring(0, 146)) + "...",
JsonLDBreadcrumb = breadcrumb,
JsonLDBreadcrumbString = breadcrumb.ToHtmlEscapedString().Replace("https://schema.org", "https://schema.org/true"),
HasCode = hasCode
HasCode = hasCode,
MarkdownMD5 = MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(markdownText)).ToHexString()
};
return new LandingPage()
{
@@ -185,11 +183,9 @@ namespace TerribleDev.Blog.Web
Title = postSettings.title,
RelativeUrl = $"/{resolvedUrl}/",
CanonicalUrl = canonicalUrl,
AMPUrl = ampUrl,
UrlWithoutPath = resolvedUrl,
isLanding = postSettings.isLanding,
Content = content,
isAmp = postSettings.isAmp
};
}
}

View File

@@ -9,8 +9,13 @@ namespace TerribleDev.Blog.Web.Factories
public class CodeFactory
{
private HttpClient httpClient = new HttpClient();
private static Boolean IsDisabled = !String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DISABLE_PRISMA"));
public async Task<(string result, bool hasCode)> ReplaceFencedCode(string markdown)
{
if(CodeFactory.IsDisabled)
{
return (markdown, false);
}
// regex grab all text between backticks
var regex = new Regex(@"```(.*?)```", RegexOptions.Singleline);

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace TerribleDev.Blog.Web.Filters
{
public class StaticETag: ActionFilterAttribute
{
static StaticETag()
{
string etagString;
if(File.Exists("buildtime.txt"))
{
Console.WriteLine("buildtime.txt found");
etagString = File.ReadAllText("buildtime.txt");
}
else
{
Console.WriteLine("buildtime.txt not found");
Console.WriteLine("Directory list");
Console.WriteLine(Directory.GetFiles(".", "*", SearchOption.AllDirectories).Aggregate((a, b) => a + "\n" + b));
var unixTime = DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString();
Console.WriteLine("Using Unix Time for Etag: " + unixTime);
etagString = unixTime;
}
StaticETag.staticEtag = "\"" + MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(etagString)).ToHexString().Substring(0,8) + "\"";
}
public static string staticEtag;
public static ConcurrentDictionary<string, string> cache = new ConcurrentDictionary<string, string>();
public override void OnActionExecuted(ActionExecutedContext context)
{
if(context.HttpContext.Response.StatusCode >= 200 && context.HttpContext.Response.StatusCode < 300 && context.HttpContext.Response.Headers.ETag.Count == 0)
{
context.HttpContext.Response.Headers.Add("ETag", staticEtag);
}
}
}
}

View File

@@ -8,8 +8,13 @@ namespace TerribleDev.Blog.Web.Filters
{
public class Http2PushFilter : ActionFilterAttribute
{
public override void OnResultExecuted(ResultExecutedContext context)
private static bool IsHttp2PushDisabled = String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DISABLE_HTTP2_PUSH"));
public override void OnActionExecuted(ActionExecutedContext context)
{
if(IsHttp2PushDisabled)
{
return;
}
var logger = context.HttpContext.RequestServices.GetService(typeof(ILogger<Http2PushFilter>)) as ILogger<Http2PushFilter>;
logger.LogDebug("Http2PushFilter.OnActionExecuted");
if(!context.HttpContext.Items.TryGetValue(HttpPush.Key, out var links))
@@ -17,22 +22,23 @@ namespace TerribleDev.Blog.Web.Filters
logger.LogDebug("Did not find any links to push");
return;
}
var linkData = links as System.Collections.Generic.List<string>;
var linkData = links as System.Collections.Generic.List<PushUrl>;
if(linkData == null || linkData.Count == 0) {
logger.LogDebug("Http2PushFilter.OnActionExecuted: No links");
return;
}
var headerBuilder = new StringBuilder();
for(var i = 0; i < linkData.Count; i++) {
var url = linkData[i];
var (url, AsProperty) = linkData[i];
var resolvedUrl = url.StartsWith("~") ? context.HttpContext.Request.PathBase.ToString() + url.Substring(1) : url;
headerBuilder.Append($"<{resolvedUrl}>; rel=preload; as=style");
headerBuilder.Append($"<{resolvedUrl}>; rel=preload; as={AsProperty}");
if(i < linkData.Count - 1) {
headerBuilder.Append(", ");
}
}
logger.LogDebug("Http2PushFilter.OnActionExecuted: " + headerBuilder.ToString());
context.HttpContext.Response.Headers.Add("Link", headerBuilder.ToString());
base.OnActionExecuted(context);
}
}
}

View File

@@ -27,7 +27,10 @@ namespace TerribleDev.Blog.Web.MarkExtension
{
return false;
}
if(linkInline.Url.EndsWith(".gif"))
{
return false;
}
renderer.Write("<picture>");
WriteImageTag(renderer, linkInline, ".webp", "image/webp");

View File

@@ -9,7 +9,6 @@ namespace TerribleDev.Blog.Web.Models
{
public interface IPost
{
string AMPUrl { get; set; }
string CanonicalUrl { get; set; }
string UrlWithoutPath { get; set; }
string RelativeUrl { get; set; }
@@ -18,7 +17,6 @@ namespace TerribleDev.Blog.Web.Models
DateTime? UpdatedDate { get; set; }
IPostContent Content { get; set; }
bool isLanding { get; set; }
bool isAmp { get; set; }
string ThumbnailImage { get; }
}

View File

@@ -7,7 +7,6 @@ namespace TerribleDev.Blog.Web.Models
{
public interface IPostContent
{
public HtmlString AmpContent { get; set; }
HtmlString Content { get; set; }
HtmlString Summary { get; set; }
string ContentPlain { get; set; }
@@ -22,5 +21,6 @@ namespace TerribleDev.Blog.Web.Models
public string JsonLDString { get; set; }
BreadcrumbList JsonLDBreadcrumb { get; set; }
string JsonLDBreadcrumbString { get; set; }
string MarkdownMD5 { get; set; }
}
}

View File

@@ -9,7 +9,6 @@ namespace TerribleDev.Blog.Web.Models
[DebuggerDisplay("{Title}")]
public class LandingPage : IPost
{
public string AMPUrl { get; set; }
public string CanonicalUrl { get; set; }
public string UrlWithoutPath { get; set; }
public string RelativeUrl { get; set; }
@@ -19,7 +18,6 @@ namespace TerribleDev.Blog.Web.Models
public IPostContent Content { get; set; }
public bool isLanding { get; set; } = false;
public bool isAmp { get; set; } = true;
public string ThumbnailImage { get => "https://www.gravatar.com/avatar/333e3cea32cd17ff2007d131df336061?s=640"; }
}
}

View File

@@ -9,7 +9,6 @@ namespace TerribleDev.Blog.Web.Models
[DebuggerDisplay("{Title}")]
public class Post : IPost
{
public string AMPUrl { get; set; }
public string CanonicalUrl { get; set; }
public string UrlWithoutPath { get; set; }
public string RelativeUrl { get; set; }
@@ -20,7 +19,6 @@ namespace TerribleDev.Blog.Web.Models
public IPostContent Content { get; set; }
public bool isLanding { get; set; } = false;
public bool isAmp { get; set; } = true;
public string ThumbnailImage { get; set; }
}

View File

@@ -8,6 +8,7 @@ namespace TerribleDev.Blog.Web.Models
public IList<IPost> PostsAsLists { get; set;}
public IDictionary<string, IList<Post>> TagsToPosts { get; set; }
public IDictionary<string, IPost> UrlToPost { get; set; }
public IDictionary<string, IPost> CaseInsensitiveUrlToPost { get; set; }
public IDictionary<int, IList<Post>> PostsByPage { get; set; }
public IList<SyndicationItem> PostsAsSyndication { get; set; }

View File

@@ -8,7 +8,6 @@ namespace TerribleDev.Blog.Web.Models
public class PostContent : IPostContent
{
public HtmlString AmpContent { get; set; }
public HtmlString Content { get; set; }
public HtmlString Summary { get; set; }
public string ContentPlain { get; set; }
@@ -20,5 +19,6 @@ namespace TerribleDev.Blog.Web.Models
public BreadcrumbList JsonLDBreadcrumb { get; set; }
public string JsonLDBreadcrumbString { get; set; }
public bool HasCode { get; set; }
public string MarkdownMD5 { get; set; }
}
}

View File

@@ -18,6 +18,5 @@ namespace TerribleDev.Blog.Web.Models
public bool isLanding { get; set; } = false;
public bool isAmp { get; set; } = true;
}
}

View File

@@ -0,0 +1,219 @@
title: Building attractive CLIs in TypeScript
date: 2022-07-08 05:18
tags:
- javascript
- typescript
- node
- cli
- tutorials
---
So you've come to a point where you want to build nice CLIs. There's a few different options for building CLI's. My two favorites are [oclif](https://oclif.io/) and [commander.js](https://github.com/tj/commander.js/). I tend toward leaning to commander, unless I know I'm building a super big app. However, I've really enjoyed building smaller CLIs with commander recently.
<!-- more -->
> tl;dr? You can [view this repo](https://github.com/TerribleDev/example-ts-cli)
![a video of the CLI](cli.gif)
## Commander.js Lingo
So commander has a few different nouns.
* `Program` - The root of the CLI. Handles running the core app.
* `Command` - A command that can be run. These must be registered into `Program`
* `Option` - I would also call these `flags` they're the `--something` part of the CLI.
* `Arguments` - These are named positioned arguments. For example `npm install commander` the `commander` string in this case is an argument. `--save` would be an option.
## Initial Setup
First, do an npm init, and install commander, types for node, typescript, esbuild, and optionally ora.
```bash
npm init -y
npm install --save commander typescript @types/node ora
```
Next we have to configure a build command in the package.json. This one runs typescript to check for types and then esbuild to compile the app for node.
```json
"scripts": {
"build": "tsc --noEmit ./index.ts && esbuild index.ts --bundle --platform=node --format=cjs --outfile=dist/index.js",
}
```
We now need to add a bin property in the package.json. This tells the package manager that we have an executable. The key should be the name of your CLI
```json
"bin": {
"<yourclinamehere>": "./dist/index.js"
}
```
Make a file called index.ts, and place this string on the first line. This is called a shebang and it tells your shell to use node when the file is ran.
`#!/usr/bin/env node`
## Getting started
Hopefully you have done the above. Now in index.ts you can make a very basic program. Try npm build and then run the CLI with --help. Hopefully you'll get some output.
```ts
#!/usr/bin/env node
import { Command } from 'commander'
import { spinnerError, stopSpinner } from './spinner';
const program = new Command();
program.description('Our New CLI');
program.version('0.0.1');
async function main() {
await program.parseAsync();
}
console.log() // log a new line so there is a nice space
main();
```
### Setting up the spinner
So, I really like loading spinners. I think it gives the CLI a more polished feel. So I added a spinner using ora. I made a file called `spinner.ts` which is a wrapper to handle states of spinning or stopped.
```ts
import ora from 'ora';
const spinner = ora({ // make a singleton so we don't ever have 2 spinners
spinner: 'dots',
})
export const updateSpinnerText = (message: string) => {
if(spinner.isSpinning) {
spinner.text = message
return;
}
spinner.start(message)
}
export const stopSpinner = () => {
if(spinner.isSpinning) {
spinner.stop()
}
}
export const spinnerError = (message?: string) => {
if(spinner.isSpinning) {
spinner.fail(message)
}
}
export const spinnerSuccess = (message?: string) => {
if(spinner.isSpinning) {
spinner.succeed(message)
}
}
export const spinnerInfo = (message: string) => {
spinner.info(message)
}
```
### Writing a command
So I like to separate my commands out into sub-commands. In this case we're making `widgets` a sub-command. Make a new file, I call it widgets.ts. I create a new `Command` called `widgets`. Commands can have commands making them sub-commands. So we can make a sub-command called `list` and `get`. **List** will list all the widgets we have, and **get** will retrive a widget by id. I added some promise to emulate some delay so we can see the spinner in action.
```ts
import { Command } from "commander";
import { spinnerError, spinnerInfo, spinnerSuccess, updateSpinnerText } from "./spinner";
export const widgets = new Command("widgets");
widgets.command("list").action(async () => {
updateSpinnerText("Processing ");
// do work
await new Promise(resolve => setTimeout(resolve, 1000)); // emulate work
spinnerSuccess()
console.table([{ id: 1, name: "Tommy" }, { id: 2, name: "Bob" }]);
})
widgets.command("get")
.argument("<id>", "the id of the widget")
.option("-f, --format <format>", "the format of the widget") // an optional flag, this will be in options.f
.action(async (id, options) => {
updateSpinnerText("Getting widget " + id);
await new Promise(resolve => setTimeout(resolve, 3000));
spinnerSuccess()
console.table({ id: 1, name: "Tommy" })
})
```
Now lets register this command into our program. (see the last line)
```ts
#!/usr/bin/env node
import { Command } from 'commander'
import { spinnerError, stopSpinner } from './spinner';
import { widgets } from './widgets';
const program = new Command();
program.description('Our New CLI');
program.version('0.0.1');
program.addCommand(widgets);
```
Do a build! Hopefully you can type `<yourcli> widgets list` and you'll see the spinner. When you call `spinnerSuccess` without any parameters the previous spinner text will stop and become a green check. You can pass a message instead to print that to the console. You can also call `spinnerError` to make the spinner a red `x` and print the message.
### Handle unhandled errors
Back in index.ts we need to add a hook to capture unhandled errors. Add a verbose flag to the program so we can see more details about the error, but by default lets hide the errors.
```ts
const program = new Command('Our New CLI');
program.option('-v, --verbose', 'verbose logging');
```
Now we need to listen for the node unhandled promise rejection event and process it.
```ts
process.on('unhandledRejection', function (err: Error) { // listen for unhandled promise rejections
const debug = program.opts().verbose; // is the --verbose flag set?
if(debug) {
console.error(err.stack); // print the stack trace if we're in verbose mode
}
spinnerError() // show an error spinner
stopSpinner() // stop the spinner
program.error('', { exitCode: 1 }); // exit with error code 1
})
```
#### Testing our error handling
Lets make a widget action called `unhandled-error`. Do a build, and then run this action. You should see the error is swallowed. Now try again but use `<yourcli> --verbose widgets unhandled-error` and you should see the error stack trace.
```ts
widgets.command("unhandled-error").action(async () => {
updateSpinnerText("Processing an unhandled failure ");
await new Promise(resolve => setTimeout(resolve, 3000));
throw new Error("Unhandled error");
})
```
## Organizing the folders
Ok, so you have the basics all setup. Now, how do you organize the folders. I like to have the top level commands in their own directories. That way the folder structure emulates the CLI. This is an idea I saw in oclif.
```
- index.ts
- /commands/widgets/index.ts
- /commands/widgets/list.ts
- /commands/widgets/get.ts
```
## So why not OCLIF?
A few simple reasons. OCLIF's getting started template comes with an extremely opinionated typescript configuration. For large projects, I've found it to be incredible. However, for smaller-ish things, I've found conforming to it, a trial of turning down the linter a lot. Overall, they're both great tools. Why not both?

View File

@@ -1,6 +1,5 @@
title: Dynamically changing the site-theme meta tag
date: 2022-04-12 11:05
isAmp: false
thumbnailImage: 1.jpg
tags:
- javascript

View File

@@ -12,8 +12,9 @@ using HardHat;
using TerribleDev.Blog.Web.Models;
using TerribleDev.Blog.Web.Factories;
using Microsoft.Extensions.Hosting;
using WebMarkupMin.AspNetCore6;
using WebMarkupMin.AspNetCore7;
using Microsoft.Extensions.Logging;
using TerribleDev.Blog.Web.Filters;
namespace TerribleDev.Blog.Web
{
@@ -58,23 +59,32 @@ namespace TerribleDev.Blog.Web
}
return postCache;
});
var controllerBuilder = services.AddControllersWithViews();
var controllerBuilder = services.AddControllersWithViews(a => {
a.Filters.Add(new StaticETag());
});
#if DEBUG
if (Env.IsDevelopment())
{
controllerBuilder.AddRazorRuntimeCompilation();
}
#endif
services.AddResponseCompression(a =>
services
.AddResponseCompression(a =>
{
a.EnableForHttps = true;
})
.AddResponseCaching()
.AddMemoryCache();
if(Env.IsProduction())
{
services.AddOutputCaching();
}
// if(Env.IsProduction())
// {
// }
services.AddOutputCache(a =>{
a.AddBasePolicy(b => {
b.Cache();
});
});
services.AddWebMarkupMin(a => {
a.AllowMinificationInDevelopmentEnvironment = true;
a.DisablePoweredByHttpHeaders = true;
@@ -86,19 +96,16 @@ namespace TerribleDev.Blog.Web
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
}
Console.WriteLine("ETag Detected As: " + StaticETag.staticEtag);
app.UseHttpsRedirection();
if (env.IsProduction())
{
app.UseOutputCache();
app.UseResponseCaching();
}
app.UseResponseCompression();
var cacheTime = env.IsDevelopment() ? 0 : 31536000;
var cacheTime = env.IsDevelopment() ? 1 : 31536000;
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
@@ -116,6 +123,16 @@ namespace TerribleDev.Blog.Web
"public,max-age=" + cacheTime;
}
});
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseRewriter(new Microsoft.AspNetCore.Rewrite.RewriteOptions().AddRedirect("(.*[^/|.xml|.html])$", "$1/", 301));
app.UseIENoOpen();
app.UseNoMimeSniff();
@@ -138,10 +155,6 @@ namespace TerribleDev.Blog.Web
// },
UpgradeInsecureRequests = true
});
if(Env.IsProduction())
{
app.UseOutputCaching();
}
app.UseWebMarkupMin();
app.UseRouting();
app.UseEndpoints(endpoints =>
@@ -150,4 +163,4 @@ namespace TerribleDev.Blog.Web
});
}
}
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace TerribleDev.Blog.Web.Taghelpers
{
public abstract class AbstractPlatformTagHelper : TagHelper
{
static Regex MobileCheck = new Regex(@"(?:phone|windows\s+phone|ipod|blackberry|(?:android|bb\d+|meego|silk|googlebot) .+? mobile|palm|windows\s+ce|opera\ mini|avantgo|mobilesafari|docomo|ipad)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ECMAScript);
static ConcurrentDictionary<string, Platform> CachedChecks = new ConcurrentDictionary<string, Platform>(); // dictionary of user agent -> mobilre
protected HttpRequest Request => ViewContext.HttpContext.Request;
protected HttpResponse Response => ViewContext.HttpContext.Response;
[ViewContext]
public ViewContext ViewContext { get; set; }
protected abstract bool ShouldRender();
public Platform GetPlatform()
{
var userAgent = this.Request.Headers.UserAgent;
if (string.IsNullOrEmpty(userAgent))
{
return Platform.Desktop; // mobile is default
}
if(CachedChecks.TryGetValue(userAgent, out var cacheResult))
{
return cacheResult;
}
var isMobile = AbstractPlatformTagHelper.MobileCheck.IsMatch(this.Request.Headers.UserAgent);
return isMobile ? Platform.Mobile : Platform.Desktop;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = null;
if(!this.ShouldRender())
{
output.SuppressOutput();
return;
}
}
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace TerribleDev.Blog.Web.Taghelpers
{
[HtmlTargetElement("desktop", TagStructure = TagStructure.NormalOrSelfClosing)]
public class DesktopTagHelper : AbstractPlatformTagHelper
{
protected override bool ShouldRender() => this.GetPlatform() == Platform.Desktop;
}
}

View File

@@ -13,9 +13,13 @@ using Microsoft.AspNetCore.Razor.TagHelpers;
namespace TerribleDev.Blog.Web.Taghelpers
{
[HtmlTargetElement("link", Attributes = "rel, href, http-2-push")]
public record PushUrl(string Url, string asProperty);
[HtmlTargetElement("link", Attributes = "[rel=stylesheet],href,push")]
[HtmlTargetElement("img", Attributes = "src,push")]
[HtmlTargetElement("script", Attributes = "src,push")]
public class HttpPush : LinkTagHelper
{
[HtmlAttributeNotBound]
public bool Http2PushEnabled { get; set; } = true;
public static readonly string Key = "http2push-link";
@@ -24,23 +28,35 @@ namespace TerribleDev.Blog.Web.Taghelpers
{
}
private (string Url, string AsProperty) GetTagInfo(string tag) =>
tag switch
{
"link" => ("href", "link"),
"img" => ("src", "image"),
"script" => ("src", "script"),
_ => (null, null)
};
public override void Process(TagHelperContext context, TagHelperOutput output)
{
if(!this.Http2PushEnabled)
{
return;
}
var url = base.TryResolveUrl(output.Attributes["href"].Value.ToString(), out string resolvedUrl) ? resolvedUrl : output.Attributes["href"].Value.ToString();
var linkList = ViewContext.HttpContext.Items.TryGetValue(Key, out var links) ? links as List<string> : null;
var (urlAttribute, asProperty) = GetTagInfo(output.TagName);
var url = base.TryResolveUrl(output.Attributes[urlAttribute].Value.ToString(), out string resolvedUrl) ? resolvedUrl : output.Attributes[urlAttribute].Value.ToString();
var linkList = ViewContext.HttpContext.Items.TryGetValue(Key, out var links) ? links as List<PushUrl> : null;
if(linkList == null)
{
linkList = new List<string>() { url };
linkList = new List<PushUrl>() { new PushUrl(url, asProperty) };
ViewContext.HttpContext.Items.Add(HttpPush.Key, linkList);
}
else
{
linkList.Add(url);
linkList.Add(new PushUrl(url, asProperty));
}
output.Attributes.Remove(output.Attributes["push"]);
}
}
}

View File

@@ -8,34 +8,9 @@ using System.Threading.Tasks;
namespace TerribleDev.Blog.Web.Taghelpers
{
[HtmlTargetElement("desktopOnly", TagStructure = TagStructure.NormalOrSelfClosing)]
public class DesktopTagHelper : TagHelper
[HtmlTargetElement("mobile", TagStructure = TagStructure.NormalOrSelfClosing)]
public class MobileTagHelper : AbstractPlatformTagHelper
{
static Regex MobileCheck = new Regex(@"(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
static ConcurrentDictionary<string, bool> CachedChecks = new ConcurrentDictionary<string, bool>();
public string UserAgent { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = null;
if (string.IsNullOrEmpty(UserAgent))
{
return;
}
var shouldRender = true;
if(CachedChecks.TryGetValue(UserAgent, out var cacheResult))
{
shouldRender = cacheResult;
}
else
{
var isMobile = MobileCheck.IsMatch(UserAgent);
shouldRender = !isMobile;
CachedChecks.TryAdd(UserAgent, !isMobile);
}
if(!shouldRender)
{
output.SuppressOutput();
}
}
protected override bool ShouldRender() => this.GetPlatform() == Platform.Mobile;
}
}

View File

@@ -0,0 +1,8 @@
namespace TerribleDev.Blog.Web.Taghelpers
{
public enum Platform
{
Desktop,
Mobile,
}
}

View File

@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RuntimeIdentifiers>linux-musl-x64</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
@@ -22,12 +23,11 @@
<PackageReference Include="Markdig" Version="0.15.7" />
<PackageReference Include="Schema.NET" Version="11.0.1" />
<PackageReference Include="UriBuilder.Fluent" Version="1.5.2" />
<PackageReference Include="WebMarkupMin.AspNetCore6" Version="2.11.0" />
<PackageReference Include="WebMarkupMin.AspNetCore7" Version="2.13.0-rc1" />
<PackageReference Include="YamlDotNet" Version="5.3.0" />
<PackageReference Include="HardHat" Version="2.1.1" />
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
<PackageReference Include="WebEssentials.AspNetCore.OutputCaching" Version="1.0.16" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.1" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.0" Condition="'$(Configuration)' == 'Debug'" />
</ItemGroup>

View File

@@ -2,7 +2,6 @@
@model PostViewModel
@{
ViewData["Title"] = Model.Post.Title;
var amp = ViewData["amp"] as bool? ?? false;
}
<cache vary-by-route="postUrl,amp">
@@ -24,10 +23,7 @@
<meta name="twitter:site" content="@@TerribleDev">
<meta name="twitter:creator" content="@@TerribleDev">
<link rel="canonical" href="@Model.Post.CanonicalUrl" />
@if(Model.Post.isAmp)
{
<link rel="amphtml" href="@Model.Post.AMPUrl">
}
@if(!string.IsNullOrEmpty(Model.Post.ThumbnailImage))
{
<meta name="twitter:image" content="@(Model.Post.ThumbnailImage)">

View File

@@ -5,13 +5,6 @@
<article>
<h1 itemprop="headline" class="headline">@Model.Title</h1>
<time class="headlineSubtext" itemprop="datePublished" content="@Model.PublishDate.ToString()">@Model.PublishDate.ToString("D")</time>
@if(amp)
{
@Model.Content.AmpContent
}
else
{
@Model.Content.Content
}
@Model.Content.Content
</article>

View File

@@ -1,18 +1,9 @@
@model Post
@{
var amp = ViewData["amp"] as bool? ?? false;
}
<article itemprop="blogPost">
<h1 itemprop="headline" class="headline">@Model.Title</h1>
<time class="headlineSubtext" itemprop="datePublished" content="@Model.PublishDate.ToString()">@Model.PublishDate.ToString("D")</time>
@if(amp)
{
@Model.Content.AmpContent
}
else
{
@Model.Content.Content
}
@Model.Content.Content
@if (Model.tags.Count > 0)
{
<div>

View File

@@ -1,11 +1,4 @@
@{
Layout = null;
var amp = ViewData["amp"] as bool? ?? false;
}
@if(!amp)
{
<script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
@@ -18,20 +11,4 @@
document.body.appendChild(script);
}, 4000)
});
</script>
}
else
{
<amp-analytics type="gtag" data-credentials="include">
<script type="application/json">
{
"vars" : {
"gtag_id": "UA-48128396-1",
"config" : {
"UA-48128396-1": { "UA-48128396-1": "default" }
}
}
}
</script>
</amp-analytics>
}
</script>

View File

@@ -1,20 +1,10 @@
@{
var amp = ViewData["amp"] as bool?;
}
<nav class="navBar hide" id="navBar">
<div class="navContent">
@if(amp == true)
{
<img src="/content/tommyAvatar4.jpg" loading="lazy" alt="An image of TerribleDev" class="round navHero" />
}
else
{
<picture class="navHero">
<picture class="navHero">
<source srcset="/content/tommyAvatar4.jpg.webp" loading="lazy" type="image/webp" alt="An image of TerribleDev" class="round" />
<img src="/content/tommyAvatar4.jpg" loading="lazy" alt="An image of TerribleDev" class="round" />
</picture>
}
</picture>
<span>Tommy "Terrible Dev" Parnell</span>
<ul class="sidebarBtns">
<li><a href="/" class="link-unstyled">Home</a></li>
@@ -24,7 +14,7 @@
<li><a href="https://github.com/terribledev" rel="noopener" target="_blank" class="link-unstyled">Github</a></li>
<li><a href="https://twitter.com/terribledev" rel="noopener" target="_blank" class="link-unstyled">Twitter</a></li>
<li><a href="mailto:tommy@terribledev.io" class="link-unstyled">Email</a></li>
<li><span class="link-unstyled" id="closeNav">Close Navbar</span></li>
<li><span onclick="toggleNav()" class="link-unstyled" id="closeNav">Close Navbar</span></li>
</ul>
</div>
</nav>

View File

@@ -1,62 +1,48 @@
@inject BlogConfiguration config
@{
var amp = ViewData["amp"] as bool? ?? false;
var htmlTag = amp ? "amp" : "";
}
<!DOCTYPE html>
<html lang="en" @htmlTag>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="author" content="Tommy &quot;TerribleDev&quot; Parnell" />
<title>@ViewData["Title"] | @config.Title</title>
<environment names="Development">
<inline-style href="css/site.css,css/site.mobile.css,css/site.desktop.css"></inline-style>
@* <desktop>
<inline-style href="css/site.css,css/site.desktop.css"></inline-style>
</desktop>
<mobile>
<inline-style href="css/site.css,css/site.mobile.css"></inline-style>
</mobile> *@
</environment>
<environment names="Production">
@* <desktop>
<inline-style href="css/site.min.css,css/site.desktop.min.css"></inline-style>
</desktop>
<mobile>
<inline-style href="css/site.min.css,css/site.mobile.min.css"></inline-style>
</mobile> *@
<inline-style href="css/site.min.css,css/site.mobile.min.css,css/site.desktop.min.css"></inline-style>
</environment>
<environment names="Production">
<partial name="Gtm" />
</environment>
<meta name="theme-color" content="#4A4A4A" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="alternate" type="application/atom+xml" title="RSS" href="/rss.xml">
@if(!amp)
{
<environment names="Production">
<partial name="Gtm" />
</environment>
}
<link rel="manifest" href="~/manifest.json" asp-append-version="true">
<link asp-append-version="true" rel="icon" href="~/favicon.ico" />
<title>@ViewData["Title"] | @config.Title</title>
@if(amp)
{
<inline-style amp-custom href="css/site.css,css/site.mobile.css,css/site.desktop.css"></inline-style>
}
else
{
<environment names="Development">
<inline-style href="css/site.css,css/site.mobile.css,css/site.desktop.css"></inline-style>
</environment>
<environment names="Production">
<inline-style href="css/site.min.css,css/site.mobile.min.css,css/site.desktop.min.css"></inline-style>
</environment>
}
@if(amp)
{
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<script async src="https://cdn.ampproject.org/v0.js"></script>
<script async custom-element="amp-analytics" src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script>
}
<meta name="author" content="Tommy &quot;TerribleDev&quot; Parnell" />
<link asp-append-version="true" rel="icon" href="~/favicon.ico" push />
@RenderSection("Head", false)
</head>
<body>
<a class="skip-main" href="#main">Skip to main content</a>
<div class="rootbox">
<header class="header">
@if(amp)
{
<a class="btn" id="menuBtn" href="/"> back to home </a>
}
else
{
<svg aria-label="Open Menu" id="menuBtn" role="button" xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="M4 10h24c1.104 0 2-.896 2-2s-.896-2-2-2H4c-1.104 0-2 .896-2 2s.896 2 2 2zm24 4H4c-1.104 0-2 .896-2 2s.896 2 2 2h24c1.104 0 2-.896 2-2s-.896-2-2-2zm0 8H4c-1.104 0-2 .896-2 2s.896 2 2 2h24c1.104 0 2-.896 2-2s-.896-2-2-2z" /></svg>
}
<svg aria-label="Open Menu" onclick="toggleNav()" id="menuBtn" role="button" xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="M4 10h24c1.104 0 2-.896 2-2s-.896-2-2-2H4c-1.104 0-2 .896-2 2s.896 2 2 2zm24 4H4c-1.104 0-2 .896-2 2s.896 2 2 2h24c1.104 0 2-.896 2-2s-.896-2-2-2zm0 8H4c-1.104 0-2 .896-2 2s.896 2 2 2h24c1.104 0 2-.896 2-2s-.896-2-2-2z"/></svg>
<div class="headerCallout"><a href="/" class="link-unstyled ">@config.Title</a></div>
</header>
<partial name="Nav" />
@@ -65,21 +51,12 @@
</main>
</div>
</div>
@if(!amp)
{
@RenderSection("Scripts", required: false)
<environment names="Development">
<script asp-append-version="true" src="~/js/swi.js" async></script>
<script push asp-append-version="true" src="~/js/swi.js" async></script>
</environment>
<environment names="Production">
<script asp-append-version="true" src="~/js/site.min.js" async></script>
<script push asp-append-version="true" src="~/js/site.min.js" async></script>
</environment>
}
else
{
<environment names="Production">
<partial name="Gtm" />
</environment>
}
</body>
</html>

View File

@@ -1,9 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Warning"
}
}
}

View File

@@ -0,0 +1,13 @@
@media (prefers-color-scheme: dark) {
:root {
--headline: #f0f0f0;
--body-text-color: #ffffff;
--block-quote-left-border: #d1dced;
--code-block-background-color: #4a4a4a;
--primary-background: #323131;
--link-color: #3faff9;
/* --link-visited: #d8dbde; */
--border-color: #bdcad2;
--horizontal-rule: #626468;
}
}

View File

@@ -13,20 +13,6 @@
--nav-bar-text-color: var(--primary-background);
}
@media (prefers-color-scheme: dark) {
:root {
--headline: #f0f0f0;
--body-text-color: #ffffff;
--block-quote-left-border: #d1dced;
--code-block-background-color: #4a4a4a;
--primary-background: #323131;
--link-color: #3faff9;
/* --link-visited: #d8dbde; */
--border-color: #bdcad2;
--horizontal-rule: #626468;
}
}
html {
font-family: Arial, Helvetica, sans-serif;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

View File

@@ -3,7 +3,9 @@
//Install stage sets up the offline page in the cache and opens a new cache
self.addEventListener('install', function (event) {
event.waitUntil(preLoad());
setTimeout(function() {
event.waitUntil(preLoad());
}, 5000);
});
var preLoad = function () {

View File

@@ -1,17 +1,16 @@
//Add this below content to your HTML page, or add the js file to your page at the very top to register sercie worker
if (navigator && navigator.serviceWorker && navigator.serviceWorker.controller) {
console.log('[PWA Builder] active service worker found, no need to register')
} else if (navigator && navigator.serviceWorker) {
//Register the ServiceWorker
navigator.serviceWorker.register('/sw.min.js', {
scope: '/'
}).then(function (reg) {
console.log('Service worker has been registered for scope:' + reg.scope);
}).then(() => {
console.log('SW');
});
}
var toggleNav = function () {
function toggleNav () {
var nav = document.getElementById('navBar');
if (!nav) {
return;
@@ -23,15 +22,4 @@ var toggleNav = function () {
else {
nav.classList.add('hide');
}
}
function attachNavToggle(elementId) {
var menuButton = document.getElementById(elementId);
if (menuButton) {
menuButton.addEventListener('click', function () {
toggleNav();
});
}
}
attachNavToggle('menuBtn');
attachNavToggle('closeNav');
}

View File

@@ -1,2 +1,3 @@
User-agent: *
Allow: /
Allow: /
Sitemap: https://blog.terrible.dev/sitemap.xml