jekyll-theme-zer0 1.31.1 → 1.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,9 +79,13 @@ Privacy: Respects Do Not Track and provides opt-out mechanisms
79
79
 
80
80
  {% comment %} Consent gate (privacy.require_consent): until the visitor
81
81
  accepts the "analytics" category in the theme's cookie-consent banner
82
- (localStorage "zer0-cookie-consent"), PostHog starts opted OUT with
83
- memory-only persistence — no cookies, nothing stored. The banner's
84
- applyConsent() flips opt-in/opt-out live once a choice is made. {% endcomment %}
82
+ (localStorage "zer0-cookie-consent"), PostHog is not loaded at all: no
83
+ request to PostHog and no ph_* cookie or localStorage entry. It used to
84
+ load opted-out with memory persistence, which still fetched array.js,
85
+ config.js and surveys.js before any choice was made. Accepting dispatches
86
+ `cookieConsentChanged`; the listener below then boots PostHog on the spot,
87
+ so no reload is needed. Reject keeps it off. Same record and 365-day
88
+ expiry as components/cookie-consent.html. {% endcomment %}
85
89
  window.__phHasAnalyticsConsent = true;
86
90
  if (window.posthogConfig.requireConsent) {
87
91
  window.__phHasAnalyticsConsent = false;
@@ -89,9 +93,10 @@ Privacy: Respects Do Not Track and provides opt-out mechanisms
89
93
  var __phStored = window.localStorage.getItem('zer0-cookie-consent');
90
94
  if (__phStored) {
91
95
  var __phConsent = JSON.parse(__phStored);
92
- window.__phHasAnalyticsConsent = __phConsent && __phConsent.analytics === true;
96
+ window.__phHasAnalyticsConsent = !!(__phConsent && __phConsent.analytics === true &&
97
+ (Date.now() - __phConsent.timestamp) <= 365 * 864e5);
93
98
  }
94
- } catch (e) { /* localStorage unavailable — stay opted out */ }
99
+ } catch (e) { /* localStorage unavailable — stay off */ }
95
100
  }
96
101
 
97
102
  {% comment %} Honor browser privacy signals before loading: Do Not Track
@@ -100,83 +105,102 @@ Privacy: Respects Do Not Track and provides opt-out mechanisms
100
105
  (window.posthogConfig.respectGpc && window.navigator.globalPrivacyControl === true)) {
101
106
  console.log('PostHog: respecting browser privacy signal (DNT/GPC)');
102
107
  } else {
103
- {% comment %} Deferred loading: the stub below is installed immediately, so
104
- posthog.capture()/opt_in_capturing() calls (e.g. from the cookie
105
- banner) queue as before, but the ~130 KB library (array.js, then
106
- config.js/surveys.js) is only fetched after the window `load` event
107
- and the next idle period. It used to be inserted while the page was
108
- still parsing and competed with first paint on mobile. The snippet is
109
- PostHog's official one; the only change is that its insertBefore()
110
- runs through __phInject. {% endcomment %}
111
- window.__phInject = function (insert) {
112
- var run = function () {
113
- if (window.requestIdleCallback) window.requestIdleCallback(insert, { timeout: 3000 });
114
- else setTimeout(insert, 1);
108
+ {% comment %} Everything that loads or configures PostHog runs in __phBoot,
109
+ once: at parse time when consent is not required or was already
110
+ given, otherwise when the visitor accepts analytics. {% endcomment %}
111
+ var __phBoot = function () {
112
+ if (window.__phBooted) return;
113
+ window.__phBooted = true;
114
+ {% comment %} Deferred loading: the stub below is installed immediately, so
115
+ posthog.capture()/opt_in_capturing() calls (e.g. from the cookie
116
+ banner) queue as before, but the ~130 KB library (array.js, then
117
+ config.js/surveys.js) is only fetched after the window `load` event
118
+ and the next idle period. It used to be inserted while the page was
119
+ still parsing and competed with first paint on mobile. The snippet is
120
+ PostHog's official one; the only change is that its insertBefore()
121
+ runs through __phInject. {% endcomment %}
122
+ window.__phInject = function (insert) {
123
+ var run = function () {
124
+ if (window.requestIdleCallback) window.requestIdleCallback(insert, { timeout: 3000 });
125
+ else setTimeout(insert, 1);
126
+ };
127
+ if (document.readyState === 'complete') run();
128
+ else window.addEventListener('load', run, { once: true });
115
129
  };
116
- if (document.readyState === 'complete') run();
117
- else window.addEventListener('load', run, { once: true });
118
- };
119
- {% comment %} PostHog Loading Script {% endcomment %}
120
- !function(t,e){var o,n,p,r;e.__SV||(window.posthog && window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",window.__phInject(function(){(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r)});var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init Rr Mr fi Or Ar ci Tr Cr capture Mi calculateEventProperties Lr register register_once register_for_session unregister unregister_for_session Hr getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey displaySurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Ur jr createPersonProfile zr kr Br opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing get_explicit_consent_status is_capturing clear_opt_in_out_capturing Dr debug M Nr getPageViewId captureTraceFeedback captureTraceMetric $r".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
130
+ {% comment %} PostHog Loading Script {% endcomment %}
131
+ !function(t,e){var o,n,p,r;e.__SV||(window.posthog && window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",window.__phInject(function(){(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r)});var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init Rr Mr fi Or Ar ci Tr Cr capture Mi calculateEventProperties Lr register register_once register_for_session unregister unregister_for_session Hr getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey displaySurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Ur jr createPersonProfile zr kr Br opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing get_explicit_consent_status is_capturing clear_opt_in_out_capturing Dr debug M Nr getPageViewId captureTraceFeedback captureTraceMetric $r".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
121
132
 
122
- {% comment %} Initialize PostHog with configuration {% endcomment %}
123
- posthog.init(window.posthogConfig.apiKey, {
124
- api_host: window.posthogConfig.apiHost,
125
- person_profiles: window.posthogConfig.personProfiles,
126
- autocapture: window.posthogConfig.autocapture,
127
- capture_pageview: window.posthogConfig.capturePageview,
128
- capture_pageleave: window.posthogConfig.capturePageleave,
129
- disable_cookie: window.posthogConfig.disableCookie,
130
- cross_subdomain_cookie: window.posthogConfig.crossSubdomainCookie,
131
- secure_cookie: window.posthogConfig.secureCookie,
132
- persistence: window.__phHasAnalyticsConsent ? window.posthogConfig.persistence : 'memory',
133
- opt_out_capturing_by_default: !window.__phHasAnalyticsConsent,
134
- session_recording: window.posthogConfig.sessionRecording,
135
- {% if site.posthog.privacy.ip_anonymization %}
136
- ip: false,
137
- {% endif %}
138
- loaded: function(posthog) {
139
- {% comment %} PostHog loaded successfully {% endcomment %}
140
- console.log('PostHog analytics loaded successfully');
133
+ {% comment %} Initialize PostHog with configuration {% endcomment %}
134
+ posthog.init(window.posthogConfig.apiKey, {
135
+ api_host: window.posthogConfig.apiHost,
136
+ person_profiles: window.posthogConfig.personProfiles,
137
+ autocapture: window.posthogConfig.autocapture,
138
+ capture_pageview: window.posthogConfig.capturePageview,
139
+ capture_pageleave: window.posthogConfig.capturePageleave,
140
+ disable_cookie: window.posthogConfig.disableCookie,
141
+ cross_subdomain_cookie: window.posthogConfig.crossSubdomainCookie,
142
+ secure_cookie: window.posthogConfig.secureCookie,
143
+ persistence: window.__phHasAnalyticsConsent ? window.posthogConfig.persistence : 'memory',
144
+ opt_out_capturing_by_default: !window.__phHasAnalyticsConsent,
145
+ session_recording: window.posthogConfig.sessionRecording,
146
+ {% if site.posthog.privacy.ip_anonymization %}
147
+ ip: false,
148
+ {% endif %}
149
+ loaded: function(posthog) {
150
+ {% comment %} PostHog loaded successfully {% endcomment %}
151
+ console.log('PostHog analytics loaded successfully');
141
152
 
142
- {% comment %} privacy.geoip_disable: stop PostHog from resolving
143
- IP to location on every event {% endcomment %}
144
- if (window.posthogConfig.geoipDisable) {
145
- posthog.register({ '$geoip_disable': true });
146
- }
153
+ {% comment %} privacy.geoip_disable: stop PostHog from resolving
154
+ IP to location on every event {% endcomment %}
155
+ if (window.posthogConfig.geoipDisable) {
156
+ posthog.register({ '$geoip_disable': true });
157
+ }
147
158
 
148
- {% comment %} Track Jekyll-specific page properties. Free-text values go
149
- through `jsonify`: they used to be pasted between single quotes, so
150
- an apostrophe in a title or description ("you're") was a
151
- SyntaxError that stopped PostHog loading at all. {% endcomment %}
152
- posthog.register({
153
- 'site_title': {{ site.title | jsonify }},
154
- 'site_description': {{ site.description | strip_newlines | strip | jsonify }},
155
- 'jekyll_version': '{{ jekyll.version }}',
156
- 'theme': {{ site.remote_theme | default: site.theme | jsonify }},
157
- 'page_layout': {{ page.layout | jsonify }},
158
- 'page_collection': {{ page.collection | jsonify }},
159
- {% if page.categories %}
160
- 'page_categories': {{ page.categories | jsonify }},
161
- {% endif %}
162
- {% if page.tags %}
163
- 'page_tags': {{ page.tags | jsonify }},
164
- {% endif %}
165
- 'page_url': window.location.pathname,
166
- 'page_title': {{ page.title | strip_newlines | strip | jsonify }},
167
- 'page_author': {{ page.author | default: site.author.name | jsonify }},
168
- {% if page.date %}
169
- 'page_date': '{{ page.date | date: "%Y-%m-%d" }}',
170
- {% endif %}
171
- 'user_agent': navigator.userAgent,
172
- 'screen_resolution': screen.width + 'x' + screen.height,
173
- 'viewport_size': window.innerWidth + 'x' + window.innerHeight
174
- });
159
+ {% comment %} Track Jekyll-specific page properties. Free-text values go
160
+ through `jsonify`: they used to be pasted between single quotes, so
161
+ an apostrophe in a title or description ("you're") was a
162
+ SyntaxError that stopped PostHog loading at all. {% endcomment %}
163
+ posthog.register({
164
+ 'site_title': {{ site.title | jsonify }},
165
+ 'site_description': {{ site.description | strip_newlines | strip | jsonify }},
166
+ 'jekyll_version': '{{ jekyll.version }}',
167
+ 'theme': {{ site.remote_theme | default: site.theme | jsonify }},
168
+ 'page_layout': {{ page.layout | jsonify }},
169
+ 'page_collection': {{ page.collection | jsonify }},
170
+ {% if page.categories %}
171
+ 'page_categories': {{ page.categories | jsonify }},
172
+ {% endif %}
173
+ {% if page.tags %}
174
+ 'page_tags': {{ page.tags | jsonify }},
175
+ {% endif %}
176
+ 'page_url': window.location.pathname,
177
+ 'page_title': {{ page.title | strip_newlines | strip | jsonify }},
178
+ 'page_author': {{ page.author | default: site.author.name | jsonify }},
179
+ {% if page.date %}
180
+ 'page_date': '{{ page.date | date: "%Y-%m-%d" }}',
181
+ {% endif %}
182
+ 'user_agent': navigator.userAgent,
183
+ 'screen_resolution': screen.width + 'x' + screen.height,
184
+ 'viewport_size': window.innerWidth + 'x' + window.innerHeight
185
+ });
175
186
 
176
- {% comment %} Initialize custom event tracking {% endcomment %}
177
- initializeCustomTracking();
178
- }
179
- });
187
+ {% comment %} Initialize custom event tracking {% endcomment %}
188
+ initializeCustomTracking();
189
+ }
190
+ });
191
+ };
192
+
193
+ if (window.__phHasAnalyticsConsent) {
194
+ __phBoot();
195
+ } else {
196
+ document.addEventListener('cookieConsentChanged', function (e) {
197
+ if (!(e.detail && e.detail.analytics === true) || window.__phBooted) return;
198
+ window.__phHasAnalyticsConsent = true;
199
+ __phBoot();
200
+ {% comment %} Clears an opt-out a previous Reject may have stored. {% endcomment %}
201
+ window.posthog.opt_in_capturing();
202
+ });
203
+ }
180
204
  }
181
205
 
182
206
  {% comment %} Custom Event Tracking Functions {% endcomment %}
@@ -185,7 +185,7 @@
185
185
  "authMode": {{ ai_auth_mode | jsonify }},
186
186
  "apiKey": {{ site.ai_chat.api_key | jsonify }},
187
187
  "anthropicVersion": {{ site.ai_chat.anthropic_version | default: "2023-06-01" | jsonify }},
188
- "model": {{ site.ai_chat.model | default: "claude-opus-4-8" | jsonify }},
188
+ "model": {{ site.ai_chat.model | default: "claude-opus-5-5" | jsonify }},
189
189
  "maxTokens": {{ site.ai_chat.max_tokens | default: 1024 }},
190
190
  "strictContext": {{ site.ai_chat.strict_context | default: true | jsonify }},
191
191
  "outOfScopeMessage": {{ site.ai_chat.out_of_scope_message | default: "I can only answer from the content on this page." | jsonify }},
@@ -249,6 +249,33 @@ Configuration: Uses site.posthog settings from _config.yml
249
249
  }));
250
250
  }
251
251
 
252
+ // Reserve the banner's height while it is up. Publishes it as
253
+ // --zer0-cookie-banner-inset on <html> plus the zer0-cookie-banner-open
254
+ // class; _sass/components/_cookie-banner.scss turns that into body
255
+ // padding, scroll-padding-bottom (keyboard focus scrolls clear of the
256
+ // banner) and a lift for the floating buttons, which used to sit under
257
+ // it. A ResizeObserver keeps it right when the banner reflows (94px on
258
+ // phones, 48px from md up). Set as soon as the banner is unhidden, before
259
+ // the 1s slide-in, so the page does not shift when it appears.
260
+ let insetObserver = null;
261
+ function setBannerInset(banner) {
262
+ const root = document.documentElement;
263
+ const apply = () => {
264
+ root.style.setProperty('--zer0-cookie-banner-inset', Math.ceil(banner.offsetHeight) + 'px');
265
+ root.classList.add('zer0-cookie-banner-open');
266
+ };
267
+ apply();
268
+ if (!insetObserver && 'ResizeObserver' in window) {
269
+ insetObserver = new ResizeObserver(apply);
270
+ insetObserver.observe(banner);
271
+ }
272
+ }
273
+ function clearBannerInset() {
274
+ if (insetObserver) { insetObserver.disconnect(); insetObserver = null; }
275
+ document.documentElement.classList.remove('zer0-cookie-banner-open');
276
+ document.documentElement.style.removeProperty('--zer0-cookie-banner-inset');
277
+ }
278
+
252
279
  // Show consent banner
253
280
  function showConsentBanner() {
254
281
  const banner = document.getElementById('cookieConsent');
@@ -257,6 +284,7 @@ Configuration: Uses site.posthog settings from _config.yml
257
284
  // Reveal the element (CSS keeps it translated off-screen + opacity:0).
258
285
  banner.hidden = false;
259
286
  banner.classList.remove('cookie-banner-visible');
287
+ setBannerInset(banner);
260
288
 
261
289
  setTimeout(() => {
262
290
  // Force reflow so the next class change actually animates.
@@ -277,6 +305,7 @@ Configuration: Uses site.posthog settings from _config.yml
277
305
  if (done) return;
278
306
  done = true;
279
307
  banner.hidden = true;
308
+ clearBannerInset();
280
309
  banner.removeEventListener('transitionend', onEnd);
281
310
  };
282
311
  const onEnd = (e) => {
@@ -280,7 +280,7 @@
280
280
  "ai": {
281
281
  "available": {{ pf_ai_available | jsonify }},
282
282
  "auto": {{ site.page_feedback.ai.auto | default: false | jsonify }},
283
- "model": {{ site.page_feedback.ai.model | default: "claude-opus-4-8" | jsonify }},
283
+ "model": {{ site.page_feedback.ai.model | default: "claude-opus-5-5" | jsonify }},
284
284
  "maxTokens": {{ site.page_feedback.ai.max_tokens | default: 1024 }}
285
285
  }
286
286
  }
@@ -111,6 +111,20 @@
111
111
  {% endif %}
112
112
  {% endif %}
113
113
 
114
+ {% comment %} Tags index existence gate (issue #537) — the same policy as
115
+ news.html / section.html: an explicit site.tags_page is trusted; otherwise
116
+ link only when a page at /tags/ is actually in the build. A remote-theme
117
+ consumer without the tags index gets plain badges instead of 404 links on
118
+ every tagged page. {% endcomment %}
119
+ {% assign intro_tags_base = site.tags_page | default: "/tags/" %}
120
+ {% assign intro_tags_linkable = false %}
121
+ {% if site.tags_page %}
122
+ {% assign intro_tags_linkable = true %}
123
+ {% else %}
124
+ {% assign intro_tags_index = site.html_pages | where: "url", intro_tags_base | first %}
125
+ {% if intro_tags_index %}{% assign intro_tags_linkable = true %}{% endif %}
126
+ {% endif %}
127
+
114
128
  {% comment %} Category or collection badge (most specific category wins) {% endcomment %}
115
129
  {% assign intro_category = nil %}
116
130
  {% assign intro_category_url = nil %}
@@ -129,7 +143,9 @@
129
143
  {% capture intro_category_url %}{{ site.baseurl }}/docs/{{ intro_category_slug }}/{% endcapture %}
130
144
  {% else %}
131
145
  {% assign intro_category_slug = intro_category | append: '' | slugify %}
132
- {% capture intro_category_url %}{{ '/tags/' | relative_url }}#{{ intro_category_slug }}{% endcapture %}
146
+ {% if intro_tags_linkable %}
147
+ {% capture intro_category_url %}{{ intro_tags_base | relative_url }}#{{ intro_category_slug }}{% endcapture %}
148
+ {% endif %}
133
149
  {% endif %}
134
150
  {% elsif page.collection and page.collection != "pages" %}
135
151
  {% assign intro_category = page.collection %}
@@ -217,7 +233,11 @@
217
233
  <span class="bd-intro-tag-list">
218
234
  {% for tag in page.tags limit: 5 %}
219
235
  {% assign intro_tag_label = tag | append: '' %}
220
- <a class="bd-intro-badge bd-intro-badge--tag" href="{{ '/tags/' | relative_url }}#{{ intro_tag_label | slugify }}">{{ intro_tag_label }}</a>
236
+ {% if intro_tags_linkable %}
237
+ <a class="bd-intro-badge bd-intro-badge--tag" href="{{ intro_tags_base | relative_url }}#{{ intro_tag_label | slugify }}">{{ intro_tag_label }}</a>
238
+ {% else %}
239
+ <span class="bd-intro-badge bd-intro-badge--tag">{{ intro_tag_label }}</span>
240
+ {% endif %}
221
241
  {% endfor %}
222
242
  {% if page.tags.size > 5 %}
223
243
  <span class="bd-intro-badge bd-intro-badge--tag bd-intro-badge--more">+{{ page.tags.size | minus: 5 }}</span>
@@ -211,11 +211,20 @@
211
211
  <tbody id="sitemapTableBody">
212
212
  {% assign total_pages = 0 %}
213
213
  {% assign collections_data = '' %}
214
+ {% comment %}
215
+ Only the first page of rows is rendered as HTML, which is enough
216
+ for no-JS visitors and first paint. Every row (these included) is
217
+ also in the compact #sitemapData JSON below, and the script builds
218
+ the other rows from it when they are needed. With 431 rows on
219
+ it-journey.dev, rendering them all made /search/ 2.2 MB of HTML.
220
+ {% endcomment %}
221
+ {% assign sitemap_server_rows = 50 %}
214
222
 
215
223
  {% comment %}Loose Jekyll pages (site.pages in Liquid is the "pages" collection, not these files){% endcomment %}
216
224
  {% for page in site.html_pages %}
217
225
  {% unless page.sitemap == false %}
218
226
  {% assign total_pages = total_pages | plus: 1 %}
227
+ {% if total_pages <= sitemap_server_rows %}
219
228
  <tr data-collection="pages"
220
229
  data-title="{{ page.title | default: page.name | escape }}"
221
230
  data-description="{{ page.description | escape }}"
@@ -281,6 +290,7 @@
281
290
  </div>
282
291
  </td>
283
292
  </tr>
293
+ {% endif %}
284
294
  {% endunless %}
285
295
  {% endfor %}
286
296
 
@@ -289,6 +299,7 @@
289
299
  {% for item in collection.docs %}
290
300
  {% unless item.sitemap == false %}
291
301
  {% assign total_pages = total_pages | plus: 1 %}
302
+ {% if total_pages <= sitemap_server_rows %}
292
303
  <tr data-collection="{{ collection.label }}"
293
304
  data-title="{{ item.title | escape }}"
294
305
  data-description="{{ item.description | escape }}"
@@ -354,6 +365,7 @@
354
365
  </div>
355
366
  </td>
356
367
  </tr>
368
+ {% endif %}
357
369
  {% endunless %}
358
370
  {% endfor %}
359
371
  {% endfor %}
@@ -365,6 +377,43 @@
365
377
  </div>
366
378
  </div>
367
379
 
380
+ {% comment %}
381
+ Row data for the whole index, one compact entry per row in the same order
382
+ as the loops above. Keys: c collection, cl collection label, t title,
383
+ d description, x description cell text (description or excerpt),
384
+ cat/tag arrays, dt ISO date, dl display date, u url. "</" is escaped so
385
+ a title can't close the script element early.
386
+ {% endcomment %}
387
+ {%- capture sitemap_json -%}
388
+ [
389
+ {%- assign sm_first = true -%}
390
+ {%- for page in site.html_pages -%}
391
+ {%- unless page.sitemap == false -%}
392
+ {%- unless sm_first %},{% endunless -%}{%- assign sm_first = false -%}
393
+ {"c":"pages","cl":"Pages","t":{{ page.title | default: page.name | jsonify }},"d":{{ page.description | default: "" | jsonify }},"x":{{ page.description | default: "No description available" | jsonify }},"cat":{{ page.categories | default: empty | jsonify }},"tag":{{ page.tags | default: empty | jsonify }},"dt":{{ page.lastmod | default: page.date | date: '%Y-%m-%d' | jsonify }},"dl":{{ page.lastmod | default: page.date | date: "%b %d, %Y" | default: "Unknown" | jsonify }},"u":{{ page.url | relative_url | jsonify }}}
394
+ {%- endunless -%}
395
+ {%- endfor -%}
396
+ {%- for collection in site.collections -%}
397
+ {%- assign sm_label = collection.label | capitalize -%}
398
+ {%- for item in collection.docs -%}
399
+ {%- unless item.sitemap == false -%}
400
+ {%- unless sm_first %},{% endunless -%}{%- assign sm_first = false -%}
401
+ {"c":{{ collection.label | jsonify }},"cl":{{ sm_label | jsonify }},"t":{{ item.title | default: "" | jsonify }},"d":{{ item.description | default: "" | jsonify }},"x":{{ item.description | default: item.excerpt | strip_html | default: "No description available" | jsonify }},"cat":{{ item.categories | default: empty | jsonify }},"tag":{{ item.tags | default: empty | jsonify }},"dt":{{ item.lastmod | default: item.date | date: '%Y-%m-%d' | jsonify }},"dl":{{ item.lastmod | default: item.date | date: "%b %d, %Y" | default: "Unknown" | jsonify }},"u":{{ item.url | relative_url | jsonify }}}
402
+ {%- endunless -%}
403
+ {%- endfor -%}
404
+ {%- endfor -%}
405
+ ]
406
+ {%- endcapture -%}
407
+ <script type="application/json" id="sitemapData" data-site-url="{{ site.url }}" data-server-rows="{{ sitemap_server_rows }}">{{ sitemap_json | replace: "</", "<\/" }}</script>
408
+ {%- if total_pages > sitemap_server_rows %}
409
+ <noscript>
410
+ <p class="text-center text-muted mt-3">
411
+ Showing the first {{ sitemap_server_rows }} of {{ total_pages }} pages. Turn on JavaScript to search and page through all of them
412
+ {%- if site.plugins contains 'jekyll-sitemap' or site.gems contains 'jekyll-sitemap' %}, or browse the full list in the <a href="{{ '/sitemap.xml' | relative_url }}">XML sitemap</a>{% endif %}.
413
+ </p>
414
+ </noscript>
415
+ {%- endif %}
416
+
368
417
  {% comment %} ================================ {% endcomment %}
369
418
  {% comment %} CARDS VIEW CONTAINER {% endcomment %}
370
419
  {% comment %} ================================ {% endcomment %}
@@ -474,13 +523,17 @@ th:hover .sort-icon {
474
523
  * Enhanced Sitemap Functionality
475
524
  * Features: Advanced search, sorting, filtering, statistics, and card/table views
476
525
  *
477
- * Paginated: every row is still rendered server-side (so the index works
478
- * without JS and with ?q= from the search modal's "View all results"), but
479
- * only PAGE_SIZE matching rows stay in the document; the rest are kept in
480
- * memory and a "Show more" button appends the next page. Cards are built on
481
- * the first switch to card view, one page at a time. On it-journey.dev the
482
- * index (431 rows plus 431 eagerly built cards) put ~19,900 elements on
483
- * /search/ before the visitor typed anything.
526
+ * Paginated: only PAGE_SIZE matching rows are in the document at a time,
527
+ * and a "Show more" button appends the next page. Cards are built on the
528
+ * first switch to card view, one page at a time.
529
+ *
530
+ * Only the first page of rows is rendered server-side, which is enough for
531
+ * no-JS visitors and first paint. Every row is described by a compact entry
532
+ * in #sitemapData, and this script builds the other <tr> elements from it
533
+ * the first time they are shown or sorted. Search and filters run on the
534
+ * entries, so ?q= from the search modal's "View all results" still covers
535
+ * the whole index. On it-journey.dev the full server-rendered table (431
536
+ * rows) made /search/ about 2.2 MB of HTML.
484
537
  */
485
538
  class SitemapManager {
486
539
  static PAGE_SIZE = 50;
@@ -503,7 +556,22 @@ class SitemapManager {
503
556
  this.showMoreWrap = document.getElementById('sitemapShowMore');
504
557
  this.showMoreBtn = document.getElementById('sitemapShowMoreBtn');
505
558
 
506
- this.rows = Array.from(this.tbody.querySelectorAll('tr'));
559
+ // One entry per index row. `dataset` mirrors the <tr data-*> attributes
560
+ // so search, filters, stats and cards read entries and rows alike.
561
+ // The first rows reuse the server-rendered <tr>; the rest are built
562
+ // on demand by rowFor().
563
+ const serverRows = Array.from(this.tbody.querySelectorAll('tr'));
564
+ const dataEl = document.getElementById('sitemapData');
565
+ let data = [];
566
+ try {
567
+ data = dataEl ? JSON.parse(dataEl.textContent) : [];
568
+ } catch (error) {
569
+ console.warn('Sitemap: could not read #sitemapData', error);
570
+ }
571
+ this.siteUrl = dataEl ? (dataEl.dataset.siteUrl || '') : '';
572
+ this.rows = data.length
573
+ ? data.map((e, i) => this.entryFrom(e, serverRows[i] || null))
574
+ : serverRows.map(tr => ({ dataset: tr.dataset, row: tr }));
507
575
  this.visibleCount = document.getElementById('visibleCount');
508
576
  this.totalCount = document.getElementById('totalCount');
509
577
 
@@ -517,6 +585,91 @@ class SitemapManager {
517
585
  this.init();
518
586
  }
519
587
 
588
+ // Normalise one #sitemapData entry; `row` is its server-rendered <tr>, if any.
589
+ entryFrom(e, row) {
590
+ const list = v => Array.isArray(v) ? v.map(String) : (v ? [String(v)] : []);
591
+ const categories = list(e.cat);
592
+ const tags = list(e.tag);
593
+ return {
594
+ row,
595
+ raw: e,
596
+ categoriesList: categories,
597
+ tagsList: tags,
598
+ dataset: {
599
+ collection: e.c || '',
600
+ title: e.t || '',
601
+ description: e.d || '',
602
+ categories: categories.join(', '),
603
+ tags: tags.join(', '),
604
+ date: e.dt || '',
605
+ url: e.u || ''
606
+ }
607
+ };
608
+ }
609
+
610
+ // Liquid's `truncate`: at most n characters including the "..." suffix.
611
+ static truncate(text, n) {
612
+ const chars = Array.from(text || '');
613
+ return chars.length > n ? chars.slice(0, n - 3).join('') + '...' : chars.join('');
614
+ }
615
+
616
+ // The <tr> for an entry: the server-rendered one, or built once with
617
+ // the same markup the include renders (text set via textContent).
618
+ rowFor(entry) {
619
+ if (entry.row) return entry.row;
620
+ const e = entry.raw;
621
+ const d = entry.dataset;
622
+ const tr = document.createElement('tr');
623
+ Object.entries(d).forEach(([k, v]) => { tr.dataset[k] = v; });
624
+ const isPage = d.collection === 'pages';
625
+ tr.innerHTML = `
626
+ <td><span class="badge ${isPage ? 'text-bg-primary' : 'text-bg-info'} rounded-pill"></span></td>
627
+ <td><a class="text-decoration-none fw-medium"></a></td>
628
+ <td class="d-none d-md-table-cell"><small class="text-muted"></small></td>
629
+ <td><small class="text-muted"></small></td>
630
+ <td class="d-none d-lg-table-cell"></td>
631
+ <td class="d-none d-xl-table-cell"></td>
632
+ <td class="text-center">
633
+ <div class="btn-group btn-group-sm" role="group">
634
+ <a class="btn btn-outline-primary btn-sm" title="View page"><i class="bi bi-eye"></i></a>
635
+ <button class="btn btn-outline-secondary btn-sm copy-url" title="Copy URL"><i class="bi bi-clipboard"></i></button>
636
+ </div>
637
+ </td>`;
638
+ const cells = tr.children;
639
+ cells[0].firstElementChild.textContent = e.cl || d.collection;
640
+ const link = cells[1].firstElementChild;
641
+ link.href = d.url;
642
+ link.textContent = SitemapManager.truncate(d.title, 50);
643
+ if (d.description) {
644
+ const small = document.createElement('small');
645
+ small.className = 'text-muted d-md-none';
646
+ small.textContent = SitemapManager.truncate(d.description, 80);
647
+ cells[1].append(document.createElement('br'), small);
648
+ }
649
+ cells[2].firstElementChild.textContent = SitemapManager.truncate(e.x || 'No description available', 120);
650
+ cells[3].firstElementChild.textContent = e.dl || 'Unknown';
651
+ const chips = (cell, items, max, cls, prefix) => {
652
+ items.slice(0, max).forEach(item => {
653
+ const b = document.createElement('span');
654
+ b.className = `badge ${cls} me-1`;
655
+ b.textContent = prefix + item;
656
+ cell.append(b, ' ');
657
+ });
658
+ if (items.length > max) {
659
+ const more = document.createElement('span');
660
+ more.className = 'badge text-body-secondary border';
661
+ more.textContent = `+${items.length - max}`;
662
+ cell.append(more);
663
+ }
664
+ };
665
+ chips(cells[4], entry.categoriesList, 2, 'bg-secondary', '');
666
+ chips(cells[5], entry.tagsList, 3, 'bg-outline-primary', '#');
667
+ cells[6].querySelector('a').href = d.url;
668
+ cells[6].querySelector('.copy-url').dataset.url = this.siteUrl + d.url;
669
+ entry.row = tr;
670
+ return tr;
671
+ }
672
+
520
673
  init() {
521
674
  this.setupCollectionFilter();
522
675
  this.updateStatistics();
@@ -651,11 +804,11 @@ class SitemapManager {
651
804
  header.addEventListener('click', () => this.sortTable(header));
652
805
  });
653
806
 
654
- // Copy URL functionality (table rows; cards bind their own)
655
- this.rows.forEach(row => {
656
- row.querySelectorAll('.copy-url').forEach(btn => {
657
- btn.addEventListener('click', (e) => this.copyUrl(e));
658
- });
807
+ // Copy URL functionality (table rows, delegated so rows built later
808
+ // work too; cards bind their own)
809
+ this.tbody.addEventListener('click', (e) => {
810
+ const btn = e.target.closest('.copy-url');
811
+ if (btn && this.tbody.contains(btn)) this.copyUrl({ currentTarget: btn });
659
812
  });
660
813
 
661
814
  // Keyboard shortcuts
@@ -729,7 +882,7 @@ class SitemapManager {
729
882
  render() {
730
883
  const shown = this.matches.slice(0, this.limit);
731
884
  if (this.currentView === 'table') {
732
- this.tbody.replaceChildren(...shown);
885
+ this.tbody.replaceChildren(...shown.map(entry => this.rowFor(entry)));
733
886
  } else {
734
887
  this.cardsContainer.replaceChildren(...shown.map(row => this.cardFor(row)));
735
888
  }
@@ -760,10 +913,13 @@ class SitemapManager {
760
913
  const icon = header.querySelector('.sort-icon');
761
914
  icon.className = `sort-icon bi bi-arrow-${newOrder === 'asc' ? 'up' : 'down'} ms-1 active`;
762
915
 
763
- // Sort every row (not just the rendered page), then re-render
916
+ // Sort every row (not just the rendered page), then re-render. Keys
917
+ // come from each row's cell text, so rows not built yet are built
918
+ // here (once, on the visitor's click).
919
+ const keys = new Map(this.rows.map(entry => [entry, this.rowFor(entry).children[headerIndex].textContent.trim()]));
764
920
  this.rows.sort((a, b) => {
765
- let aValue = a.children[headerIndex].textContent.trim();
766
- let bValue = b.children[headerIndex].textContent.trim();
921
+ let aValue = keys.get(a);
922
+ let bValue = keys.get(b);
767
923
 
768
924
  if (type === 'date') {
769
925
  aValue = aValue === "Unknown" ? new Date(0) : new Date(aValue);