PageSourceSearch

https://storywell.in/js/blog.js

js storywell.in collected 2026-09-29 07:08:13 UTC 10,449 bytes, 273 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;')
52      .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 + ' &middot; ' + esc(post.category) +
182            (post.content ? ' &middot; ' + 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 &rarr;</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">&larr; Back to all posts</button>' +
247      '<article class="blog-article">' +
248        '<p class="blog-date" style="margin-bottom:8px;">' + dateStr + ' &middot; ' + 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.