28 Commits

Author SHA1 Message Date
Tommy Parnell
b776c14feb oof 2022-08-21 22:28:10 -04:00
Tommy Parnell
26780cf00f revert svg opt 2022-08-21 22:25:42 -04:00
Tommy Parnell
d85c0c8c99 shrink html 2022-08-21 22:10:26 -04:00
Tommy Parnell
75e329b3df save some cshtml changes 2022-08-20 10:09:37 -04:00
Tommy Parnell
c252cd2d4b checkbox hamburger (#14)
Convert hamburger menu to checkbox
2022-08-20 09:40:08 -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
45 changed files with 634 additions and 271 deletions

View File

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

View File

@@ -0,0 +1,60 @@
name: Trigger auto deployment for tparnellblogk8s
# When this action will be executed
on:
# Automatically trigger it when detected changes in repo
push:
branches:
[ master ]
paths:
- '**'
- '.github/workflows/tparnellblogk8s-AutoDeployTrigger-63a78573-0450-4fa7-92eb-43918b0e6169.yml'
# Allow mannually trigger
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout to the branch
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to container registry
uses: docker/login-action@v1
with:
registry: terribledevreg.azurecr.io
username: ${{ secrets.TPARNELLBLOGK8S_REGISTRY_USERNAME }}
password: ${{ secrets.TPARNELLBLOGK8S_REGISTRY_PASSWORD }}
- name: Build and push container image to registry
uses: docker/build-push-action@v2
with:
push: true
tags: terribledevreg.azurecr.io/tparnellblogk8s:${{ github.sha }}
file: ./Dockerfile
context: ./
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.TPARNELLBLOGK8S_AZURE_CREDENTIALS }}
- name: Deploy to containerapp
uses: azure/CLI@v1
with:
inlineScript: |
az config set extension.use_dynamic_install=yes_without_prompt
az containerapp registry set -n tparnellblogk8s -g containerapp --server terribledevreg.azurecr.io --username ${{ secrets.TPARNELLBLOGK8S_REGISTRY_USERNAME }} --password ${{ secrets.TPARNELLBLOGK8S_REGISTRY_PASSWORD }}
az containerapp update -n tparnellblogk8s -g containerapp --image terribledevreg.azurecr.io/tparnellblogk8s:${{ github.sha }}

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
.DS_Store
# User-specific files
*.suo
*.user

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:6.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:6.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:6.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"
}

39
fly.toml Normal file
View File

@@ -0,0 +1,39 @@
# fly.toml file generated for tparnellblog on 2022-08-20T09:53:58-04:00
app = "tparnellblog"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
[env]
[experimental]
allowed_public_ports = []
auto_rollback = true
private_network = true
[[services]]
http_checks = []
internal_port = 80
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 200
soft_limit = 100
type = "connections"
[[services.ports]]
force_https = true
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"

View File

@@ -69,13 +69,12 @@ namespace TerribleDev.Blog.Web.Controllers
return Redirect($"/404/?from=/{postUrl}/{amp}/");
}
var isAmp = amp == "amp";
this.ViewData["amp"] = isAmp;
if(isAmp)
{
return this.RedirectPermanent($"/{postUrl}");
}
if(postCache.UrlToPost.TryGetValue(postUrl, out var currentPost))
{
if(isAmp && !currentPost.isAmp)
{
return Redirect($"/{postUrl}/");
}
return View("Post", model: new PostViewModel() { Post = currentPost });
}
if(postCache.LandingPagesUrl.TryGetValue(postUrl, out var landingPage))

View File

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

@@ -9,7 +9,7 @@ RUN dotnet restore -r linux-musl-x64 /p:PublishReadyToRunComposite=true
# copy everything else and build app
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
RUN date +%s > /app/buildtime.txt
# final stage/image
FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-alpine-amd64
WORKDIR /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

@@ -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);
@@ -22,6 +27,7 @@ namespace TerribleDev.Blog.Web.Factories
if(!codeContent.IsSuccessStatusCode)
{
Console.Error.WriteLine("Error posting code to prisma");
Console.Error.WriteLine("status code: " + codeContent.StatusCode);
}
return (code, await codeContent.Content.ReadAsStringAsync());
}));

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

@@ -17,16 +17,16 @@ 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(", ");
}

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

@@ -14,6 +14,7 @@ using TerribleDev.Blog.Web.Factories;
using Microsoft.Extensions.Hosting;
using WebMarkupMin.AspNetCore6;
using Microsoft.Extensions.Logging;
using TerribleDev.Blog.Web.Filters;
namespace TerribleDev.Blog.Web
{
@@ -58,7 +59,9 @@ 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())
{
@@ -86,6 +89,7 @@ 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)
{
Console.WriteLine("ETag Detected As: " + StaticETag.staticEtag);
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
@@ -138,6 +142,16 @@ namespace TerribleDev.Blog.Web
// },
UpgradeInsecureRequests = true
});
app.Use(async (context, next) => {
var etag = context.Request.Headers.IfNoneMatch.ToString();
if(etag != null && string.Equals(etag, StaticETag.staticEtag, StringComparison.Ordinal))
{
context.Response.StatusCode = 304;
await context.Response.CompleteAsync();
return;
}
await next();
});
if(Env.IsProduction())
{
app.UseOutputCaching();

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,12 @@ 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")]
public class HttpPush : LinkTagHelper
{
[HtmlAttributeNotBound]
public bool Http2PushEnabled { get; set; } = true;
public static readonly string Key = "http2push-link";
@@ -24,23 +27,34 @@ namespace TerribleDev.Blog.Web.Taghelpers
{
}
private (string Url, string AsProperty) GetTagInfo(string tag) =>
tag switch {
"link" => ("href", "link"),
"img" => ("src", "image"),
_ => (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 urlAttribute = context.TagName == "link" ? "href" : "src";
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.ECMAScript | 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

@@ -5,7 +5,6 @@
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Remove="compilerconfig.json" />
<Content Remove="bundleconfig.json" />

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,26 +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">
<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>
@* <svg class="navHero round" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" preserveAspectRatio="none"><filter id="a" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="20 20" edgeMode="duplicate"/><feComponentTransfer><feFuncA type="discrete" tableValues="1 1"/></feComponentTransfer></filter><image filter="url(#a)" x="0" y="0" height="100%" width="100%" xlink:href="data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAoACgDASIAAhEBAxEB/8QAGgAAAgMBAQAAAAAAAAAAAAAAAAYDBQcECP/EAC4QAAEDAwMBBgYDAQAAAAAAAAECAwQABREGEiFBIjEyUWGBBxMUFSNSQpGxwf/EABkBAAIDAQAAAAAAAAAAAAAAAAMEAAECBf/EAB0RAAMBAAIDAQAAAAAAAAAAAAABAhEDIQQSMWH/2gAMAwEAAhEDEQA/AN2QBjmsg+LfxaTY5siw6fKTcW+w/IJH4lfqkdVDqelawl2vO7ulbfdfijqx69QkyGmpRLe8nB3c54PPFZrovjn3eCLD1XqduemZDvUxD+48/UKUCc9QSQR7V6K0Dqy7XNMWJqeG2zKks/OjSWcbHwBlSSP4rA5x3EZ8qprDpfQCHmowtEV98eEFS1DI7x4sE+h5q+n3S33J21/ZglKYE1oFKE7QlPgIHscVnQ9cOJtjavHlRUalZHrRRMFCIuYzSNe4ioN/dnpR2JOAcDgkf95ptW92SRnApP1UiRJfYkt5/FlAT+oOOT7gf3UqG1oz42qvw6JsuHGXCTEiBpYcDikhQST6gGr2K+1IKQiIhKN4WHUjG7qPfOKzdrU13iXdyPMtT70cAfKU23vJPpinrTEydcHZq5zS2nUqSEMHvbRj/T30KG6roZ5nkvoZEqymiokK49aKMc05ZDTnyFBK8qIyOlVhmJjodV9smPkcEJCBkem4iiinWPyWtslWcpQFLQlZ5SHOypPmOeoqKKWLbKmyBvdU+reEI8RAGAAPOiihRCVai7WPDqjO/Wx0vBt1oqydrqdqx6EUUUUVxL7Yu5W/D//Z"/></svg> *@
@* <div class="navHero"></div> *@
@* <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>

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" 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,8 +51,6 @@
</main>
</div>
</div>
@if(!amp)
{
@RenderSection("Scripts", required: false)
<environment names="Development">
<script asp-append-version="true" src="~/js/swi.js" async></script>
@@ -74,12 +58,5 @@
<environment names="Production">
<script 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

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="210" height="210" viewBox="0 0 200 200" preserveAspectRatio="none"><filter id="a" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="20 20" edgeMode="duplicate"/><feComponentTransfer><feFuncA type="discrete" tableValues="1 1"/></feComponentTransfer></filter><image filter="url(#a)" x="0" y="0" height="100%" width="100%" xlink:href="data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAoACgDASIAAhEBAxEB/8QAGgAAAgMBAQAAAAAAAAAAAAAAAAYDBQcECP/EAC4QAAEDAwMBBgYDAQAAAAAAAAECAwQABREGEiFBIjEyUWGBBxMUFSNSQpGxwf/EABkBAAIDAQAAAAAAAAAAAAAAAAMEAAECBf/EAB0RAAMBAAIDAQAAAAAAAAAAAAABAhEDIQQSMWH/2gAMAwEAAhEDEQA/AN2QBjmsg+LfxaTY5siw6fKTcW+w/IJH4lfqkdVDqelawl2vO7ulbfdfijqx69QkyGmpRLe8nB3c54PPFZrovjn3eCLD1XqduemZDvUxD+48/UKUCc9QSQR7V6K0Dqy7XNMWJqeG2zKks/OjSWcbHwBlSSP4rA5x3EZ8qprDpfQCHmowtEV98eEFS1DI7x4sE+h5q+n3S33J21/ZglKYE1oFKE7QlPgIHscVnQ9cOJtjavHlRUalZHrRRMFCIuYzSNe4ioN/dnpR2JOAcDgkf95ptW92SRnApP1UiRJfYkt5/FlAT+oOOT7gf3UqG1oz42qvw6JsuHGXCTEiBpYcDikhQST6gGr2K+1IKQiIhKN4WHUjG7qPfOKzdrU13iXdyPMtT70cAfKU23vJPpinrTEydcHZq5zS2nUqSEMHvbRj/T30KG6roZ5nkvoZEqymiokK49aKMc05ZDTnyFBK8qIyOlVhmJjodV9smPkcEJCBkem4iiinWPyWtslWcpQFLQlZ5SHOypPmOeoqKKWLbKmyBvdU+reEI8RAGAAPOiihRCVai7WPDqjO/Wx0vBt1oqydrqdqx6EUUUUVxL7Yu5W/D//Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,14 @@
@media (prefers-color-scheme: dark) {
:root {
--hln: #f0f0f0;
--bdy-txt-clr: #ffffff;
--blk-qt-lb: #d1dced;
--code-blk-bg-clr: #4a4a4a;
--pmry-bknd: #323131;
--lnk-clr: #3faff9;
/* --lnk-vistd: #d8dbde; */
--bc: #bdcad2;
--hr: #626468;
}
}

View File

@@ -1,31 +1,18 @@
:root {
--headline: #4a4a4a;
--body-text-color: #5d686f;
--block-quote-left-border: #d1dced;
--code-block-background-color: #f5f5f5;
--primary-background: #FFFFFF;
--link-color: #00558d;
--link-visited: var(--link-color);
/* --link-visited: #6c6c6c; */
--border-color: #738691;
--horizontal-rule: #dfe2e7;
--nav-bar-background: var(--headline);
--nav-bar-text-color: var(--primary-background);
--hln: #4a4a4a;
--bdy-txt-clr: #5d686f;
--blk-qt-lb: #d1dced;
--code-blk-bg-clr: #f5f5f5;
--pmry-bknd: #FFFFFF;
--lnk-clr: #00558d;
--lnk-vistd: var(--lnk-clr);
/* --lnk-vistd: #6c6c6c; */
--bc: #738691;
--hr: #dfe2e7;
--nb-bkgd: var(--hln);
--nb-txt-color: var(--pmry-bknd);
}
@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;
@@ -37,7 +24,7 @@ h3,
h4,
h5,
h6 {
color: var(--headline);
color: var(--hln);
line-height: 1.45;
letter-spacing: -0.01em;
line-height: 1.25em;
@@ -51,8 +38,8 @@ body {
text-rendering: optimizeLegibility;
letter-spacing: -0.01em;
line-height: 1.9rem;
background-color: var(--primary-background);
color: var(--body-text-color);
background-color: var(--pmry-bknd);
color: var(--bdy-txt-clr);
font-size: 1.125rem;
margin: 0;
}
@@ -87,7 +74,7 @@ body {
}
blockquote {
border-left: 2px solid var(--block-quote-left-border);
border-left: 2px solid var(--blk-qt-lb);
padding: 0.4em 1.2em;
}
@@ -97,7 +84,7 @@ pre {
font-family: "Courier New", Courier, monospace;
font-weight: 600;
border-radius: 3px;
background: var(--code-block-background-color);
background: var(--code-blk-bg-clr);
padding: 0 0.4em;
overflow-x: scroll;
letter-spacing: .02em;
@@ -109,16 +96,16 @@ pre > code {
}
a {
color: var(--link-color);
color: var(--lnk-clr);
font-weight: 400;
}
a:visited {
color: var(--link-visited);
color: var(--lnk-vistd);
}
.btmRule {
border-bottom: 1px solid var(--horizontal-rule);
border-bottom: 1px solid var(--hr);
padding-bottom: 3rem;
}
@@ -126,8 +113,8 @@ a:visited {
display: flex;
flex-direction: column;
align-items: center;
background: var(--nav-bar-background);
color: var(--nav-bar-text-color);
background: var(--nb-bkgd);
color: var(--nb-txt-color);
padding-top: 20px;
height: 100vh;
z-index: 40;
@@ -141,9 +128,9 @@ a:visited {
.header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--horizontal-rule);
color: var(--headline);
background-color: var(--primary-background);
border-bottom: 1px solid var(--hr);
color: var(--hln);
background-color: var(--pmry-bknd);
z-index: 20;
padding: 0;
margin: 0;
@@ -186,21 +173,21 @@ a:visited {
.btn {
width: auto;
height: auto;
background: var(--primary-background);
background: var(--pmry-bknd);
border-radius: 3px;
margin: 0;
cursor: pointer;
color: var(--body-text-color);
border: 1px solid var(--body-text-color);
color: var(--bdy-txt-clr);
border: 1px solid var(--bdy-txt-clr);
padding: 0.3em 0.2em;
text-decoration: none;
font-size: 1.1rem;
text-transform: uppercase;
}
.btn:visited {
background: var(--primary-background);
color: var(--border-color);
border: 1px solid var(--border-color);
background: var(--pmry-bknd);
color: var(--bc);
border: 1px solid var(--bc);
}
.btn.block {

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

@@ -35,14 +35,3 @@ function attachNavToggle(elementId) {
attachNavToggle('menuBtn');
attachNavToggle('closeNav');
// setTimeout(function() {
// console.log('trigger')
// const nav = document.querySelector('.navHero');
// nav.outerHTML = `<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>`
// }, 3000)
// const hero = document.querySelector('.navHero');
// hero.outerHTML = `<svg class="navHero round" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" preserveAspectRatio="none"><filter id="a" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="20 20" edgeMode="duplicate"/><feComponentTransfer><feFuncA type="discrete" tableValues="1 1"/></feComponentTransfer></filter><image filter="url(#a)" x="0" y="0" height="100%" width="100%" xlink:href="data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAoACgDASIAAhEBAxEB/8QAGgAAAgMBAQAAAAAAAAAAAAAAAAYDBQcECP/EAC4QAAEDAwMBBgYDAQAAAAAAAAECAwQABREGEiFBIjEyUWGBBxMUFSNSQpGxwf/EABkBAAIDAQAAAAAAAAAAAAAAAAMEAAECBf/EAB0RAAMBAAIDAQAAAAAAAAAAAAABAhEDIQQSMWH/2gAMAwEAAhEDEQA/AN2QBjmsg+LfxaTY5siw6fKTcW+w/IJH4lfqkdVDqelawl2vO7ulbfdfijqx69QkyGmpRLe8nB3c54PPFZrovjn3eCLD1XqduemZDvUxD+48/UKUCc9QSQR7V6K0Dqy7XNMWJqeG2zKks/OjSWcbHwBlSSP4rA5x3EZ8qprDpfQCHmowtEV98eEFS1DI7x4sE+h5q+n3S33J21/ZglKYE1oFKE7QlPgIHscVnQ9cOJtjavHlRUalZHrRRMFCIuYzSNe4ioN/dnpR2JOAcDgkf95ptW92SRnApP1UiRJfYkt5/FlAT+oOOT7gf3UqG1oz42qvw6JsuHGXCTEiBpYcDikhQST6gGr2K+1IKQiIhKN4WHUjG7qPfOKzdrU13iXdyPMtT70cAfKU23vJPpinrTEydcHZq5zS2nUqSEMHvbRj/T30KG6roZ5nkvoZEqymiokK49aKMc05ZDTnyFBK8qIyOlVhmJjodV9smPkcEJCBkem4iiinWPyWtslWcpQFLQlZ5SHOypPmOeoqKKWLbKmyBvdU+reEI8RAGAAPOiihRCVai7WPDqjO/Wx0vBt1oqydrqdqx6EUUUUVxL7Yu5W/D//Z"/></svg>`