Files
oneuptime/Home/Views/Blog/Post.ejs

1280 lines
65 KiB
Plaintext

<!DOCTYPE html>
<html lang="en" id="home">
<meta http-equiv="content-type" content="text/html;charset=utf-8" />
<head>
<title>
<%= blogPost.title %>
</title>
<meta name="description" content="<%= blogPost.description %>">
<% const seo = { fullCanonicalUrl: 'https://oneuptime.com/blog/post/' + blogPost.fileName + '/view' }; %>
<%- include('../head-basic') -%>
<link rel="alternate" type="application/rss+xml" title="OneUptime Blog RSS Feed" href="/blog/rss.xml">
<meta property="og:site_name" content="OneUptime | One Complete Observability platform.">
<meta property="og:type" content="article">
<meta property="og:title" content="<%= blogPost.title %>">
<meta property="og:description" content="<%= blogPost.description %>">
<meta property="og:url" content="<%= blogPost.blogUrl %>">
<meta property="og:image" content="<%= blogPost.socialMediaImageUrl %>">
<meta property="article:published_time" content="<%- blogPost.postDate -%>T00:00:00.000Z">
<meta property="article:modified_time" content="<%- blogPost.postDate -%>T00:00:00.000Z">
<meta property="article:publisher" content="https://www.facebook.com/OneUptime">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<%= blogPost.title %>">
<meta name="twitter:description" content="<%= blogPost.description %>">
<meta name="twitter:url" content="<%= blogPost.blogUrl %>">
<meta name="twitter:image" content="<%= blogPost.socialMediaImageUrl %>">
<meta name="twitter:label1" content="Written by">
<meta name="twitter:data1" content="<%= blogPost.author.name %>">
<meta name="twitter:site" content="@OneUptimeHQ">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="720">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"publisher": {
"@type": "Organization",
"name": "OneUptime",
"url": "https://oneuptime.com/",
"logo": {
"@type": "ImageObject",
"url": "https://oneuptime.com/img/OneUptimePNG/1.png",
"width": 60,
"height": 60
}
},
"author": {
"@type": "Person",
"name": "<%= blogPost.author.name %>",
"url": "<%= blogPost.author.githubUrl %>",
"sameAs": []
},
"headline": "<%= blogPost.title %>",
"url": "<%= blogPost.blogUrl %>",
"datePublished": "<%- blogPost.postDate -%>T00:00:00.000Z",
"dateModified": "<%- blogPost.postDate -%>T00:00:00.000Z",
"description": "<%- blogPost.description -%>",
"mainEntityOfPage": "<%= blogPost.blogUrl %>"
}
</script>
<!-- Breadcrumb schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://oneuptime.com/" },
{ "@type": "ListItem", "position": 2, "name": "Blog", "item": "https://oneuptime.com/blog" },
{ "@type": "ListItem", "position": 3, "name": "<%= blogPost.title %>" }
]
}
</script>
<!-- Syntax highlighting with highlight.js - using VS Code dark theme -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>
// Lazy-load only the language packs actually used on this page
document.addEventListener('DOMContentLoaded', function(){
var langMap = {
'dockerfile':'dockerfile','yaml':'yaml','yml':'yaml','bash':'bash','shell':'bash','sh':'bash',
'typescript':'typescript','ts':'typescript','go':'go','python':'python','py':'python',
'json':'json','ruby':'ruby','rb':'ruby','java':'java','css':'css','sql':'sql',
'rust':'rust','graphql':'graphql','javascript':'javascript','js':'javascript',
'xml':'xml','html':'xml','markup':'xml','cpp':'cpp','csharp':'csharp','php':'php',
'bind':'dns','dns':'dns','zone':'dns',
'conf':'apache','apache':'apache','nginx':'nginx',
'ini':'ini','toml':'ini',
'diff':'diff','http':'http','makefile':'makefile',
'c':'c','swift':'swift','kotlin':'kotlin','scala':'scala','lua':'lua',
'perl':'perl','r':'r','powershell':'powershell','ps':'powershell'
};
var needed = {};
document.querySelectorAll('pre code[class*="language-"]').forEach(function(el){
var m = el.className.match(/language-(\w+)/);
if(m && langMap[m[1]]){ needed[langMap[m[1]]] = true; }
else if(m && m[1]){ el.className = el.className.replace(/language-\w+/, ''); }
});
var langs = Object.keys(needed);
var loaded = 0;
if(langs.length === 0){ hljs.highlightAll(); return; }
langs.forEach(function(lang){
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/' + lang + '.min.js';
s.async = false;
s.onload = s.onerror = function(){
loaded++;
if(loaded >= langs.length){ hljs.highlightAll(); }
};
document.head.appendChild(s);
});
});
</script>
<style>
body {
background: #ffffff;
color: #111827;
}
.reading-progress {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 3px;
z-index: 60;
background: linear-gradient(90deg, #3b82f6, #06b6d4, #10b981);
transition: width 120ms ease-out;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.4), 0 0 20px rgba(6, 182, 212, 0.3);
}
.floating-share {
position: absolute;
left: -5rem;
top: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.floating-share a, .floating-share button {
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
border: 1px solid rgba(148, 163, 184, 0.5);
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
transition: all 0.2s ease;
cursor: pointer;
color: inherit;
}
.floating-share a:hover, .floating-share button:hover {
border-color: #3b82f6;
color: #3b82f6;
transform: translateY(-2px);
}
/* Code blocks styling */
.blog-body pre {
position: relative;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace !important;
font-size: 14px !important;
line-height: 1.6 !important;
font-weight: 400 !important;
margin-top: 1.75rem !important;
margin-bottom: 1.75rem !important;
border: 1px solid rgba(15, 23, 42, 0.1) !important;
border-radius: 0.75rem !important;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06) !important;
}
.blog-body pre code {
font-family: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
font-weight: inherit !important;
}
/* Ensure highlight.js doesn't override our font settings */
.blog-body .hljs {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace !important;
font-size: 14px !important;
line-height: 1.6 !important;
font-weight: 400 !important;
padding: 1.25rem !important;
padding-top: 3rem !important;
border-radius: 0.75rem !important;
}
/* Language label */
.code-block-header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.06);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem 0.75rem 0 0;
z-index: 2;
}
.code-lang-label {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.5);
user-select: none;
}
/* Copy button */
.code-copy-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
border: none;
border-radius: 0.375rem;
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.code-copy-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
}
.code-copy-btn.copied {
color: #4ade80;
}
.code-copy-btn svg {
width: 14px;
height: 14px;
}
/* Custom scrollbar for code blocks */
.blog-body pre::-webkit-scrollbar,
.blog-body pre code::-webkit-scrollbar {
height: 6px;
}
.blog-body pre::-webkit-scrollbar-track,
.blog-body pre code::-webkit-scrollbar-track {
background: transparent;
}
.blog-body pre::-webkit-scrollbar-thumb,
.blog-body pre code::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.blog-body pre::-webkit-scrollbar-thumb:hover,
.blog-body pre code::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Inline code chips */
.blog-body :not(pre) > code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace;
font-size: 0.80em;
padding: 0.15rem 0.4rem;
border-radius: 0.375rem;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.15);
color: #2563eb;
white-space: nowrap;
}
.blog-body :not(pre) > code strong {
font-weight: 600;
}
/* Blockquotes */
.blog-body blockquote {
border-left: 3px solid #60a5fa;
background: rgba(239, 246, 255, 0.6);
padding: 1.25rem 1.5rem;
border-radius: 0 0.75rem 0.75rem 0;
font-style: normal;
color: #1f2937;
}
.blog-body img {
border-radius: 1rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08);
cursor: zoom-in;
transition: transform 0.2s;
}
.blog-body img:hover {
transform: scale(1.01);
}
.blog-body h2,
.blog-body h3,
.blog-body h4 {
scroll-margin-top: 7rem;
}
/* Links */
.blog-body a:not(.no-underline) {
text-decoration-color: rgba(59, 130, 246, 0.3);
text-underline-offset: 3px;
}
.blog-body a:not(.no-underline):hover {
text-decoration-color: rgba(59, 130, 246, 0.6);
}
@media (max-width: 1279px) {
.floating-share {
display: none !important;
}
}
/* Back to top button */
.back-to-top {
position: fixed;
bottom: 2rem;
left: 2rem;
width: 3rem;
height: 3rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(148, 163, 184, 0.3);
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 50;
}
.back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.back-to-top:hover {
background: #fff;
border-color: #3b82f6;
transform: translateY(-3px);
box-shadow: 0 15px 50px rgba(59, 130, 246, 0.15);
}
.back-to-top svg {
width: 1.25rem;
height: 1.25rem;
color: #64748b;
transition: color 0.2s;
}
.back-to-top:hover svg {
color: #3b82f6;
}
/* Floating TOC */
.floating-toc {
position: absolute;
top: 0;
left: calc(100% + 2rem);
width: 200px;
max-height: 60vh;
z-index: 40;
}
.floating-toc.is-fixed {
position: fixed;
top: 50%;
left: calc(50% + 28rem);
transform: translateY(-50%);
width: 200px;
}
.floating-toc.is-bottom {
position: absolute;
top: auto;
bottom: 0;
left: calc(100% + 2rem);
transform: none;
width: 200px;
}
@media (min-width: 1280px) and (max-width: 1439px) {
.floating-toc {
width: 180px;
}
.floating-toc.is-fixed {
left: calc(50% + 24rem);
width: 180px;
}
.floating-toc.is-bottom {
width: 180px;
}
}
@media (min-width: 1440px) and (max-width: 1599px) {
.floating-toc {
width: 190px;
}
.floating-toc.is-fixed {
left: calc(50% + 26rem);
width: 190px;
}
.floating-toc.is-bottom {
width: 190px;
}
}
.floating-toc-inner {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 1rem;
padding: 1rem;
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.08);
}
.floating-toc-inner:hover {
box-shadow: 0 15px 50px rgba(15, 23, 42, 0.12);
border-color: rgba(59, 130, 246, 0.3);
}
/* TOC active state */
#toc a.toc-active {
color: #2563eb;
font-weight: 500;
}
#toc a {
position: relative;
padding-left: 0.75rem;
display: block;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
font-size: 0.8125rem;
line-height: 1.4;
color: #64748b;
text-decoration: none;
transition: color 0.2s;
}
#toc a:hover {
color: #3b82f6;
}
#toc a::before {
content: '';
position: absolute;
left: 0;
top: 0.35rem;
bottom: 0.35rem;
width: 2px;
background: transparent;
border-radius: 1px;
transition: background 0.2s;
}
#toc a.toc-active::before {
background: #3b82f6;
}
#toc a.toc-h3 {
padding-left: 1.25rem;
font-size: 0.75rem;
color: #94a3b8;
}
#toc a.toc-h3::before {
left: 0.5rem;
}
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* Heading anchor links */
.blog-body h2,
.blog-body h3,
.blog-body h4 {
position: relative;
}
.heading-anchor {
position: absolute;
left: -1.5rem;
top: 50%;
transform: translateY(-50%);
opacity: 0;
color: #94a3b8;
text-decoration: none;
font-size: 0.9em;
font-weight: 400;
transition: opacity 0.2s, color 0.2s;
line-height: 1;
}
.blog-body h2:hover .heading-anchor,
.blog-body h3:hover .heading-anchor,
.blog-body h4:hover .heading-anchor,
.heading-anchor:focus {
opacity: 1;
}
.heading-anchor:hover {
color: #3b82f6;
}
/* Collapsible code blocks */
.collapsible-code.collapsed {
max-height: 400px;
overflow: hidden;
}
.collapsible-code.collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background: linear-gradient(to bottom, rgba(30, 30, 30, 0), rgba(30, 30, 30, 0.95));
border-radius: 0 0 0.75rem 0.75rem;
pointer-events: none;
z-index: 3;
}
.code-expand-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: -0.5rem;
margin-bottom: 1.5rem;
padding: 0.35rem 1rem;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 9999px;
background: rgba(248, 250, 252, 1);
color: rgba(100, 116, 139, 1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.code-expand-btn:hover {
background: rgba(241, 245, 249, 1);
border-color: rgba(148, 163, 184, 0.5);
color: #334155;
}
/* Image lightbox */
.blog-lightbox-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s;
cursor: zoom-out;
}
.blog-lightbox-overlay.active {
opacity: 1;
visibility: visible;
}
.blog-lightbox-overlay img {
max-width: 90vw;
max-height: 90vh;
border-radius: 0.75rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5);
transform: scale(0.95);
transition: transform 0.25s;
}
.blog-lightbox-overlay.active img {
transform: scale(1);
}
.blog-lightbox-close {
position: absolute;
top: 1.5rem;
right: 1.5rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.blog-lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Time remaining badge */
.time-remaining {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 50;
padding: 0.4rem 0.75rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(148, 163, 184, 0.3);
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.1);
font-size: 12px;
font-weight: 500;
color: #64748b;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
backdrop-filter: blur(8px);
}
.time-remaining.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* Print styles */
@media print {
.reading-progress,
.back-to-top,
.floating-share,
.floating-toc,
.time-remaining,
.code-block-header,
.code-expand-btn,
.blog-lightbox-overlay,
nav,
footer,
.blog-cta-section {
display: none !important;
}
.blog-body pre {
border: 1px solid #ccc !important;
box-shadow: none !important;
max-height: none !important;
overflow: visible !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
}
.collapsible-code.collapsed {
max-height: none !important;
overflow: visible !important;
}
.collapsible-code.collapsed::after {
display: none !important;
}
.blog-body {
font-size: 12pt;
}
body {
background: #fff !important;
}
}
</style>
</head>
<body>
<div class="reading-progress" id="reading-progress"></div>
<!-- Back to top button -->
<button class="back-to-top" id="back-to-top" aria-label="Back to top" title="Back to top">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
</button>
<%- include('../nav') -%>
<!-- Image lightbox overlay -->
<div class="blog-lightbox-overlay" id="lightbox-overlay">
<button class="blog-lightbox-close" aria-label="Close lightbox">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<img id="lightbox-img" src="" alt="" />
</div>
<!-- Time remaining badge -->
<div class="time-remaining" id="time-remaining">
<svg class="inline w-3 h-3 mr-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span id="time-remaining-text"></span>
</div>
<div class="relative isolate overflow-hidden bg-white">
<!-- Hero / Title Section -->
<div class="pt-20 pb-12 sm:pt-28 sm:pb-20">
<div class="mx-auto max-w-4xl px-6 lg:px-8 text-center relative">
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight text-gray-900 leading-tight">
<%= blogPost.title %>
</h1>
<p class="mt-6 text-xl text-gray-600 leading-relaxed max-w-3xl mx-auto">
<%= blogPost.description %>
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-4 text-sm text-gray-500">
<% if(blogPost.author){ %>
<a href="<%- blogPost.author.githubUrl -%>" target="_blank" class="flex items-center space-x-2 group">
<!-- Added explicit width & height to stabilize layout (CLS) -->
<img class="h-8 w-8 rounded-full ring-2 ring-gray-200 group-hover:ring-gray-400 transition" src="<%- blogPost.author.profileImageUrl -%>" alt="<%- blogPost.author.name -%>" width="32" height="32" decoding="async">
<span class="font-medium text-gray-700 group-hover:text-gray-900 transition">@<%- blogPost.author.username -%></span>
</a>
<span class="hidden sm:inline select-none">•</span>
<span><%- blogPost.formattedPostDate -%></span>
<span class="hidden sm:inline select-none">•</span>
<% } %>
<span id="reading-time" class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 border border-blue-100 text-blue-600">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="sr-only">Reading time</span>
</span>
</div>
<div class="mt-8">
<%- include('./Partials/Tags', { blogPost: blogPost }) -%>
</div>
</div>
</div>
<!-- Content -->
<div class="pb-24">
<div class="mx-auto max-w-4xl px-6 lg:px-8">
<div class="relative" id="content-wrapper">
<!-- Floating TOC -->
<div class="floating-toc hidden xl:block" id="floating-toc">
<div class="floating-toc-inner">
<h2 class="text-xs font-semibold tracking-wider text-blue-600 uppercase mb-3 flex items-center gap-2">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
On this page
</h2>
<nav id="toc" aria-label="Table of contents" class="overflow-y-auto max-h-[50vh] pr-1"></nav>
</div>
</div>
<!-- Main content -->
<article>
<div class="relative">
<div class="floating-share hidden xl:flex" aria-label="Share this article">
<a title="Share on X" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=<%- encodeURIComponent(blogPost.title) -%>&url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M17.53 3h3.77l-8.26 9.45L23 21h-6.17l-4.8-6.01L6.4 21H2.62l8.63-9.87L1 3h6.32l4.33 5.41L17.53 3Zm-1.33 15.62h2.09L7.94 4.29H5.71l10.49 14.33Z"/></svg>
</a>
<a title="Share on LinkedIn" target="_blank" rel="noopener" href="https://www.linkedin.com/sharing/share-offsite/?url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.049c.476-.9 1.637-1.85 3.37-1.85 3.601 0 4.266 2.37 4.266 5.455v6.286ZM5.337 7.433a2.062 2.062 0 1 1 0-4.124 2.062 2.062 0 0 1 0 4.124ZM7.119 20.452H3.553V9h3.566v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>
</a>
<a title="Discuss on Hacker News" target="_blank" rel="noopener" href="https://news.ycombinator.com/submitlink?u=<%- encodeURIComponent(blogPost.blogUrl) -%>&t=<%- encodeURIComponent(blogPost.title) -%>">
<svg class="w-4 h-4" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.92"></circle>
<path d="M7.2 6.2L12 12.8l4.8-6.6M12 13.4V17.8" fill="none" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</a>
<button title="Copy link" class="copy-link-btn" data-url="<%- blogPost.blogUrl -%>">
<svg class="w-4 h-4 copy-link-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
<svg class="w-4 h-4 copy-link-check hidden" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</button>
</div>
<div class="blog-body prose prose-gray max-w-none prose-headings:scroll-mt-28 prose-headings:font-display prose-headings:font-semibold lg:prose-headings:scroll-mt-[8.5rem] prose-a:font-semibold prose-a:text-gray-600 hover:prose-a:text-gray-500 prose-img:rounded-xl prose-pre:rounded-xl prose-code:text-gray-600">
<%- blogPost.htmlBody -%>
</div>
</div>
<!-- Share -->
<div class="mt-14 border-t border-gray-200/70 pt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
<div class="text-sm font-medium text-gray-600">Share this article</div>
<div class="flex gap-3">
<a title="Share on X" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-gray-400 hover:bg-gray-100 transition" href="https://twitter.com/intent/tweet?text=<%- encodeURIComponent(blogPost.title) -%>&url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
<svg class="w-4 h-4 text-gray-500 group-hover:text-gray-600" viewBox="0 0 24 24" fill="currentColor"><path d="M17.53 3h3.77l-8.26 9.45L23 21h-6.17l-4.8-6.01L6.4 21H2.62l8.63-9.87L1 3h6.32l4.33 5.41L17.53 3Zm-1.33 15.62h2.09L7.94 4.29H5.71l10.49 14.33Z"/></svg>
</a>
<a title="Share on LinkedIn" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-gray-400 hover:bg-gray-100 transition" href="https://www.linkedin.com/sharing/share-offsite/?url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
<svg class="w-4 h-4 text-gray-500 group-hover:text-gray-600" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.049c.476-.9 1.637-1.85 3.37-1.85 3.601 0 4.266 2.37 4.266 5.455v6.286ZM5.337 7.433a2.062 2.062 0 1 1 0-4.124 2.062 2.062 0 0 1 0 4.124ZM7.119 20.452H3.553V9h3.566v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>
</a>
<a title="Discuss on Hacker News" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-gray-400 hover:bg-gray-100 transition" href="https://news.ycombinator.com/submitlink?u=<%- encodeURIComponent(blogPost.blogUrl) -%>&t=<%- encodeURIComponent(blogPost.title) -%>">
<svg class="w-4 h-4 text-gray-500 group-hover:text-gray-600" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.92"></circle>
<path d="M7.2 6.2L12 12.8l4.8-6.6M12 13.4V17.8" fill="none" stroke="#ffffff" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</a>
<button title="Copy link" class="copy-link-btn group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-gray-400 hover:bg-gray-100 transition" data-url="<%- blogPost.blogUrl -%>">
<svg class="w-4 h-4 text-gray-500 group-hover:text-gray-600 copy-link-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
<svg class="w-4 h-4 text-green-500 copy-link-check hidden" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</button>
</div>
</div>
<!-- Author -->
<% if(blogPost.author){ %>
<div class="mt-16 p-6 sm:p-8 rounded-2xl bg-gradient-to-br from-gray-50 to-blue-50/30 border border-gray-200 shadow-sm hover:shadow-md hover:border-blue-200/60 transition-all duration-300">
<div class="flex flex-col sm:flex-row gap-5 sm:gap-6 items-start">
<!-- Added explicit width/height + lazy loading for below-the-fold author bio image -->
<div class="relative">
<img class="h-16 w-16 sm:h-20 sm:w-20 rounded-full ring-4 ring-white shadow-md" src="<%- blogPost.author.profileImageUrl -%>" alt="<%- blogPost.author.name -%>" width="80" height="80" loading="lazy" decoding="async">
<div class="absolute -bottom-1 -right-1 h-6 w-6 bg-emerald-400 rounded-full border-2 border-white flex items-center justify-center">
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-lg font-semibold text-gray-900"><%- blogPost.author.name -%></h3>
<span class="text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">Author</span>
</div>
<p class="text-sm text-gray-500 mb-3">@<%- blogPost.author.username -%> • <%- blogPost.formattedPostDate -%> • <span id="reading-time-inline"></span></p>
<div class="text-sm text-gray-600 leading-relaxed"><%- blogPost.author.bio || 'Building reliable software at OneUptime. Follow along for more on observability & reliability.' -%></div>
<div class="mt-4 flex flex-wrap gap-3">
<a href="<%- blogPost.author.githubUrl -%>" target="_blank" class="text-xs font-medium text-gray-600 hover:text-gray-900 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white border border-gray-200 hover:border-gray-300 shadow-sm transition-all duration-200">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
</div>
</div>
</div>
</div>
<% } %>
<div class="mt-20">
<%- include('./Partials/OpenSourceCommitment', { blogPost: blogPost }) -%>
</div>
</article>
</div>
</div>
</div>
</div>
<%- include('./Partials/BlogCta') -%>
<%- include('../footer') -%>
<script>
(function(){
// Language name mapping for display
var langNames = {
'js': 'JavaScript', 'javascript': 'JavaScript', 'ts': 'TypeScript', 'typescript': 'TypeScript',
'py': 'Python', 'python': 'Python', 'rb': 'Ruby', 'ruby': 'Ruby',
'go': 'Go', 'java': 'Java', 'css': 'CSS', 'html': 'HTML', 'xml': 'XML',
'json': 'JSON', 'yaml': 'YAML', 'yml': 'YAML', 'sql': 'SQL',
'bash': 'Bash', 'shell': 'Shell', 'sh': 'Shell', 'zsh': 'Shell',
'dockerfile': 'Dockerfile', 'docker': 'Dockerfile',
'rust': 'Rust', 'cpp': 'C++', 'c': 'C', 'csharp': 'C#',
'php': 'PHP', 'graphql': 'GraphQL', 'http': 'HTTP',
'markdown': 'Markdown', 'md': 'Markdown', 'plaintext': 'Text'
};
// Add copy button and language label to code blocks
try {
var codeBlocks = document.querySelectorAll('.blog-body pre');
codeBlocks.forEach(function(pre) {
var codeEl = pre.querySelector('code');
if(!codeEl) return;
// Detect language from class
var lang = '';
var classes = (codeEl.className || '').split(/\s+/);
for(var i = 0; i < classes.length; i++){
var m = classes[i].match(/^(?:language-|hljs-)(.+)$/);
if(m && m[1] !== 'hljs'){ lang = m[1]; break; }
}
if(!lang && codeEl.dataset && codeEl.dataset.highlightedLanguage){
lang = codeEl.dataset.highlightedLanguage;
}
var displayLang = langNames[lang] || (lang ? lang.charAt(0).toUpperCase() + lang.slice(1) : '');
// Create header bar
var header = document.createElement('div');
header.className = 'code-block-header';
// Language label
var labelEl = document.createElement('span');
labelEl.className = 'code-lang-label';
labelEl.textContent = displayLang;
header.appendChild(labelEl);
// Copy button
var copyBtn = document.createElement('button');
copyBtn.className = 'code-copy-btn';
copyBtn.setAttribute('aria-label', 'Copy code');
var copyIcon = '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>';
var checkIcon = '<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
copyBtn.innerHTML = copyIcon + '<span>Copy</span>';
copyBtn.addEventListener('click', function(){
var text = codeEl.textContent || '';
navigator.clipboard.writeText(text).then(function(){
copyBtn.classList.add('copied');
copyBtn.innerHTML = checkIcon + '<span>Copied!</span>';
setTimeout(function(){
copyBtn.classList.remove('copied');
copyBtn.innerHTML = copyIcon + '<span>Copy</span>';
}, 2000);
});
});
header.appendChild(copyBtn);
pre.style.position = 'relative';
pre.insertBefore(header, pre.firstChild);
});
} catch(e) {}
// Reading time
try {
const container = document.querySelector('.blog-body');
if(container){
const text = container.textContent || '';
const words = text.trim().split(/\s+/).filter(Boolean).length;
const minutes = Math.max(1, Math.round(words / 200));
const rt = minutes + ' min read';
const el = document.getElementById('reading-time');
if(el){ el.insertAdjacentText('beforeend', rt); }
const el2 = document.getElementById('reading-time-inline');
if(el2){ el2.textContent = rt; }
}
} catch (e) {}
// Reading progress indicator
try {
const progressBar = document.getElementById('reading-progress');
const target = document.querySelector('.blog-body');
if(progressBar && target){
let start = 0;
let end = 0;
const recalc = () => {
const rect = target.getBoundingClientRect();
const scrollTop = window.scrollY || window.pageYOffset;
start = scrollTop + rect.top - 120;
end = start + target.offsetHeight;
};
const update = () => {
if(!end){ recalc(); }
const scrollPos = window.scrollY || window.pageYOffset;
const distance = end - start;
if(distance <= 0){ return; }
const progress = Math.min(1, Math.max(0, (scrollPos - start) / distance));
progressBar.style.width = (progress * 100).toFixed(2) + '%';
};
recalc();
window.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', () => {
end = 0;
recalc();
update();
});
update();
}
} catch (e) {}
// TOC with active state tracking and sticky positioning
try {
const container = document.querySelector('.blog-body');
const contentWrapper = document.getElementById('content-wrapper');
if(!container){ return; }
const headings = container.querySelectorAll('h2');
if(!headings.length){ return; }
const toc = document.getElementById('toc');
const floatingToc = document.getElementById('floating-toc');
if(!toc){ return; }
const tocLinks = [];
headings.forEach(h => {
if(!h.id){
h.id = h.textContent.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
}
const a = document.createElement('a');
a.href = '#' + h.id;
a.textContent = h.textContent;
a.className = h.tagName === 'H3' ? 'toc-h3' : '';
a.dataset.target = h.id;
toc.appendChild(a);
tocLinks.push({ link: a, heading: h });
});
// Track active TOC item and position on scroll
const updateToc = () => {
const scrollPos = window.scrollY + 150;
let activeIndex = 0;
tocLinks.forEach((item, i) => {
const rect = item.heading.getBoundingClientRect();
const top = rect.top + window.scrollY;
if(scrollPos >= top){
activeIndex = i;
}
});
tocLinks.forEach((item, i) => {
if(i === activeIndex){
item.link.classList.add('toc-active');
} else {
item.link.classList.remove('toc-active');
}
});
// Handle TOC positioning: top (absolute), fixed, or bottom (absolute)
if(floatingToc && contentWrapper){
const wrapperRect = contentWrapper.getBoundingClientRect();
const tocHeight = floatingToc.offsetHeight;
const viewportHeight = window.innerHeight;
const fixedTopPosition = (viewportHeight - tocHeight) / 2;
// When content top is above the point where TOC would be centered
const shouldBeFixed = wrapperRect.top < fixedTopPosition && wrapperRect.bottom > (fixedTopPosition + tocHeight);
// When content bottom is above where the fixed TOC would end
const shouldBeBottom = wrapperRect.bottom <= (fixedTopPosition + tocHeight);
floatingToc.classList.remove('is-fixed', 'is-bottom');
if(shouldBeBottom){
floatingToc.classList.add('is-bottom');
} else if(shouldBeFixed){
floatingToc.classList.add('is-fixed');
}
// else: stays at absolute top (default)
}
};
window.addEventListener('scroll', updateToc, { passive: true });
window.addEventListener('resize', updateToc, { passive: true });
updateToc();
} catch (e) {}
// Back to top button
try {
const backToTopBtn = document.getElementById('back-to-top');
if(backToTopBtn){
const toggleVisibility = () => {
if(window.scrollY > 400){
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
};
window.addEventListener('scroll', toggleVisibility, { passive: true });
toggleVisibility();
backToTopBtn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
} catch (e) {}
// Stabilize images inside blog body (add width/height if missing & lazy-load non-hero images)
try {
const imgs = document.querySelectorAll('.blog-body img');
imgs.forEach((img, index) => {
// If width/height already specified, skip dimension inference
if(!img.hasAttribute('width') || !img.hasAttribute('height')){
if(img.naturalWidth && img.naturalHeight){
img.setAttribute('width', img.naturalWidth);
img.setAttribute('height', img.naturalHeight);
} else {
// If not yet loaded, attach a one-time listener
img.addEventListener('load', function handler(){
if(!img.hasAttribute('width') && img.naturalWidth){ img.setAttribute('width', img.naturalWidth); }
if(!img.hasAttribute('height') && img.naturalHeight){ img.setAttribute('height', img.naturalHeight); }
img.removeEventListener('load', handler);
});
}
}
// Lazy-load images after the first one (often top/hero) for better LCP while reducing CLS
if(index > 0 && !img.hasAttribute('loading')){
img.setAttribute('loading', 'lazy');
}
if(!img.hasAttribute('decoding')){
img.setAttribute('decoding', 'async');
}
// Ensure display block for centered images without causing late shifts
if(!img.className.includes('inline') && !img.className.includes('block')){
img.classList.add('block');
}
});
} catch(e) {}
// Mermaid diagrams
try {
const mermaidCodeBlocks = document.querySelectorAll('.blog-body pre code.language-mermaid, .blog-body pre code.mermaid, .blog-body pre code[class*="language-mermaid"], .blog-body pre code[class*="mermaid"]');
if(mermaidCodeBlocks.length){
// Dynamically load Mermaid only if needed to avoid extra payload on pages without diagrams
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
script.async = true;
script.onload = () => {
if(!window.mermaid){ return; }
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
window.mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose', // Content is trusted (owned blog); allows links & styling
theme: prefersDark ? 'dark' : 'default'
});
mermaidCodeBlocks.forEach((codeEl, i) => {
// Avoid double-processing
if(codeEl.dataset.mermaidProcessed){ return; }
const pre = codeEl.closest('pre');
if(!pre){ return; }
const def = codeEl.textContent.trim(); // Use textContent to ignore highlight.js span wrappers
const container = document.createElement('div');
container.className = 'mermaid my-6';
// Provide a deterministic ID (optional)
container.id = 'mermaid-diagram-' + (i+1);
container.textContent = def;
pre.replaceWith(container);
codeEl.dataset.mermaidProcessed = 'true';
});
try { window.mermaid.run && window.mermaid.run(); } catch(_) {}
};
document.head.appendChild(script);
}
} catch(e) {}
// Anchor links on headings
try {
var headingsForAnchors = document.querySelectorAll('.blog-body h1, .blog-body h2, .blog-body h3, .blog-body h4');
headingsForAnchors.forEach(function(h) {
if(!h.id){
h.id = h.textContent.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
}
h.style.position = 'relative';
var anchor = document.createElement('a');
anchor.href = '#' + h.id;
anchor.className = 'heading-anchor';
anchor.setAttribute('aria-label', 'Link to ' + h.textContent);
anchor.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>';
h.appendChild(anchor);
});
} catch(e) {}
// Image lightbox
try {
var overlay = document.getElementById('lightbox-overlay');
var lightboxImg = document.getElementById('lightbox-img');
if(overlay && lightboxImg) {
var blogImages = document.querySelectorAll('.blog-body img');
blogImages.forEach(function(img) {
img.addEventListener('click', function() {
lightboxImg.src = img.src;
lightboxImg.alt = img.alt || '';
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
});
});
var closeLightbox = function() {
overlay.classList.remove('active');
document.body.style.overflow = '';
lightboxImg.src = '';
};
overlay.addEventListener('click', function(e) {
if(e.target === overlay || e.target.closest('.blog-lightbox-close')) {
closeLightbox();
}
});
document.addEventListener('keydown', function(e) {
if(e.key === 'Escape' && overlay.classList.contains('active')) {
closeLightbox();
}
});
}
} catch(e) {}
// Collapsible long code blocks (>100 lines)
try {
var allPres = document.querySelectorAll('.blog-body pre');
allPres.forEach(function(pre) {
var codeEl = pre.querySelector('code');
if(!codeEl) return;
var lineCount = (codeEl.textContent || '').split('\n').length;
if(lineCount > 100) {
pre.classList.add('collapsible-code');
pre.classList.add('collapsed');
var expandBtn = document.createElement('button');
expandBtn.className = 'code-expand-btn';
expandBtn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg> Show all ' + lineCount + ' lines';
expandBtn.addEventListener('click', function() {
if(pre.classList.contains('collapsed')) {
pre.classList.remove('collapsed');
expandBtn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg> Show less';
} else {
pre.classList.add('collapsed');
expandBtn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg> Show all ' + lineCount + ' lines';
pre.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
pre.parentNode.insertBefore(expandBtn, pre.nextSibling);
}
});
} catch(e) {}
// Time remaining indicator
try {
var blogBody = document.querySelector('.blog-body');
var timeRemainingEl = document.getElementById('time-remaining');
var timeRemainingText = document.getElementById('time-remaining-text');
if(blogBody && timeRemainingEl && timeRemainingText) {
var totalWords = (blogBody.textContent || '').trim().split(/\s+/).filter(Boolean).length;
var wpm = 200;
var updateTimeRemaining = function() {
var rect = blogBody.getBoundingClientRect();
var scrollTop = window.scrollY || window.pageYOffset;
var bodyTop = scrollTop + rect.top;
var bodyHeight = blogBody.offsetHeight;
var progress = Math.min(1, Math.max(0, (scrollTop - bodyTop + window.innerHeight * 0.5) / bodyHeight));
var wordsRemaining = Math.max(0, Math.round(totalWords * (1 - progress)));
var minsRemaining = Math.max(0, Math.ceil(wordsRemaining / wpm));
if(progress > 0.02 && progress < 0.95) {
timeRemainingEl.classList.add('visible');
if(minsRemaining <= 1) {
timeRemainingText.textContent = 'Less than 1 min left';
} else {
timeRemainingText.textContent = minsRemaining + ' min remaining';
}
} else {
timeRemainingEl.classList.remove('visible');
}
};
window.addEventListener('scroll', updateTimeRemaining, { passive: true });
updateTimeRemaining();
}
} catch(e) {}
// Copy link buttons
try {
var copyLinkBtns = document.querySelectorAll('.copy-link-btn');
copyLinkBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
var url = btn.dataset.url || window.location.href;
navigator.clipboard.writeText(url).then(function() {
var icon = btn.querySelector('.copy-link-icon');
var check = btn.querySelector('.copy-link-check');
if(icon) icon.classList.add('hidden');
if(check) check.classList.remove('hidden');
setTimeout(function() {
if(icon) icon.classList.remove('hidden');
if(check) check.classList.add('hidden');
}, 2000);
});
});
});
} catch(e) {}
})();
</script>
<%- include("../Partials/cta-tracking") -%>
</body>
</html>