Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3946d61d4 | ||
|
|
de0fafbc74 | ||
|
|
8fd4dbd4ec | ||
|
|
3795a95ed0 | ||
|
|
0dce62ab99 | ||
|
|
dbea3664d6 | ||
|
|
e26c47f91f | ||
|
|
58e34d8177 | ||
|
|
0e36180218 | ||
|
|
a245a21e02 | ||
|
|
792919cb70 | ||
|
|
934a762939 | ||
|
|
be76863dc2 | ||
|
|
da8a0d9a4f | ||
|
|
71cf945baf | ||
|
|
abacb42468 | ||
|
|
198f99f7f1 | ||
|
|
4fe77edfbc | ||
|
|
78e73f2a4c | ||
|
|
521b335f8a | ||
|
|
e2ad204571 | ||
|
|
755c03303b | ||
|
|
4c25db4039 |
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@@ -9,7 +9,7 @@
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/src/TerribleDev.Blog.Web/bin/Debug/netcoreapp2.2/TerribleDev.Blog.Web.dll",
|
||||
"program": "${workspaceFolder}/src/TerribleDev.Blog.Web/bin/Debug/netcoreapp3.1/TerribleDev.Blog.Web.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/src/TerribleDev.Blog.Web",
|
||||
"stopAtEntry": false,
|
||||
@@ -42,4 +42,4 @@
|
||||
"processId": "${command:pickProcess}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
this.configuration = configuration;
|
||||
this.postCache = postCache;
|
||||
}
|
||||
public static DateTimeOffset publishDate = DateTimeOffset.UtcNow; // keep publish date in memory so we just return when the server was kicked
|
||||
public static DateTimeOffset publishDate = DateTimeOffset.UtcNow; // keep publish date in memory so we just return when the server was kicked
|
||||
[Route("/rss")]
|
||||
[Route("/rss.xml")]
|
||||
[ResponseCache(Duration = 7200)]
|
||||
@@ -55,17 +55,17 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
{
|
||||
Response.StatusCode = 200;
|
||||
Response.ContentType = "text/xml";
|
||||
var sitewideLinks = new List<SiteMapItem>(postCache.TagsToPosts.Keys.Select(a => new SiteMapItem() { LastModified = DateTime.UtcNow, Location = $"https://blog.terribledev.io/tag/{a}/" }))
|
||||
var sitewideLinks = new List<SiteMapItem>(postCache.TagsToPosts.Keys.Select(a => new SiteMapItem() { LastModified = DateTime.UtcNow, Location = $"https://blog.terrible.dev/tag/{a}/" }))
|
||||
{
|
||||
new SiteMapItem() { LastModified = DateTime.UtcNow, Location="https://blog.terribledev.io/all-tags/" }
|
||||
new SiteMapItem() { LastModified = DateTime.UtcNow, Location="https://blog.terrible.dev/all-tags/" }
|
||||
};
|
||||
var ser = new XmlSerializer(typeof(SiteMapRoot));
|
||||
var sitemap = new SiteMapRoot()
|
||||
{
|
||||
Urls = postCache.PostsAsLists.Select(a => new SiteMapItem() { LastModified = DateTime.UtcNow, Location = $"https://blog.terribledev.io/{a.Url}/" }).ToList()
|
||||
Urls = postCache.PostsAsLists.Select(a => new SiteMapItem() { LastModified = DateTime.UtcNow, Location = a.CanonicalUrl }).ToList()
|
||||
};
|
||||
sitemap.Urls.AddRange(sitewideLinks);
|
||||
ser.Serialize(this.Response.Body, sitemap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
{
|
||||
return View(postCache.TagsToPosts);
|
||||
}
|
||||
[Route("/tags/{tagName}")]
|
||||
[OutputCache(Duration = 31536000, VaryByParam = "tagName")]
|
||||
public IActionResult TagPluralRedirect(string tagName)
|
||||
{
|
||||
if(string.IsNullOrEmpty(tagName))
|
||||
{
|
||||
return Redirect($"/404/?from=/tags/emptyString/");
|
||||
}
|
||||
return Redirect($"/tag/{tagName}/");
|
||||
}
|
||||
[Route("/tag/{tagName}")]
|
||||
[OutputCache(Duration = 31536000, VaryByParam = "tagName")]
|
||||
public IActionResult GetTag(string tagName)
|
||||
@@ -30,8 +40,8 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
return Redirect($"/404/?from=/tag/{tagName}/");
|
||||
}
|
||||
{
|
||||
return View(new Models.GetTagViewModel { Tag = tagName, Posts = models });
|
||||
return View(new Models.GetTagViewModel { Tag = tagName, Posts = models, CanonicalUrl = $"https://blog.terrible.dev/tag/{tagName.ToLower()}/" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM microsoft/dotnet:2.2-aspnetcore-runtime AS base
|
||||
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
|
||||
FROM microsoft/dotnet:2.2-sdk AS build
|
||||
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
|
||||
WORKDIR /src
|
||||
COPY ["./TerribleDev.Blog.Web.csproj", "."]
|
||||
RUN dotnet restore "TerribleDev.Blog.Web.csproj"
|
||||
@@ -17,4 +17,4 @@ RUN dotnet publish "TerribleDev.Blog.Web.csproj" -c Release -o /app
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app .
|
||||
ENTRYPOINT ["dotnet", "TerribleDev.Blog.Web.dll"]
|
||||
ENTRYPOINT ["dotnet", "TerribleDev.Blog.Web.dll"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
title: Hosting your blog on the cheap
|
||||
date: 2018-08-22 04:49:46
|
||||
date: 2019-08-17 04:49:46
|
||||
tags:
|
||||
- cloud
|
||||
|
||||
@@ -9,13 +9,12 @@ A load of people have been asking me lately how I host my blog. Incase its not a
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Since I make no money, on this my strategy is about cutting costs. My grandfather use to say "take care of the pounds, because the pennies will take care of themselves." Now since my grandfather is in England, and their dollar is known as the pound, he was telling me to focus on the bigger picture.
|
||||
Since I make no money, on this my strategy is about cutting costs. My grandfather use to say "take care of the pounds, let the pennies take care of themselves." Now since my grandfather is in England, and their dollar is known as the pound, he was telling me to focus on the bigger picture.
|
||||
|
||||
The first big decision for blogs is what "engine" you are going to use, or if you are going to make your own. These usually fall into 2 categories. Static sites, which are usually when blogs are written in text files, and are compiled into static html, or server rendered blogs such as wordpress. When a request is made to blog that has server rendering, the html is dynamically built in time and delivered to the consumer. Static sites, on the other hand are precomputed and thus are just delivered to the browser.
|
||||
|
||||
I won't go into the details on what is better for different scenarios. If you are being cheap, then you will want to use static sites. Static sites are precomputed, which essentially means you just need to serve files to the user. There is no dynamic server to host, you won't need a database, etc. There are a few I like. This blog is ran off [Hexo](https://hexo.io)
|
||||
I won't go into the details on what is better for different scenarios. If you are being cheap, then you will want to use static sites. Static sites are precomputed, which essentially means you just need to serve files to the user. There is no dynamic server to host, you won't need a database, etc. There are a few I like, but my favorite is [gatsbyjs](https://www.gatsbyjs.org/).
|
||||
|
||||
|
||||
|
||||
<!-- So I know what you are thinking, static sites are just 'better' for page load time. While this is true, they can lack dynamic features that might be important to you, such as adding new blog posts on a schedule, or limiting ip addresses, or even some kind of login/subscription model. -->
|
||||
So I know what you are thinking, static sites are just 'better' for page load time. While this is true, they can lack dynamic features that might be important to you, such as adding new blog posts on a schedule, or limiting ip addresses, or even some kind of login/subscription model.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public static SyndicationItem ToSyndicationItem(this IPost x)
|
||||
{
|
||||
Uri.TryCreate($"https://blog.terribledev.io/{x.Url}/", UriKind.Absolute, out var url);
|
||||
Uri.TryCreate(x.CanonicalUrl, UriKind.Absolute, out var url);
|
||||
var syn = new SyndicationItem()
|
||||
{
|
||||
Title = x.Title,
|
||||
@@ -22,5 +22,13 @@ namespace TerribleDev.Blog.Web
|
||||
syn.AddLink(new SyndicationLink(url));
|
||||
return syn;
|
||||
}
|
||||
public static ISet<string> ToNormalizedTagList(this IPost x)
|
||||
{
|
||||
if(x.tags == null)
|
||||
{
|
||||
return new HashSet<string>();
|
||||
}
|
||||
return new HashSet<string>(x.tags.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.ToLower()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,49 +3,58 @@ using TerribleDev.Blog.Web.Models;
|
||||
using System.Linq;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System;
|
||||
using Microsoft.SyndicationFeed;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Factories
|
||||
{
|
||||
public static class BlogCacheFactory
|
||||
{
|
||||
|
||||
public static PostCache ProjectPostCache(IEnumerable<IPost> rawPosts)
|
||||
{
|
||||
var posts = rawPosts.OrderByDescending(a => a.PublishDate).ToImmutableList();
|
||||
var tagsToPost = posts.Where(a=>a.tags != null)
|
||||
.Aggregate(
|
||||
ImmutableDictionary.Create<string, ImmutableList<IPost>>(),
|
||||
(accum, item) => {
|
||||
foreach(var tag in item.tags.Select(i => i.ToLower()))
|
||||
var orderedPosts = rawPosts.OrderByDescending(a => a.PublishDate);
|
||||
var posts = new List<IPost>();
|
||||
var urlToPosts = new Dictionary<string, IPost>();
|
||||
var tagsToPost = new Dictionary<string, IList<IPost>>();
|
||||
var postsByPage = new Dictionary<int, IList<IPost>>();
|
||||
var syndicationPosts = new List<SyndicationItem>();
|
||||
foreach(var post in orderedPosts)
|
||||
{
|
||||
posts.Add(post);
|
||||
urlToPosts.Add(post.UrlWithoutPath, post);
|
||||
syndicationPosts.Add(post.ToSyndicationItem());
|
||||
foreach(var tag in post.ToNormalizedTagList())
|
||||
{
|
||||
if(accum.TryGetValue(tag, out var list))
|
||||
if(tagsToPost.TryGetValue(tag, out var list))
|
||||
{
|
||||
accum = accum.SetItem(tag, list.Add(item));
|
||||
list.Add(post);
|
||||
}
|
||||
else
|
||||
{
|
||||
accum = accum.Add(tag, ImmutableList.Create<IPost>(item));
|
||||
tagsToPost.Add(tag, new List<IPost>() { post });
|
||||
}
|
||||
}
|
||||
return accum;
|
||||
}).ToImmutableSortedDictionary();
|
||||
var urlToPosts = posts.ToImmutableDictionary(a => a.Url);
|
||||
var postsByPage =
|
||||
posts.Aggregate(ImmutableDictionary.Create<int, ImmutableList<IPost>>(), (accum, item) =>
|
||||
{
|
||||
if(!accum.Keys.Any())
|
||||
if(postsByPage.Keys.Count < 1)
|
||||
{
|
||||
accum = accum.SetItem(1, ImmutableList.Create<IPost>());
|
||||
postsByPage.Add(1, new List<IPost>() { post });
|
||||
}
|
||||
var highestPage = accum.Keys.Any() ? accum.Keys.Max() : 1;
|
||||
var current = accum[highestPage];
|
||||
if (current.Count >= 10)
|
||||
else
|
||||
{
|
||||
return accum.Add(highestPage + 1, ImmutableList.Create(item));
|
||||
var highestPageKey = postsByPage.Keys.Max();
|
||||
var highestPage = postsByPage[highestPageKey];
|
||||
if(highestPage.Count < 10)
|
||||
{
|
||||
highestPage.Add(post);
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
postsByPage.Add(highestPageKey + 1, new List<IPost>() { post });
|
||||
}
|
||||
}
|
||||
|
||||
return accum.SetItem(highestPage, current.Add(item));
|
||||
}).ToImmutableDictionary();
|
||||
var syndicationPosts = posts.Select(i => i.ToSyndicationItem()).ToImmutableList();
|
||||
}
|
||||
|
||||
return new PostCache()
|
||||
{
|
||||
PostsAsLists = posts,
|
||||
@@ -57,4 +66,4 @@ namespace TerribleDev.Blog.Web.Factories
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,23 +11,29 @@ using TerribleDev.Blog.Web.MarkExtension.TerribleDev.Blog.Web.ExternalLinkParser
|
||||
using TerribleDev.Blog.Web.MarkExtension;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using System.Diagnostics;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public class BlogFactory
|
||||
{
|
||||
public List<IPost> GetAllPosts(string domain)
|
||||
public IEnumerable<IPost> GetAllPosts(string domain)
|
||||
{
|
||||
// why didn't I use f# I'd have a pipe operator by now
|
||||
var posts = GetPosts();
|
||||
var postsAsText = posts.Select(GetFileText);
|
||||
return Task.WhenAll(postsAsText).Result.Select(b => ParsePost(b.text, b.fileInfo.Name, domain)).ToList();
|
||||
var list = new ConcurrentBag<IPost>();
|
||||
Parallel.ForEach(posts, post =>
|
||||
{
|
||||
var (text, fileInfo) = GetFileText(post);
|
||||
list.Add(ParsePost(text, fileInfo.Name, domain));
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
private static async Task<(string text, FileInfo fileInfo)> GetFileText(string filePath)
|
||||
private static (string text, FileInfo fileInfo) GetFileText(string filePath)
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var text = await File.ReadAllTextAsync(fileInfo.FullName);
|
||||
var text = File.ReadAllText(fileInfo.FullName);
|
||||
return (text, fileInfo);
|
||||
|
||||
}
|
||||
@@ -52,22 +58,24 @@ namespace TerribleDev.Blog.Web
|
||||
.Use(new AbsoluteLinkConverter(resolvedUrl, domain))
|
||||
.Use<ImageRecorder>(new ImageRecorder(ref postImages))
|
||||
.Use<TargetLinkExtension>()
|
||||
.Use<PictureInline>()
|
||||
.UseMediaLinks()
|
||||
.Use<PictureInline>()
|
||||
.UseEmojiAndSmiley()
|
||||
.Build();
|
||||
var postContent = Markdown.ToHtml(markdownText, pipeline);
|
||||
var postContentPlain = String.Join("", Markdown.ToPlainText(markdownText, pipeline).Split("<!-- more -->"));
|
||||
|
||||
|
||||
var summary = postContent.Split("<!-- more -->")[0];
|
||||
var postSummaryPlain = postContentPlain.Split("<!-- more -->")[0];
|
||||
|
||||
|
||||
return new Post()
|
||||
{
|
||||
PublishDate = postSettings.date.ToUniversalTime(),
|
||||
tags = postSettings.tags?.Select(a => a.Replace(' ', '-').WithoutSpecialCharacters().ToLower()).ToList() ?? new List<string>(),
|
||||
Title = postSettings.title,
|
||||
Url = resolvedUrl,
|
||||
RelativeUrl = $"/{resolvedUrl}/",
|
||||
CanonicalUrl = $"https://blog.terrible.dev/{resolvedUrl}/",
|
||||
UrlWithoutPath = resolvedUrl,
|
||||
Content = new HtmlString(postContent),
|
||||
Summary = new HtmlString(summary),
|
||||
SummaryPlain = postSummaryPlain,
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public IEnumerable<IPost> Posts { get; set; }
|
||||
public string Tag { get; set; }
|
||||
public string CanonicalUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public interface IPost
|
||||
{
|
||||
string Url { get; set; }
|
||||
string CanonicalUrl { get; set; }
|
||||
string UrlWithoutPath { get; set; }
|
||||
string RelativeUrl { get; set; }
|
||||
string Title { get; set; }
|
||||
HtmlString Summary { get; set; }
|
||||
DateTime PublishDate { get; set; }
|
||||
@@ -20,4 +22,5 @@ namespace TerribleDev.Blog.Web.Models
|
||||
IList<string> tags { get; set; }
|
||||
IList<string> Images { get; set;}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
[DebuggerDisplay("{Title}")]
|
||||
public class Post : IPost
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string CanonicalUrl { get; set; }
|
||||
public string UrlWithoutPath { get; set; }
|
||||
public string RelativeUrl { get; set; }
|
||||
public string Title { get; set; }
|
||||
public DateTime PublishDate { get; set; }
|
||||
public HtmlString Content { get; set; }
|
||||
@@ -17,6 +20,6 @@ namespace TerribleDev.Blog.Web.Models
|
||||
public string SummaryPlain { get; set; }
|
||||
public string SummaryPlainShort { get; set; }
|
||||
public IList<string> tags { get; set; }
|
||||
public IList<string> Images { get; set;}
|
||||
public IList<string> Images { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.SyndicationFeed;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public class PostCache
|
||||
{
|
||||
public ImmutableList<IPost> PostsAsLists { get; set;}
|
||||
public ImmutableSortedDictionary<string, ImmutableList<IPost>> TagsToPosts { get; set; }
|
||||
public ImmutableDictionary<string, IPost> UrlToPost { get; set; }
|
||||
public ImmutableDictionary<int, ImmutableList<IPost>> PostsByPage { get; set; }
|
||||
public ImmutableList<SyndicationItem> PostsAsSyndication { get; set; }
|
||||
public IList<IPost> PostsAsLists { get; set;}
|
||||
public IDictionary<string, IList<IPost>> TagsToPosts { get; set; }
|
||||
public IDictionary<string, IPost> UrlToPost { get; set; }
|
||||
public IDictionary<int, IList<IPost>> PostsByPage { get; set; }
|
||||
public IList<SyndicationItem> PostsAsSyndication { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
20
src/TerribleDev.Blog.Web/Models/PostComparer.cs
Normal file
20
src/TerribleDev.Blog.Web/Models/PostComparer.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public class PostComparer
|
||||
{
|
||||
public static PostComparisonByDateInternal PostComparisonByDate = new PostComparisonByDateInternal();
|
||||
|
||||
public class PostComparisonByDateInternal : IComparer<IPost>
|
||||
{
|
||||
public int Compare([AllowNull] IPost x, [AllowNull] IPost y)
|
||||
{
|
||||
return DateTime.Compare(x.PublishDate, y.PublishDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
title: 5 web perf tips for 2019
|
||||
date: 2019-02-23 01:32
|
||||
tags:
|
||||
- web
|
||||
- performance
|
||||
- javascript
|
||||
- battle of the bulge
|
||||
---
|
||||
|
||||
As more and more of the world is getting online, a larger part of the internet community is using the internet on lower powered devices. Making websites fast is becoming paramount. Here are 5 tips to improving you web page's performance
|
||||
|
||||
<!-- more -->
|
||||
|
||||
## Brotli and gzip
|
||||
|
||||
So incase you didn't know, when your browser makes a request to the server it sends along a header called `Accept-Encoding` This is a comma separated list of compression types your server can use to compress the data to the user. The common ones in the past have been `gzip, and deflate`. [Broli](https://en.wikipedia.org/wiki/Brotli), is a compression
|
||||
algorithm invented by google to be a more efficient for the web. This has about a 35% effectiveness over gzip based on my own testing. This means your content will be almost 1/3rd smaller over the wire. Most browsers [support this already](https://caniuse.com/#feat=brotli). You can use cloudflare to serve Brotli (br) to your users, and most web servers support this today. Make sure your server is serving br, and at minimum gzip.
|
||||
|
||||
|
||||
## Webp, JPEG 2000
|
||||
|
||||
Images are among one of the largest types of files on the internet today, and picking the right file type is as important as getting your data structures right. In the past we told everyone to keep photography in `jpeg`, logos and screen shots in `png`. However google has come out with a new file format. One that is massively smaller than either `jpeg` or `png`, and that is `webp`. Webp is only supported on [chrome, edge and firefox](https://caniuse.com/#search=webp), but don't worry for IOS Safari you can use `JPEG 2000`. Sizing images is also a key concern, you can use srcset to size images appropriately, and you can use the picture element to select the right image given browser support.
|
||||
|
||||
```html
|
||||
|
||||
<picture>
|
||||
<source type="image/webp" srcset="3.webp" alt="an image showing the tiny png results">
|
||||
<source type="image/jp2" srcset="3.jp2" alt="an image showing the tiny png results">
|
||||
<img src="3.png" alt="an image showing the tiny png results">
|
||||
</picture>
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Lighthouse
|
||||
|
||||
Ok so this is less of a trick to implement and more of a tool use use. Man I keep mentioning google, but they keep making amazing web stuff so here we are. Google has made this awesome performance tool called [lighthouse](https://developers.google.com/web/tools/lighthouse/). A version of this tool is built into chrome. Open the developer tools, and click the `audits` tab. That tool is lighthouse. You can install newer versions with `npm install -g lighthouse` or `yarn global add lighthouse`. Then just run `lighthouse --view <url>` so this blog would be `lighthouse --view https://blog.terrible.dev`. You should be hit with a pretty in depth report as to how you can fix and improve your web pages. You can also have your CI system run lighthouse on every build. You can fail PR's if they reduce performance, or just track your accessibility over time.
|
||||
|
||||
## HTTP/2
|
||||
|
||||
HTTP version 2 is a newer version of the http spec. Supported [by all major browsers](https://caniuse.com/#feat=http2) this protocol offers compression of http headers, a [push feature](https://en.wikipedia.org/wiki/HTTP/2_Server_Push) that lets you push files down to the browser before they are requested, [http pipelining](https://en.wikipedia.org/wiki/HTTP_pipelining), and multiplexing multiple requests over a single TCP connection. You can easily get http2 working if you let [cloudflare](https://www.cloudflare.com/) front your http traffic, but you will still want to implement http2 in your server eventually.
|
||||
|
||||
|
||||
## Service workers
|
||||
|
||||
My last and probably favorite feature. [Service Workers](https://developers.google.com/web/fundamentals/primers/service-workers/) are a worker that can stand in between your server and web page in the browser. They are mostly a proxy that let you do things like cache your content, and support offline capabilities. They are easy to implement, you need to have a `manifest.json` file which you can generate from Microsoft's [PWA Builder](https://www.pwabuilder.com/), and just serve traffic over https only. PWA Builder even has [pre-made service workers](https://www.pwabuilder.com/serviceworker) for most scenarios so you don't even need to write your own. I use this for my blog to cache static content, preload blog posts, and provide offline support.
|
||||
@@ -0,0 +1,103 @@
|
||||
title: Accessibility Driven Development
|
||||
date: 2020-08-07 05:27:00
|
||||
tags:
|
||||
- a11y
|
||||
- accessibility
|
||||
---
|
||||
|
||||
|
||||
I've been working at [CarGurus.com](https://www.cargurus.com) for the last 2 years or so. One of the biggest journeys we've been undertaking is to take accessibility far more seriously. However with an engineering team way into the triple digits it gets harder and harder to scale accessibility knowledge.
|
||||
<!-- more -->
|
||||
Knowledge gaps aside CarGurus has a multitude of technologies UI are build with. The two major are [Freemarker](https://freemarker.apache.org/) and [React](https://reactjs.org/). I manage one of our infrastructure teams, we build the tools and technologies to create the site with. This includes our component library, our build systems, linting tools, authentication systems, and core utilities for product development. When we first started really taking accessibility seriously we went to several teams in the business. Many of them did not have anyone with accessibility expertise.
|
||||
|
||||
> Our first approach was to teach accessibility. At the same time we worked with our brand marketing team to ensure our color pallet would be accessible from the start.
|
||||
|
||||
|
||||
After identifying advocates on every team we set out to streamline identifying accessibility issues. One approach I decided to take was to show borders around failing elements during development. I first heard of this idea years ago when GitHub released something it called [accessibilityjs](https://github.com/github/accessibilityjs). This script Github included in its pages and put a giant ugly red border around failing elements. I thought this was a really slick idea to point out issues during development.
|
||||
|
||||
> I was going to use accessibility JS until I found axe-core
|
||||
|
||||
So [axe](https://www.deque.com/axe/) is a technology built by deque to identify accessibility issues. This is a highly configurable piece of technology that includes libraries for developers, browser extensions, and bots you can scan sites with. Deque has open sourced the core technology of axe which is a JavaScript called [axe-core](https://github.com/dequelabs/axe-core).
|
||||
|
||||
> I first started out by writing a script to use axe-core and to add a 10px red border around elements, but I quickly ran into trouble
|
||||
|
||||
First problem, I need to re-run axe every time the browser changes. If we click to open a nav-bar we'll need to rescan the page. Second problem, every-time we change the DOM the script would crash react apps, and finally axe-core is quite slow on large HTML documents.
|
||||
|
||||
## Mutation Observers
|
||||
|
||||
So the first problem was easily solvable. The browser has an API called [Mutation Observer](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver). This is an API that lets you listen to changes to certain elements and fire a function when those elements change. In our case we wanted to listen to any changes to the `<body>` tag and all of its descendants.
|
||||
|
||||
```js
|
||||
function scanForAccesibilityIssues() { /* scan for issues */}
|
||||
const observer = new MutationObserver(scanForAccesibilityIssues);
|
||||
observer.observe(document.querySelector('body'), { childList: true, subtree: true });
|
||||
```
|
||||
|
||||
## Shadow DOM
|
||||
|
||||
Several UI frameworks such as React keep an in memory representation of the HTML document. The reason for this is when you want to change the UI in React. React will diff its current in-memory DOM with the next DOM and determine the most efficient way to actually apply the changes to the browser. Any application such as a browser extension, or our accessibility detector that edits the DOM outside of React's in-memory DOM will cause React to freak out and either crash of apply a change in an unexpected way. Luckily in recent years browsers have added a [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM). This is essentially a DOM that is used to apply visual changes to a user, but sits outside the light DOM (or the regular DOM). However, not all HTML elements support The Shadow DOM. For us to apply the red border we need to use the shadow DOM, and if any elements do not support shadow then we have to apply the border to the parent element. I wrote a [recursive function](https://en.wikipedia.org/wiki/Recursion_(computer_science)#Tail-recursive_functions) called `resolveClosestShadowRoot` which will walk up the DOM document and find the closest parent a target element has that supports shadow. You can tell if a node supports shadow because it will have a `.attachShadow` method. So we can simply access this variable and see if its defined or not.
|
||||
|
||||
|
||||
```js
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} node
|
||||
* @returns
|
||||
*/
|
||||
function resolveClosestShadowRoot(node) {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
if (node.attachShadow) {
|
||||
return node;
|
||||
}
|
||||
return resolveClosestShadowRoot(node.parentElement);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
After we identify which element to style we just have to apply the border. The code below is doing that by calling the attach shadow function and setting its innerHTML.
|
||||
|
||||
```js
|
||||
const resolvedNode = resolveClosestShadowRoot(node);
|
||||
const shadowRoot = resolvedNode.attachShadow({ mode: 'open' });
|
||||
shadowRoot.innerHTML = '<style>:host { outline: red solid 1rem; }</style><slot></slot>';
|
||||
```
|
||||
|
||||
The `<slot></slot>` element is rendering the content of the light DOM. We still have to show the existing content, and the `:host` psudo-class selector is selecting the host of the shadow DOM.
|
||||
|
||||
## Debounce 🎉
|
||||
|
||||
In web development we often use what's known as a "debounce" to delay doing something. The simple example is sometimes people click on a button multiple times, often on accident, sometimes intentionally. Before taking any action or taking multiple actions you might wait a moment before they stop clicking to do something. You wouldn't want to take the same action multiple times for each click. This is where debounce comes into play.
|
||||
|
||||
```js
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timeout = null;
|
||||
return function (...args) {
|
||||
const next = () => fn.apply(this, args);
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(next, wait);
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
A debounce function accepts a function and a "wait time" or delay before being called to actually executing your function. To debounce a buttons onclick function you would pass its standard onclick function into the debounce function
|
||||
|
||||
```js
|
||||
const onclick = () => { };
|
||||
const debouncedClick = debounce(onclick, 500); // 500 milliseconds before the function is actually fired
|
||||
```
|
||||
|
||||
```html
|
||||
<button onclick="debouncedClick()" ></button>
|
||||
```
|
||||
## The result
|
||||
|
||||
So the result of all this is a function that listens to changes in the HTML document, waits 1 second for all the changes to finish applying, then scans the page for failing elements and uses The Shadow DOM to apply a red border around those elements. You can see a basic version of the code at [this Github Gist](https://gist.github.com/TerribleDev/51049146e00b36b0d8643f5e09d21ea8).
|
||||
|
||||
We log the Deque error object to the console which includes links to the failing elements. The result is whenever anyone develops new UI at CarGurus a giant ugly red border surrounds elements they don't write as accessible. This provides **immediate** feedback during the development process and prevents huge categories of accessibility issues from reaching production.
|
||||
|
||||

|
||||
@@ -43,7 +43,7 @@ Essentially I add the routing package to the container, and then have have the a
|
||||
foreach(var route in Routes.RoutesDictionary)
|
||||
{
|
||||
a.MapGet("docker101", handler: async b=>{
|
||||
b.Response.Redirect("https://blog.terribledev.io/Getting-started-with-docker-containers/", true);
|
||||
b.Response.Redirect("https://blog.terrible.dev/Getting-started-with-docker-containers/", true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ namespace TerribleDev.Blog.Web
|
||||
|
||||
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
|
||||
WebHost.CreateDefaultBuilder(args)
|
||||
.UseApplicationInsights()
|
||||
.UseStartup<Startup>()
|
||||
.ConfigureKestrel(a =>
|
||||
{
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpsPolicy;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Rewrite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using HardHat.Middlewares;
|
||||
using HardHat;
|
||||
using TerribleDev.Blog.Web.Models;
|
||||
using TerribleDev.Blog.Web.Factories;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public class Startup
|
||||
{
|
||||
public Startup(IConfiguration configuration, IHostingEnvironment env)
|
||||
public Startup(IConfiguration configuration, IWebHostEnvironment env)
|
||||
{
|
||||
Configuration = configuration;
|
||||
Env = env;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
public IHostingEnvironment Env { get; }
|
||||
public IWebHostEnvironment Env { get; }
|
||||
|
||||
// This method gets called by the runtime. Use this method to add services to the container.
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
@@ -43,25 +38,29 @@ namespace TerribleDev.Blog.Web
|
||||
{
|
||||
services.AddSingleton(getBlog());
|
||||
}
|
||||
services.AddSingleton(i => {
|
||||
var posts = new BlogFactory().GetAllPosts(Env.IsDevelopment() ? "https://localhost:5001": "https://blog.terribledev.io");
|
||||
services.AddSingleton((i) => {
|
||||
var posts = new BlogFactory().GetAllPosts(Env.IsDevelopment() ? "https://localhost:5001": "https://blog.terrible.dev");
|
||||
return BlogCacheFactory.ProjectPostCache(posts);
|
||||
});
|
||||
services.AddApplicationInsightsTelemetry();
|
||||
var controllerBuilder = services.AddControllersWithViews();
|
||||
#if DEBUG
|
||||
if (Env.IsDevelopment())
|
||||
{
|
||||
controllerBuilder.AddRazorRuntimeCompilation();
|
||||
}
|
||||
#endif
|
||||
services.AddResponseCompression(a =>
|
||||
{
|
||||
a.EnableForHttps = true;
|
||||
|
||||
})
|
||||
.AddMemoryCache()
|
||||
.AddMvcCore()
|
||||
.AddCacheTagHelper()
|
||||
.AddRazorViewEngine()
|
||||
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
|
||||
services.AddOutputCaching();
|
||||
.AddOutputCaching();
|
||||
}
|
||||
|
||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
@@ -75,7 +74,6 @@ namespace TerribleDev.Blog.Web
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseResponseCompression();
|
||||
|
||||
var cacheTime = env.IsDevelopment() ? 0 : 31536000;
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
@@ -117,7 +115,11 @@ namespace TerribleDev.Blog.Web
|
||||
UpgradeInsecureRequests = true
|
||||
});
|
||||
app.UseOutputCaching();
|
||||
app.UseMvc();
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapControllers();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -14,18 +15,18 @@ namespace TerribleDev.Blog.Web.Taghelpers
|
||||
[HtmlAttributeName("href")]
|
||||
public string Href { get; set; }
|
||||
|
||||
private IHostingEnvironment HostingEnvironment { get; }
|
||||
private IWebHostEnvironment HostingEnvironment { get; }
|
||||
private IMemoryCache Cache { get; }
|
||||
|
||||
|
||||
|
||||
public InlineStyleTagHelper(IHostingEnvironment hostingEnvironment, IMemoryCache cache)
|
||||
|
||||
public InlineStyleTagHelper(IWebHostEnvironment hostingEnvironment, IMemoryCache cache)
|
||||
{
|
||||
HostingEnvironment = hostingEnvironment;
|
||||
Cache = cache;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
var path = Href;
|
||||
@@ -69,4 +70,4 @@ namespace TerribleDev.Blog.Web.Taghelpers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<UserSecretsId>9a1f51b6-f4d9-4df7-a0af-e345176e9927</UserSecretsId>
|
||||
@@ -23,21 +23,20 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BuildBundlerMinifier" Version="2.8.391" />
|
||||
<PackageReference Include="Markdig" Version="0.15.7" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.0.2105168" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.2.0" />
|
||||
<PackageReference Include="UriBuilder.Fluent" Version="1.5.2" />
|
||||
<PackageReference Include="YamlDotNet" Version="5.3.0" />
|
||||
<PackageReference Include="HardHat" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
|
||||
<PackageReference Include="WebEssentials.AspNetCore.OutputCaching" Version="1.0.16" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="3.1.0" Condition="'$(Configuration)' == 'Debug'" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Posts\*.md" CopyToOutputDirectory="Always" />
|
||||
<Watch Include="Posts\*.md" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -45,4 +44,7 @@
|
||||
<WCFMetadata Include="Connected Services" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="BackgroundWorker\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@inject Microsoft.AspNetCore.Hosting.IHostingEnvironment env
|
||||
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment env
|
||||
@{
|
||||
ViewData["Title"] = "Debug";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@inject BlogConfiguration config
|
||||
@model IPost
|
||||
@{
|
||||
ViewData["Title"] = "Post";
|
||||
ViewData["HideNav"] = true;
|
||||
ViewData["Title"] = @Model.Title;
|
||||
}
|
||||
|
||||
<cache vary-by-route="postUrl">
|
||||
@@ -13,7 +12,7 @@
|
||||
<meta name="description" content="@Model.SummaryPlainShort" />
|
||||
<meta property="og:type" content="blog">
|
||||
<meta property="og:title" content="@Model.Title">
|
||||
<meta property="og:url" content="https://blog.terribledev.io/@Model.Url/">
|
||||
<meta property="og:url" content="@Model.CanonicalUrl">
|
||||
<meta property="og:site_name" content="@config.Title">
|
||||
<meta property="og:description" content="@Model.SummaryPlainShort">
|
||||
<meta property="og:updated_time" content="@Model.PublishDate.ToString("O")">
|
||||
@@ -22,6 +21,7 @@
|
||||
<meta name="twitter:description" content="@Model.SummaryPlainShort">
|
||||
<meta name="twitter:site" content="@@TerribleDev">
|
||||
<meta name="twitter:creator" content="@@TerribleDev">
|
||||
<link rel="canonical" href="@Model.CanonicalUrl" />
|
||||
@foreach(var image in Model.Images.Take(6))
|
||||
{
|
||||
<meta property="og:image" content="@image">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
@inject Microsoft.ApplicationInsights.AspNetCore.JavaScriptSnippet JavaScriptSnippet
|
||||
@{
|
||||
Layout = null;
|
||||
}
|
||||
@@ -14,5 +13,4 @@
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'UA-48128396-1');
|
||||
</script>
|
||||
@Html.Raw(JavaScriptSnippet.FullScript)
|
||||
</script>
|
||||
@@ -1,19 +1,18 @@
|
||||
@{
|
||||
var hideNav = ViewData["HideNav"] != null ? "" : "withBody";
|
||||
}
|
||||
<nav class="navBar hide @hideNav" id="navBar">
|
||||
<picture>
|
||||
<source srcset="" type="image/webp" alt="An image of TerribleDev" data-src="/content/tommyAvatar4.jpg.webp" class="lazy round" />
|
||||
<img src="" alt="An image of TerribleDev" data-src="/content/tommyAvatar4.jpg" class="lazy round" />
|
||||
</picture>
|
||||
<span>Tommy "Terrible Dev" Parnell</span>
|
||||
<ul class="sidebarBtns">
|
||||
<li><a href="/" class="link-unstyled">Home</a></li>
|
||||
<li><a href="/all-tags" class="link-unstyled">Tags</a></li>
|
||||
<li><a href="/rss.xml" class="link-unstyled">RSS Feed</a></li>
|
||||
<li><a href="https://github.com/terribledev" rel="noopener" target="_blank" class="link-unstyled">Github</a></li>
|
||||
<li><a href="https://twitter.com/terribledev" rel="noopener" target="_blank" class="link-unstyled">Twitter</a></li>
|
||||
<li><a href="mailto:tommy@terribledev.io" class="link-unstyled">Email</a></li>
|
||||
<li><span class="link-unstyled" id="closeNav">Close Navbar</span></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav class="navBar hide" id="navBar">
|
||||
<div class="navContent">
|
||||
<picture class="navHero">
|
||||
<source srcset="" type="image/webp" alt="An image of TerribleDev" data-src="/content/tommyAvatar4.jpg.webp" class="lazy round" />
|
||||
<img src="" alt="An image of TerribleDev" data-src="/content/tommyAvatar4.jpg" class="lazy round" />
|
||||
</picture>
|
||||
<span>Tommy "Terrible Dev" Parnell</span>
|
||||
<ul class="sidebarBtns">
|
||||
<li><a href="/" class="link-unstyled">Home</a></li>
|
||||
<li><a href="/all-tags" class="link-unstyled">Tags</a></li>
|
||||
<li><a href="/rss.xml" class="link-unstyled">RSS Feed</a></li>
|
||||
<li><a href="https://github.com/terribledev" rel="noopener" target="_blank" class="link-unstyled">Github</a></li>
|
||||
<li><a href="https://twitter.com/terribledev" rel="noopener" target="_blank" class="link-unstyled">Twitter</a></li>
|
||||
<li><a href="mailto:tommy@terribledev.io" class="link-unstyled">Email</a></li>
|
||||
<li><span class="link-unstyled" id="closeNav">Close Navbar</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model IPost
|
||||
|
||||
<article class="btmRule">
|
||||
<h3 itemprop="headline" class="headline"><a href="/@Model.Url/" class="link-unstyled">@Model.Title</a></h3>
|
||||
<h3 itemprop="headline" class="headline"><a href="@Model.RelativeUrl" class="link-unstyled">@Model.Title</a></h3>
|
||||
<time class="headlineSubtext" itemprop="datePublished" content="@Model.PublishDate.ToString()">@Model.PublishDate.ToString("D")</time>
|
||||
<div itemprop="articleBody">
|
||||
@Model.Summary
|
||||
</div>
|
||||
<a href="/@Model.Url/">Continue Reading </a>
|
||||
</article>
|
||||
<a href="@Model.RelativeUrl">Continue Reading </a>
|
||||
</article>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<meta name="description" content="My name is Tommy Parnell. I usually go by TerribleDev on the internets. These are just some of my writings and rants about the software space." />
|
||||
<meta property="og:type" content="blog">
|
||||
<meta property="og:title" content="@config.Title">
|
||||
<meta property="og:url" content="https://blog.terribledev.io/">
|
||||
<meta property="og:url" content="https://blog.terrible.dev/">
|
||||
<meta property="og:site_name" content="@config.Title">
|
||||
<meta property="og:description" content="My name is Tommy Parnell. I usually go by TerribleDev on the internets. These are just some of my writings and rants about the software space.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
|
||||
@@ -15,27 +15,43 @@
|
||||
<link asp-append-version="true" rel="icon" async href="~/favicon.ico" />
|
||||
<title>@ViewData["Title"] - @config.Title</title>
|
||||
<environment names="Development">
|
||||
<inline-style href="css/site.css"></inline-style>
|
||||
<link asp-append-version="true" rel="stylesheet" href="~/css/site.css" />
|
||||
</environment>
|
||||
<environment names="Production">
|
||||
<inline-style href="css/site.min.css"></inline-style>
|
||||
<link asp-append-version="true" rel="stylesheet" href="~/css/site.min.css" />
|
||||
</environment>
|
||||
<environment names="Development">
|
||||
<link asp-append-version="true" media="screen and (min-width: 769px)" rel="stylesheet" href="~/css/site.desktop.css" />
|
||||
</environment>
|
||||
<environment names="Production">
|
||||
<link asp-append-version="true" media="screen and (min-width: 769px)" rel="stylesheet" href="~/css/site.desktop.min.css" />
|
||||
</environment>
|
||||
<environment names="Development">
|
||||
<link asp-append-version="true" media="screen and (max-width: 768px)" rel="stylesheet" href="~/css/site.mobile.css" />
|
||||
</environment>
|
||||
<environment names="Production">
|
||||
<link asp-append-version="true" media="screen and (max-width: 768px)" rel="stylesheet" href="~/css/site.mobile.min.css" />
|
||||
</environment>
|
||||
<environment names="Development">
|
||||
<link asp-append-version="true" rel="preload" as="script" href="~/js/swi.js" />
|
||||
</environment>
|
||||
<environment names="Production">
|
||||
<link asp-append-version="true" rel="preload" as="script" href="~/js/site.min.js" />
|
||||
</environment>
|
||||
@RenderSection("Head", false)
|
||||
</head>
|
||||
<body>
|
||||
<partial name="Nav" />
|
||||
<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>
|
||||
<div class="headerCallout"><a href="/" class="link-unstyled ">@config.Title</a></div>
|
||||
</header>
|
||||
@{
|
||||
var bodyBump = ViewData["HideNav"] == null ? "bodyWithNav": "";
|
||||
}
|
||||
<main role="main" class="@bodyBump headerBump">
|
||||
<div class="main-content-wrap">
|
||||
@RenderBody()
|
||||
</div>
|
||||
</main>
|
||||
<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>
|
||||
<div class="headerCallout"><a href="/" class="link-unstyled ">@config.Title</a></div>
|
||||
</header>
|
||||
<partial name="Nav" />
|
||||
<main class="headerBump main-content-wrap">
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@RenderSection("Scripts", required: false)
|
||||
<environment names="Development">
|
||||
<script asp-append-version="true" src="~/js/swi.js" async></script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@using System.Collections.Immutable;
|
||||
@model IDictionary<string, ImmutableList<IPost>>
|
||||
@model IDictionary<string, IList<IPost>>
|
||||
@{
|
||||
ViewData["Title"] = "all-tags";
|
||||
}
|
||||
@@ -12,4 +11,5 @@
|
||||
</cache>
|
||||
@section Head {
|
||||
<partial name="StockMeta" />
|
||||
}
|
||||
<link rel="canonical" href="https://blog.terrible.dev/all-tags/" />
|
||||
}
|
||||
|
||||
@@ -8,4 +8,10 @@
|
||||
{
|
||||
<partial name="PostSummary" model="post" />
|
||||
}
|
||||
</cache>
|
||||
</cache>
|
||||
|
||||
@section Head {
|
||||
@if(!String.IsNullOrEmpty(Model.CanonicalUrl)) {
|
||||
<link rel="canonical" href="@Model.CanonicalUrl" />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"AllowedHosts": "*",
|
||||
"Blog": {
|
||||
"title": "The Ramblings of TerribleDev",
|
||||
"link": "https://blog.terribledev.io"
|
||||
"link": "https://blog.terrible.dev"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,18 @@
|
||||
"wwwroot/css/site.css"
|
||||
]
|
||||
},
|
||||
{
|
||||
"outputFileName": "wwwroot/css/site.desktop.min.css",
|
||||
"inputFiles": [
|
||||
"wwwroot/css/site.desktop.css"
|
||||
]
|
||||
},
|
||||
{
|
||||
"outputFileName": "wwwroot/css/site.mobile.min.css",
|
||||
"inputFiles": [
|
||||
"wwwroot/css/site.mobile.css"
|
||||
]
|
||||
},
|
||||
{
|
||||
"outputFileName": "wwwroot/js/site.min.js",
|
||||
"inputFiles": [
|
||||
|
||||
@@ -1,259 +1,255 @@
|
||||
html {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
: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);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #4A4A4A;
|
||||
line-height: 1.45;
|
||||
letter-spacing: -.01em;
|
||||
line-height: 1.25em
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--headline: #f0f0f0;
|
||||
--body-text-color: #ffffff;
|
||||
--block-quote-left-border: #d1dced;
|
||||
--code-block-background-color: #727171;
|
||||
--primary-background: #323131;
|
||||
--link-color: #3faff9;
|
||||
/* --link-visited: #d8dbde; */
|
||||
--border-color: #bdcad2;
|
||||
--horizontal-rule: #626468;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--headline);
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.3rem;
|
||||
font-size: 3.3rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.6rem;
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.4rem;
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 2.2rem;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 2rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
article {
|
||||
margin-top: .5em;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
body {
|
||||
text-rendering: optimizeLegibility;
|
||||
letter-spacing: -.01em;
|
||||
line-height: 1.9rem;
|
||||
color: #5d686f;
|
||||
font-size: 1.125rem;
|
||||
margin: 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.9rem;
|
||||
background-color: var(--primary-background);
|
||||
color: var(--body-text-color);
|
||||
font-size: 1.125rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-content-wrap {
|
||||
display: block;
|
||||
max-width: 750px;
|
||||
margin: 0 auto;
|
||||
padding-right: 1.2em;
|
||||
padding-left: 1.2em;
|
||||
padding-bottom: 1.2em;
|
||||
display: block;
|
||||
max-width: 750px;
|
||||
padding-right: 1.2em;
|
||||
padding-left: 1.2em;
|
||||
padding-bottom: 1.2em;
|
||||
margin: 0 auto;
|
||||
grid-area: c
|
||||
}
|
||||
.headerBump {
|
||||
padding-top: 3.5rem;
|
||||
}
|
||||
.headerBump {
|
||||
padding-top: 3.5rem;
|
||||
}
|
||||
|
||||
.main-content-wrap img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1.2rem
|
||||
}
|
||||
|
||||
.bodyWithNav {
|
||||
width: calc(100% - 250px);
|
||||
float: right;
|
||||
.main-content-wrap img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.headlineSubtext {
|
||||
font-size: .8em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 2px solid #d1dced;
|
||||
padding: .4em 1.2em;
|
||||
border-left: 2px solid var(--block-quote-left-border);
|
||||
padding: 0.4em 1.2em;
|
||||
}
|
||||
|
||||
code, pre {
|
||||
font-size: 1rem;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
background: #f5f5f5;
|
||||
padding: 0 .4em;
|
||||
overflow-x: auto;
|
||||
code,
|
||||
pre {
|
||||
font-size: 1rem;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
background: var(--code-block-background-color);
|
||||
padding: 0 0.4em;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
/* fix a bug where a code block (not inline) would have extra padding) */
|
||||
pre > code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00558d;
|
||||
font-weight: 400;
|
||||
color: var(--link-color);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #6c6c6c;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--link-visited);
|
||||
}
|
||||
|
||||
.btmRule {
|
||||
border-bottom: 1px solid #eef2f8;
|
||||
padding-bottom: 3rem;
|
||||
border-bottom: 1px solid var(--horizontal-rule);
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
background: #4A4A4A;
|
||||
color: white;
|
||||
width: 250px;
|
||||
padding-top: 20px;
|
||||
transition: width ease-in-out .5s;
|
||||
z-index: 100;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--nav-bar-background);
|
||||
color: var(--nav-bar-text-color);
|
||||
padding-top: 20px;
|
||||
height: 100vh;
|
||||
z-index: 40;
|
||||
grid-area: b;
|
||||
}
|
||||
|
||||
.navBar.hide {
|
||||
display: none;
|
||||
}
|
||||
.navBar.withBody > * > * > #closeNav {
|
||||
display: none;
|
||||
}
|
||||
.navBar.withBody.hide {
|
||||
width: 250px;
|
||||
}
|
||||
.navBar.hide {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.navBar > * {
|
||||
max-width: 100%;
|
||||
color: white;
|
||||
}
|
||||
.header {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 3.5rem;
|
||||
top: 0;
|
||||
border: 1px solid #eef2f8;
|
||||
color: #4A4A4A;
|
||||
background-color: white;
|
||||
z-index: 20;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3.5rem;
|
||||
top: 0;
|
||||
border-bottom: 1px solid var(--horizontal-rule);
|
||||
color: var(--headline);
|
||||
background-color: var(--primary-background);
|
||||
z-index: 20;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
grid-area: a;
|
||||
}
|
||||
.headerCallout {
|
||||
margin: auto;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#menuBtn {
|
||||
cursor: pointer;
|
||||
margin-left: 1rem;
|
||||
cursor: pointer;
|
||||
margin-left: 1rem;
|
||||
fill: var(--headline);
|
||||
}
|
||||
|
||||
.sidebarBtns {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebarBtns > li {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.sidebarBtns > li {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.round {
|
||||
border-radius: 50%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bottomNavButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: .5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: #5d686f;
|
||||
border: 1px solid #9eabb3;
|
||||
padding: .3em .2em;
|
||||
text-decoration: none;
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: var(--primary-background);
|
||||
border-radius: 3px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: var(--border-color);
|
||||
border: 1px solid var(--border-color);
|
||||
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);
|
||||
}
|
||||
|
||||
.btn.block {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
margin-right: 4px;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: #4A4A4A;
|
||||
border: 1px solid #738691;
|
||||
}
|
||||
|
||||
|
||||
|
||||
a.link-unstyled, div.link-unstyled, span.link-unstyled, svg.link-unstyled {
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: inherit;
|
||||
.btn.block {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
margin-right: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn:hover {
|
||||
color: var(--body-text-color);
|
||||
border: 1px solid var(--body-text-color);
|
||||
|
||||
h1 {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
a.link-unstyled,
|
||||
div.link-unstyled,
|
||||
span.link-unstyled,
|
||||
svg.link-unstyled {
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
.navHero {
|
||||
min-height: 210px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 2.0rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
width: 100%;
|
||||
}
|
||||
.navBar.withBody.hide {
|
||||
width: 0;
|
||||
}
|
||||
.navBar.withBody > * > * > #closeNav {
|
||||
display: unset;
|
||||
}
|
||||
|
||||
.bodyWithNav {
|
||||
width: initial;
|
||||
float: initial;
|
||||
}
|
||||
}
|
||||
30
src/TerribleDev.Blog.Web/wwwroot/css/site.desktop.css
Normal file
30
src/TerribleDev.Blog.Web/wwwroot/css/site.desktop.css
Normal file
@@ -0,0 +1,30 @@
|
||||
@media screen and (min-width: 769px) {
|
||||
.rootbox {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
gap: 0;
|
||||
grid-template:
|
||||
"b b a"
|
||||
"b b c"
|
||||
"b b c";
|
||||
grid-template-columns: .25fr .75fr;
|
||||
}
|
||||
.navBar {
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
.navContent {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
.navBar.hide {
|
||||
display: flex;
|
||||
}
|
||||
#closeNav {
|
||||
display: none;
|
||||
}
|
||||
#menuBtn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
31
src/TerribleDev.Blog.Web/wwwroot/css/site.mobile.css
Normal file
31
src/TerribleDev.Blog.Web/wwwroot/css/site.mobile.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@media screen and (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.rootbox {
|
||||
display: grid;
|
||||
grid-template-areas: "a" "c";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.navBar {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
@@ -10,31 +10,6 @@ if (navigator && navigator.serviceWorker && navigator.serviceWorker.controller)
|
||||
console.log('Service worker has been registered for scope:' + reg.scope);
|
||||
});
|
||||
}
|
||||
var fetched = [];
|
||||
if (fetch) {
|
||||
document.querySelectorAll('a').forEach(a => {
|
||||
if (a.href.includes('http') || a.href.includes('https') || a.href.includes('mailto') || a.href.includes('#') || fetched.includes(a.href)) {
|
||||
return;
|
||||
}
|
||||
fetched.push(item.href);
|
||||
fetch(item.href);
|
||||
})
|
||||
}
|
||||
Promise.resolve(fetched);
|
||||
var triggerLazyImages = function () {
|
||||
document.querySelectorAll('source.lazy').forEach(a => {
|
||||
var src = a.getAttribute('data-src');
|
||||
if (src) {
|
||||
a.srcset = src;
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('img.lazy').forEach(a => {
|
||||
var src = a.getAttribute('data-src');
|
||||
if (src) {
|
||||
a.src = src;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var toggleNav = function () {
|
||||
var nav = document.getElementById('navBar');
|
||||
@@ -44,7 +19,6 @@ var toggleNav = function () {
|
||||
var hidden = nav.classList.contains('hide');
|
||||
if (hidden) {
|
||||
nav.classList.remove('hide');
|
||||
triggerLazyImages();
|
||||
}
|
||||
else {
|
||||
nav.classList.add('hide');
|
||||
@@ -60,13 +34,37 @@ function attachNavToggle(elementId) {
|
||||
}
|
||||
attachNavToggle('menuBtn');
|
||||
attachNavToggle('closeNav');
|
||||
document.addEventListener("readystatechange", function () {
|
||||
var nav = document.getElementById('navBar');
|
||||
if (!nav) {
|
||||
return;
|
||||
}
|
||||
var computedNav = window.getComputedStyle(nav);
|
||||
if (computedNav.width && computedNav.width !== "0px") {
|
||||
triggerLazyImages();
|
||||
}
|
||||
});
|
||||
|
||||
if(window.IntersectionObserver) {
|
||||
var lazyImages = [].slice.call(document.querySelectorAll(".lazy"));
|
||||
var lazyImageObserver = new IntersectionObserver(function(entries, observer) {
|
||||
entries.forEach(function(entry) {
|
||||
if (entry.isIntersecting) {
|
||||
var lazyImage = entry.target;
|
||||
if(lazyImage.dataset.src) {
|
||||
lazyImage.src = lazyImage.dataset.src;
|
||||
}
|
||||
if(lazyImage.dataset.srcset) {
|
||||
lazyImage.srcset = lazyImage.dataset.srcset;
|
||||
}
|
||||
|
||||
lazyImage.classList.remove("lazy");
|
||||
lazyImageObserver.unobserve(lazyImage);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lazyImages.forEach(function(lazyImage) {
|
||||
lazyImageObserver.observe(lazyImage);
|
||||
});
|
||||
} else {
|
||||
var lazyImages = [].slice.call(document.querySelectorAll(".lazy"));
|
||||
lazyImages.forEach(function(image) {
|
||||
if(image.dataset.srcset) {
|
||||
image.srcset = image.dataset.srcset;
|
||||
}
|
||||
if(image.dataset.src) {
|
||||
image.src = image.dataset.src;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user