Compare commits
1 Commits
backup-nav
...
blurinput
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef4fed4f5 |
@@ -4,6 +4,6 @@
|
||||
.gitignore
|
||||
.vs
|
||||
.vscode
|
||||
**/bin
|
||||
**/obj
|
||||
*/bin
|
||||
*/obj
|
||||
**/.toolstarget
|
||||
@@ -1,60 +0,0 @@
|
||||
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
2
.gitignore
vendored
@@ -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
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@@ -1,26 +1,17 @@
|
||||
# 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
|
||||
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env
|
||||
WORKDIR /app
|
||||
COPY --from=build /app ./
|
||||
|
||||
# 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 csproj and restore as distinct layers
|
||||
COPY *.sln .
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
|
||||
ENTRYPOINT ["./TerribleDev.Blog.Web"]
|
||||
# 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"]
|
||||
@@ -1,12 +0,0 @@
|
||||
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"]
|
||||
4
captain-definition
Normal file
4
captain-definition
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"dockerfilePath": "./Dockerfile"
|
||||
}
|
||||
39
fly.toml
39
fly.toml
@@ -1,39 +0,0 @@
|
||||
# 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"
|
||||
@@ -69,12 +69,13 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
return Redirect($"/404/?from=/{postUrl}/{amp}/");
|
||||
}
|
||||
var isAmp = amp == "amp";
|
||||
if(isAmp)
|
||||
{
|
||||
return this.RedirectPermanent($"/{postUrl}");
|
||||
}
|
||||
this.ViewData["amp"] = isAmp;
|
||||
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))
|
||||
|
||||
@@ -66,6 +66,7 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public static class ArrayExtensions
|
||||
{
|
||||
public static string ToHexString(this byte[] bytes)
|
||||
{
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ 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
|
||||
{
|
||||
@@ -76,10 +74,11 @@ 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}/";
|
||||
return postSettings.isLanding ? await BuildLandingPage(fileName, domain, markdownText, postSettings, resolvedUrl, canonicalUrl) : await BuildPost(fileName, domain, markdownText, postSettings, resolvedUrl, canonicalUrl);
|
||||
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);
|
||||
}
|
||||
|
||||
private async Task<Post> BuildPost(string fileName, string domain, string markdownText, PostSettings postSettings, string resolvedUrl, string canonicalUrl)
|
||||
private async Task<Post> BuildPost(string fileName, string domain, string markdownText, PostSettings postSettings, string resolvedUrl, string canonicalUrl, string ampUrl)
|
||||
{
|
||||
|
||||
(string postContent, string postContentPlain, string summary, string postSummaryPlain, IList<string> postImages, bool hasCode) = await ResolveContentForPost(markdownText, fileName, resolvedUrl, domain);
|
||||
@@ -113,6 +112,7 @@ 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,8 +123,7 @@ 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,
|
||||
MarkdownMD5 = MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(markdownText)).ToHexString()
|
||||
HasCode = hasCode
|
||||
};
|
||||
var thumbNailUrl = string.IsNullOrWhiteSpace(postSettings.thumbnailImage) ?
|
||||
postImages?.FirstOrDefault() ?? "https://www.gravatar.com/avatar/333e3cea32cd17ff2007d131df336061?s=640" :
|
||||
@@ -137,13 +136,15 @@ 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)
|
||||
private async Task<LandingPage> BuildLandingPage(string fileName, string domain, string markdownText, PostSettings postSettings, string resolvedUrl, string canonicalUrl, string ampUrl)
|
||||
{
|
||||
(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()
|
||||
@@ -163,8 +164,10 @@ 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,
|
||||
@@ -173,8 +176,7 @@ 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,
|
||||
MarkdownMD5 = MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(markdownText)).ToHexString()
|
||||
HasCode = hasCode
|
||||
};
|
||||
return new LandingPage()
|
||||
{
|
||||
@@ -183,9 +185,11 @@ namespace TerribleDev.Blog.Web
|
||||
Title = postSettings.title,
|
||||
RelativeUrl = $"/{resolvedUrl}/",
|
||||
CanonicalUrl = canonicalUrl,
|
||||
AMPUrl = ampUrl,
|
||||
UrlWithoutPath = resolvedUrl,
|
||||
isLanding = postSettings.isLanding,
|
||||
Content = content,
|
||||
isAmp = postSettings.isAmp
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,8 @@ 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);
|
||||
@@ -27,7 +22,6 @@ 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());
|
||||
}));
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PushUrl>;
|
||||
var linkData = links as System.Collections.Generic.List<string>;
|
||||
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, AsProperty) = linkData[i];
|
||||
var url = linkData[i];
|
||||
var resolvedUrl = url.StartsWith("~") ? context.HttpContext.Request.PathBase.ToString() + url.Substring(1) : url;
|
||||
headerBuilder.Append($"<{resolvedUrl}>; rel=preload; as={AsProperty}");
|
||||
headerBuilder.Append($"<{resolvedUrl}>; rel=preload; as=style");
|
||||
if(i < linkData.Count - 1) {
|
||||
headerBuilder.Append(", ");
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ namespace TerribleDev.Blog.Web.MarkExtension
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if(linkInline.Url.EndsWith(".gif"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
renderer.Write("<picture>");
|
||||
WriteImageTag(renderer, linkInline, ".webp", "image/webp");
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public interface IPost
|
||||
{
|
||||
string AMPUrl { get; set; }
|
||||
string CanonicalUrl { get; set; }
|
||||
string UrlWithoutPath { get; set; }
|
||||
string RelativeUrl { get; set; }
|
||||
@@ -17,6 +18,7 @@ namespace TerribleDev.Blog.Web.Models
|
||||
DateTime? UpdatedDate { get; set; }
|
||||
IPostContent Content { get; set; }
|
||||
bool isLanding { get; set; }
|
||||
bool isAmp { get; set; }
|
||||
string ThumbnailImage { get; }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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; }
|
||||
@@ -21,6 +22,5 @@ namespace TerribleDev.Blog.Web.Models
|
||||
public string JsonLDString { get; set; }
|
||||
BreadcrumbList JsonLDBreadcrumb { get; set; }
|
||||
string JsonLDBreadcrumbString { get; set; }
|
||||
string MarkdownMD5 { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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; }
|
||||
@@ -18,6 +19,7 @@ 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"; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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; }
|
||||
@@ -19,6 +20,7 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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; }
|
||||
@@ -19,6 +20,5 @@ 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; }
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,6 @@ namespace TerribleDev.Blog.Web.Models
|
||||
|
||||
public bool isLanding { get; set; } = false;
|
||||
|
||||
public bool isAmp { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
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)
|
||||
|
||||

|
||||
|
||||
## 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?
|
||||
@@ -1,5 +1,6 @@
|
||||
title: Dynamically changing the site-theme meta tag
|
||||
date: 2022-04-12 11:05
|
||||
isAmp: false
|
||||
thumbnailImage: 1.jpg
|
||||
tags:
|
||||
- javascript
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
{
|
||||
@@ -59,9 +58,7 @@ namespace TerribleDev.Blog.Web
|
||||
}
|
||||
return postCache;
|
||||
});
|
||||
var controllerBuilder = services.AddControllersWithViews(a => {
|
||||
a.Filters.Add(new StaticETag());
|
||||
});
|
||||
var controllerBuilder = services.AddControllersWithViews();
|
||||
#if DEBUG
|
||||
if (Env.IsDevelopment())
|
||||
{
|
||||
@@ -89,7 +86,6 @@ 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();
|
||||
@@ -142,16 +138,6 @@ 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();
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,9 @@ using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Taghelpers
|
||||
{
|
||||
public record PushUrl(string Url, string asProperty);
|
||||
[HtmlTargetElement("link", Attributes = "[rel=stylesheet],href,push")]
|
||||
[HtmlTargetElement("img", Attributes = "src,push")]
|
||||
[HtmlTargetElement("link", Attributes = "rel, href, http-2-push")]
|
||||
public class HttpPush : LinkTagHelper
|
||||
{
|
||||
[HtmlAttributeNotBound]
|
||||
public bool Http2PushEnabled { get; set; } = true;
|
||||
|
||||
public static readonly string Key = "http2push-link";
|
||||
@@ -27,34 +24,23 @@ 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 (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;
|
||||
|
||||
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;
|
||||
if(linkList == null)
|
||||
{
|
||||
linkList = new List<PushUrl>() { new PushUrl(url, asProperty) };
|
||||
linkList = new List<string>() { url };
|
||||
ViewContext.HttpContext.Items.Add(HttpPush.Key, linkList);
|
||||
}
|
||||
else
|
||||
{
|
||||
linkList.Add(new PushUrl(url, asProperty));
|
||||
linkList.Add(url);
|
||||
}
|
||||
output.Attributes.Remove(output.Attributes["push"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,34 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Taghelpers
|
||||
{
|
||||
[HtmlTargetElement("mobile", TagStructure = TagStructure.NormalOrSelfClosing)]
|
||||
public class MobileTagHelper : AbstractPlatformTagHelper
|
||||
[HtmlTargetElement("desktopOnly", TagStructure = TagStructure.NormalOrSelfClosing)]
|
||||
public class DesktopTagHelper : TagHelper
|
||||
{
|
||||
protected override bool ShouldRender() => this.GetPlatform() == Platform.Mobile;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace TerribleDev.Blog.Web.Taghelpers
|
||||
{
|
||||
public enum Platform
|
||||
{
|
||||
Desktop,
|
||||
Mobile,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="compilerconfig.json" />
|
||||
<Content Remove="bundleconfig.json" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@model PostViewModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Post.Title;
|
||||
var amp = ViewData["amp"] as bool? ?? false;
|
||||
}
|
||||
|
||||
<cache vary-by-route="postUrl,amp">
|
||||
@@ -23,7 +24,10 @@
|
||||
<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)">
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<article>
|
||||
<h1 itemprop="headline" class="headline">@Model.Title</h1>
|
||||
<time class="headlineSubtext" itemprop="datePublished" content="@Model.PublishDate.ToString()">@Model.PublishDate.ToString("D")</time>
|
||||
@Model.Content.Content
|
||||
@if(amp)
|
||||
{
|
||||
@Model.Content.AmpContent
|
||||
}
|
||||
else
|
||||
{
|
||||
@Model.Content.Content
|
||||
}
|
||||
|
||||
</article>
|
||||
@@ -1,9 +1,18 @@
|
||||
@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>
|
||||
@Model.Content.Content
|
||||
@if(amp)
|
||||
{
|
||||
@Model.Content.AmpContent
|
||||
}
|
||||
else
|
||||
{
|
||||
@Model.Content.Content
|
||||
}
|
||||
@if (Model.tags.Count > 0)
|
||||
{
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<script>
|
||||
|
||||
@{
|
||||
Layout = null;
|
||||
var amp = ViewData["amp"] as bool? ?? false;
|
||||
}
|
||||
@if(!amp)
|
||||
{
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
@@ -11,4 +18,20 @@
|
||||
document.body.appendChild(script);
|
||||
}, 4000)
|
||||
});
|
||||
</script>
|
||||
</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>
|
||||
}
|
||||
@@ -1,10 +1,26 @@
|
||||
|
||||
@{
|
||||
var amp = ViewData["amp"] as bool?;
|
||||
}
|
||||
|
||||
<nav class="navBar hide" id="navBar">
|
||||
<div class="navContent">
|
||||
<picture class="navHero">
|
||||
@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">
|
||||
<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>
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
@inject BlogConfiguration config
|
||||
|
||||
|
||||
@{
|
||||
var amp = ViewData["amp"] as bool? ?? false;
|
||||
var htmlTag = amp ? "amp" : "";
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" @htmlTag>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<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="author" content="Tommy "TerribleDev" Parnell" />
|
||||
<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">
|
||||
<meta name="author" content="Tommy "TerribleDev" Parnell" />
|
||||
<link asp-append-version="true" rel="icon" href="~/favicon.ico" push />
|
||||
<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>
|
||||
}
|
||||
@RenderSection("Head", false)
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-main" href="#main">Skip to main content</a>
|
||||
<div class="rootbox">
|
||||
<header class="header">
|
||||
<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>
|
||||
@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>
|
||||
}
|
||||
<div class="headerCallout"><a href="/" class="link-unstyled ">@config.Title</a></div>
|
||||
</header>
|
||||
<partial name="Nav" />
|
||||
@@ -51,6 +65,8 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@if(!amp)
|
||||
{
|
||||
@RenderSection("Scripts", required: false)
|
||||
<environment names="Development">
|
||||
<script asp-append-version="true" src="~/js/swi.js" async></script>
|
||||
@@ -58,5 +74,12 @@
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning"
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,14 +0,0 @@
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
:root {
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -24,7 +37,7 @@ h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--hln);
|
||||
color: var(--headline);
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25em;
|
||||
@@ -38,8 +51,8 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.9rem;
|
||||
background-color: var(--pmry-bknd);
|
||||
color: var(--bdy-txt-clr);
|
||||
background-color: var(--primary-background);
|
||||
color: var(--body-text-color);
|
||||
font-size: 1.125rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -74,7 +87,7 @@ body {
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 2px solid var(--blk-qt-lb);
|
||||
border-left: 2px solid var(--block-quote-left-border);
|
||||
padding: 0.4em 1.2em;
|
||||
}
|
||||
|
||||
@@ -84,7 +97,7 @@ pre {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
background: var(--code-blk-bg-clr);
|
||||
background: var(--code-block-background-color);
|
||||
padding: 0 0.4em;
|
||||
overflow-x: scroll;
|
||||
letter-spacing: .02em;
|
||||
@@ -96,16 +109,16 @@ pre > code {
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--lnk-clr);
|
||||
color: var(--link-color);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--lnk-vistd);
|
||||
color: var(--link-visited);
|
||||
}
|
||||
|
||||
.btmRule {
|
||||
border-bottom: 1px solid var(--hr);
|
||||
border-bottom: 1px solid var(--horizontal-rule);
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
@@ -113,8 +126,8 @@ a:visited {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--nb-bkgd);
|
||||
color: var(--nb-txt-color);
|
||||
background: var(--nav-bar-background);
|
||||
color: var(--nav-bar-text-color);
|
||||
padding-top: 20px;
|
||||
height: 100vh;
|
||||
z-index: 40;
|
||||
@@ -128,9 +141,9 @@ a:visited {
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--hr);
|
||||
color: var(--hln);
|
||||
background-color: var(--pmry-bknd);
|
||||
border-bottom: 1px solid var(--horizontal-rule);
|
||||
color: var(--headline);
|
||||
background-color: var(--primary-background);
|
||||
z-index: 20;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@@ -173,21 +186,21 @@ a:visited {
|
||||
.btn {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: var(--pmry-bknd);
|
||||
background: var(--primary-background);
|
||||
border-radius: 3px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: var(--bdy-txt-clr);
|
||||
border: 1px solid var(--bdy-txt-clr);
|
||||
color: var(--body-text-color);
|
||||
border: 1px solid var(--body-text-color);
|
||||
padding: 0.3em 0.2em;
|
||||
text-decoration: none;
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.btn:visited {
|
||||
background: var(--pmry-bknd);
|
||||
color: var(--bc);
|
||||
border: 1px solid var(--bc);
|
||||
background: var(--primary-background);
|
||||
color: var(--border-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn.block {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 MiB |
@@ -3,9 +3,7 @@
|
||||
//Install stage sets up the offline page in the cache and opens a new cache
|
||||
|
||||
self.addEventListener('install', function (event) {
|
||||
setTimeout(function() {
|
||||
event.waitUntil(preLoad());
|
||||
}, 5000);
|
||||
event.waitUntil(preLoad());
|
||||
});
|
||||
|
||||
var preLoad = function () {
|
||||
|
||||
@@ -35,3 +35,14 @@ 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>`
|
||||
|
||||
Reference in New Issue
Block a user