1/* ======================================== 2 StoryWell â Blog Loader 3 Per-post URLs via ?id=<slug>, with history 4 support, dynamic titles, and HTML escaping. 5 ======================================== */ 6 7(function () { 8 var blogGrid = document.getElementById('blogGrid'); 9 var blogLoading = document.getElementById('blogLoading'); 10 var latestBlog = document.getElementById('latestBlog'); 11 12 var BASE_TITLE = document.title; 13 var BASE_PATH = window.location.pathname; 14 var BASE_URL = window.location.origin + BASE_PATH; 15 var descEl = document.querySelector('meta[name="description"]'); 16 var BASE_DESC = descEl ? descEl.getAttribute('content') : ''; 17 var allPosts = []; 18 19 var filters = document.getElementById('blogFilters'); 20 var searchBox = document.getElementById('blogSearch'); 21 var chipRow = document.getElementById('blogCategories'); 22 var countEl = document.getElementById('blogCount'); 23 var activeCategory = 'All'; 24 25 // Same estimate the static pages carry: 200 words a minute. 26 function readingMinutes(text) { 27 return Math.max(1, Math.round((text || '').split(/\s+/).length / 200)); 28 } 29 30 // Keep title + canonical + social tags in sync with the current view, 31 // so deep-linked posts are individually indexable and shareable. 32 function setMeta(sel, attr, val) { 33 var el = document.querySelector(sel); 34 if (el) el.setAttribute(attr, val); 35 } 36 function applyMeta(title, url, desc) { 37 document.title = title; 38 setMeta('link[rel="canonical"]', 'href', url); 39 setMeta('meta[property="og:url"]', 'content', url); 40 setMeta('meta[property="og:title"]', 'content', title); 41 setMeta('meta[name="twitter:title"]', 'content', title); 42 if (desc) { 43 setMeta('meta[name="description"]', 'content', desc); 44 setMeta('meta[property="og:description"]', 'content', desc); 45 setMeta('meta[name="twitter:description"]', 'content', desc); 46 } 47 } 48 49 function esc(str) { 50 return String(str == null ? '' : str) 51 .replace(/&/g, '&').replace(/</g, '<') 52 .replace(/>/g, '>').replace(/"/g, '"'); 53 } 54 55 function currentId() { 56 return new URLSearchParams(window.location.search).get('id'); 57 } 58 59 function findById(id) { 60 for (var i = 0; i < allPosts.length; i++) { 61 if (allPosts[i].id === id) return allPosts[i]; 62 } 63 return null; 64 } 65 66 fetch('content/blog/blog.json') 67 .then(function (res) { return res.json(); }) 68 .then(function (data) { 69 allPosts = data.posts || data; 70 buildFilters(); 71 if (latestBlog) renderBlogPosts(latestBlog, allPosts.slice(0, 3), false); 72 if (blogGrid) route(false); 73 }) 74 .catch(function () { 75 if (blogLoading) blogLoading.textContent = 'Could not load blog posts.'; 76 }); 77 78 // Sync the view to the current URL (used on load and on back/forward). 79 function route(smoothScroll) { 80 if (!blogGrid) return; 81 var id = currentId(); 82 var post = id ? findById(id) : null; 83 if (post) { 84 renderFullPost(post, smoothScroll); 85 } else { 86 renderGrid(); 87 } 88 } 89 90 window.addEventListener('popstate', function () { route(false); }); 91 92 // The page's own illustration introduces the grid; inside a single 93 // post it would sit above that post's hero image, so it steps aside. 94 function setIntroArtVisible(visible) { 95 var art = document.getElementById('blogIntroArt'); 96 if (art) art.style.display = visible ? '' : 'none'; 97 } 98 99 function renderGrid() { 100 applyMeta(BASE_TITLE, BASE_URL, BASE_DESC); 101 setIntroArtVisible(true); 102 blogGrid.style.display = ''; 103 if (filters) filters.hidden = false; 104 applyFilters(); 105 } 106 107 // Title, excerpt and category are all searchable -- a reader looking for 108 // 'bilingual' should find the post whether or not the word is in the title. 109 function matches(post, term) { 110 if (activeCategory !== 'All' && post.category !== activeCategory) return false; 111 if (!term) return true; 112 return (post.title + ' ' + (post.excerpt || '') + ' ' + (post.category || '')) 113 .toLowerCase().indexOf(term) !== -1; 114 } 115 116 function applyFilters() { 117 var term = (searchBox ? searchBox.value : '').trim().toLowerCase(); 118 var shown = allPosts.filter(function (p) { return matches(p, term); }); 119 renderBlogPosts(blogGrid, shown, true); 120 if (countEl) { 121 countEl.textContent = (term || activeCategory !== 'All') 122 ? shown.length + (shown.length === 1 ? ' post' : ' posts') 123 : ''; 124 } 125 if (chipRow) { 126 [].forEach.call(chipRow.children, function (btn) { 127 var on = btn.dataset.cat === activeCategory; 128 btn.classList.toggle('is-active', on); 129 btn.setAttribute('aria-pressed', on ? 'true' : 'false'); 130 }); 131 } 132 } 133 134 function buildFilters() { 135 if (!filters || !chipRow) return; 136 var cats = ['All'];
137 allPosts.forEach(function (p) { 138 if (p.category && cats.indexOf(p.category) === -1) cats.push(p.category); 139 }); 140 chipRow.innerHTML = ''; 141 cats.forEach(function (cat) { 142 var btn = document.createElement('button'); 143 btn.type = 'button'; 144 btn.className = 'sw-chip'; 145 btn.dataset.cat = cat; 146 btn.textContent = cat; 147 btn.addEventListener('click', function () { 148 activeCategory = cat; 149 applyFilters(); 150 }); 151 chipRow.appendChild(btn); 152 }); 153 if (searchBox) searchBox.addEventListener('input', applyFilters); 154 } 155 156 function renderBlogPosts(container, posts, expandable) { 157 container.innerHTML = ''; 158 159 if (!posts || posts.length === 0) { 160 var searching = (searchBox && searchBox.value.trim()) || activeCategory !== 'All'; 161 container.innerHTML = '<p style="text-align:center;grid-column:1/-1;color:var(--sw-muted);padding:40px;">' + 162 (searching ? 'No posts match that. Try another word or category.' : 'No blog posts yet. Check back soon!') + 163 '</p>'; 164 return; 165 } 166 167 posts.forEach(function (post) { 168 var card = document.createElement('div'); 169 card.className = 'blog-card'; 170 171 var imageHtml = post.image 172 ? '<div class="blog-card-image">' + window.swCardImg(post.image, post.title) + '</div>' 173 : ''; 174 175 var date = new Date(post.date); 176 var dateStr = date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); 177 178 card.innerHTML = 179 imageHtml + 180 '<div class="blog-card-body">' + 181 '<p class="blog-date">' + dateStr + ' · ' + esc(post.category) + 182 (post.content ? ' · ' + readingMinutes(post.content) + ' min read' : '') + '</p>' + 183 '<h3>' + esc(post.title) + '</h3>' + 184 '<p>' + esc(post.excerpt) + '</p>' + 185 (expandable && post.content ? '<p style="color:var(--sw-saffron-ink);font-weight:600;margin-top:12px;font-size:0.9rem;">Read more →</p>' : '') + 186 '</div>'; 187 188 if (expandable && post.content) { 189 card.style.cursor = 'pointer'; 190 card.setAttribute('role', 'link'); 191 card.setAttribute('tabindex', '0'); 192 var open = function () { window.location.href = postUrl(post); }; 193 card.addEventListener('click', open); 194 card.addEventListener('keydown', function (e) { 195 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } 196 }); 197 } else if (!expandable) { 198 // Homepage strip: link straight to the post's own URL. 199 card.style.cursor = 'pointer'; 200 card.addEventListener('click', function () { 201 window.location.href = postUrl(post); 202 }); 203 } 204 205 container.appendChild(card); 206 }); 207 } 208 209 // Each post also exists as its own page, written by 210 // scripts/build-blog-pages.py, so that a shared link carries that 211 // post's title and picture â social crawlers do not run this script. 212 function postUrl(post) { 213 return (BASE_PATH.indexOf('/blog.html') === 0 ? '' : '') + 'blog/' + 214 encodeURIComponent(post.id) + '.html'; 215 } 216 217 // Navigate to a post (pushes a history entry so it's shareable/back-able). 218 function openPost(post) { 219 history.pushState({ id: post.id }, '', BASE_PATH + '?id=' + encodeURIComponent(post.id)); 220 renderFullPost(post, true); 221 } 222 223 function renderFullPost(post, smoothScroll) { 224 setIntroArtVisible(false); 225 if (filters) filters.hidden = true; 226 if (countEl) countEl.textContent = ''; 227 applyMeta(post.title + ' â StoryWell Books', 228 window.location.origin + '/blog/' + encodeURIComponent(post.id) + '.html', 229 post.excerpt); 230 231 var date = new Date(post.date); 232 var dateStr = date.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); 233 234 // Content is trusted (CMS-authored): escape, then apply light markdown. 235 var contentHtml = esc(post.content) 236 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>') 237 .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') 238 .replace(/\*(.*?)\*/g, '<em>$1</em>') 239 .replace(/\n\n/g, '</p><p>') 240 .replace(/\n/g, '<br>'); 241 contentHtml = '<p>' + contentHtml + '</p>'; 242 243 var postView = document.createElement('div'); 244 postView.className = 'blog-post-full'; 245 postView.innerHTML = 246 '<button class="blog-back-btn" id="blogBackBtn">← Back to all posts</button>' + 247 '<article class="blog-article">' + 248 '<p class="blog-date" style="margin-bottom:8px;">' + dateStr + ' · ' + esc(post.category) + '</p>' + 249 '<h2 style="margin-bottom:8px;">' + esc(post.title) + '</h2>' + 250 '<p style="color:var(--sw-muted);font-size:0.9rem;margin-bottom:32px;">By ' + esc(post.author) + '</p>' + 251 // The card carries a lettered banner sized for a thumbnail; 252 // the full post shows the illustration when there is one. 253 (post.hero || post.image 254 ? '<img src="' + esc(post.hero || post.image) + '" alt="' + esc(post.title) + '" style="width:100%;border-radius:12px;margin-bottom:32px;">' 255 : '') + 256 '<div class="blog-content">' + contentHtml + '</div>' + 257 '</article>'; 258 259 blogGrid.innerHTML = '';
260 blogGrid.appendChild(postView); 261 blogGrid.style.display = 'block'; 262 263 if (blogGrid.offsetTop) { 264 window.scrollTo({ top: blogGrid.offsetTop - 100, behavior: smoothScroll ? 'smooth' : 'auto' }); 265 } 266 267 document.getElementById('blogBackBtn').addEventListener('click', function () { 268 history.pushState({}, '', BASE_PATH); 269 renderGrid(); 270 window.scrollTo({ top: blogGrid.offsetTop - 100, behavior: 'smooth' }); 271 }); 272 } 273})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.