jekyll-theme-zer0 1.28.0 → 1.29.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +430 -13
- data/_data/backlog.yml +356 -2
- data/_data/consumers.yml +109 -4
- data/_data/features.yml +157 -0
- data/_data/i18n/fr.yml +12 -7
- data/_data/i18n/manifest.yml +39 -12
- data/_data/ingredient_densities.yml +122 -0
- data/_data/navigation/main.yml +16 -0
- data/_data/recipe_courses.yml +64 -0
- data/_data/theme-manifest.yml +68 -40
- data/_data/ui-text.yml +8 -0
- data/_includes/README.md +15 -1
- data/_includes/components/background-customizer.html +2 -2
- data/_includes/components/cookie-consent.html +4 -4
- data/_includes/components/info-section.html +8 -3
- data/_includes/components/language-toggle.html +168 -21
- data/_includes/components/page-views-init.html +55 -0
- data/_includes/components/page-views.html +33 -0
- data/_includes/components/recipe-card.html +67 -0
- data/_includes/components/recipe-duration.html +50 -0
- data/_includes/components/recipe-grams.html +58 -0
- data/_includes/components/recipe-index.html +96 -0
- data/_includes/components/recipe-ingredients.html +90 -0
- data/_includes/components/recipe-meta.html +96 -0
- data/_includes/components/recipe-nutrition.html +57 -0
- data/_includes/components/recipe-qty.html +73 -0
- data/_includes/components/recipe-ratio.html +151 -0
- data/_includes/components/recipe-scaler.html +73 -0
- data/_includes/components/recipe-steps.html +86 -0
- data/_includes/components/recipe-temp.html +45 -0
- data/_includes/components/search-modal.html +2 -2
- data/_includes/components/shortcuts-modal.html +3 -0
- data/_includes/components/theme-info.html +8 -1
- data/_includes/content/jsonld-software.html +22 -3
- data/_includes/core/footer.html +18 -16
- data/_includes/core/head.html +12 -0
- data/_includes/core/header.html +7 -4
- data/_includes/navigation/section-sidebar.html +93 -11
- data/_includes/navigation/sidebar-left.html +2 -1
- data/_includes/navigation/sidebar-right.html +2 -1
- data/_includes/setup/wizard.html +173 -86
- data/_includes/stats/stats-metrics.html +2 -2
- data/_layouts/README.md +2 -0
- data/_layouts/admin.html +2 -2
- data/_layouts/article.html +5 -0
- data/_layouts/cookbook.html +88 -0
- data/_layouts/default.html +4 -4
- data/_layouts/recipe.html +274 -0
- data/_layouts/root.html +22 -3
- data/_layouts/section.html +39 -10
- data/_sass/components/_callout.scss +1 -1
- data/_sass/components/_footer.scss +37 -1
- data/_sass/components/_page-views.scss +36 -0
- data/_sass/components/_recipe.scss +506 -0
- data/_sass/components/_setup-wizard.scss +235 -0
- data/_sass/components/_ui-enhancements.scss +6 -6
- data/_sass/core/_navbar.scss +250 -15
- data/_sass/layouts/_landing.scss +2 -2
- data/_sass/tokens/_color.scss +6 -0
- data/_sass/tokens/_index.scss +2 -0
- data/_sass/tokens/_radius.scss +21 -0
- data/_sass/tokens/_typography.scss +4 -0
- data/_sass/utilities/_focus.scss +14 -0
- data/assets/css/main.scss +3 -0
- data/assets/js/modules/navigation/navbar.js +55 -0
- data/assets/js/page-views.js +372 -0
- data/assets/js/recipe-scaler.js +501 -0
- data/assets/js/search-modal.js +10 -0
- data/assets/js/setup-wizard.js +322 -20
- data/scripts/README.md +29 -0
- data/scripts/bin/audit-consumer +39 -7
- data/scripts/bin/giscus-discussions +213 -14
- data/scripts/bin/manifest +35 -12
- data/scripts/ci/agent_review_result.py +164 -0
- data/scripts/ci/test_agent_review_result.py +172 -0
- data/scripts/design-system-check.rb +170 -0
- data/scripts/lib/audit.sh +42 -2
- data/scripts/lint-liquid-raw.rb +137 -0
- data/scripts/test/lib/run_tests.sh +2 -1
- data/scripts/test/lib/test_agent_review_result.sh +27 -0
- data/scripts/translate.rb +71 -15
- metadata +31 -2
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
--zer0-text-h5: 1.125rem;
|
|
29
29
|
--zer0-text-h6: 1rem;
|
|
30
30
|
|
|
31
|
+
// Display sizes (Bootstrap .display-* — hero titles) ---------------------
|
|
32
|
+
--zer0-text-display-4: clamp(2.5rem, 6vw, 3.5rem);
|
|
33
|
+
--zer0-text-display-5: clamp(2rem, 5vw, 3rem);
|
|
34
|
+
|
|
31
35
|
// Line heights -----------------------------------------------------------
|
|
32
36
|
--zer0-leading-tight: 1.2;
|
|
33
37
|
--zer0-leading-normal: 1.55;
|
data/_sass/utilities/_focus.scss
CHANGED
|
@@ -19,6 +19,20 @@ summary:focus-visible {
|
|
|
19
19
|
border-radius: 0.125rem;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Skip-link target — <main id="main-content" tabindex="-1"> in _layouts/root.html.
|
|
23
|
+
// The -1 lets the skip link move real keyboard focus into the content (see the
|
|
24
|
+
// comment there), but it also makes the container paintable by the UA's default
|
|
25
|
+
// focus outline. Suppress it on the container ONLY: this rule cannot match a
|
|
26
|
+
// descendant, so links, buttons and headings inside the content keep their rings
|
|
27
|
+
// — including the :focus-visible ring defined at the top of this file.
|
|
28
|
+
//
|
|
29
|
+
// Safe here specifically because the element is tabindex="-1": it is never
|
|
30
|
+
// reachable by Tab, so no keyboard user can land on it and be left without an
|
|
31
|
+
// indicator. Do not generalise this selector to focusable elements.
|
|
32
|
+
#main-content:focus {
|
|
33
|
+
outline: none;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
// Skip-to-content link — visually hidden until focused
|
|
23
37
|
.zer0-skip-link {
|
|
24
38
|
position: absolute;
|
data/assets/css/main.scss
CHANGED
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
@import "components/content-tables";
|
|
41
41
|
@import "components/search-modal";
|
|
42
42
|
@import "components/page-feedback";
|
|
43
|
+
@import "components/page-views";
|
|
43
44
|
@import "components/book";
|
|
45
|
+
@import "components/recipe";
|
|
46
|
+
@import "components/setup-wizard";
|
|
44
47
|
|
|
45
48
|
// 6. Layout partials
|
|
46
49
|
@import "layouts/landing";
|
|
@@ -55,6 +55,7 @@ export class Navbar {
|
|
|
55
55
|
this._setupMobileDropdowns();
|
|
56
56
|
this._setupOutsideClickClose();
|
|
57
57
|
this._setupOffcanvasReset();
|
|
58
|
+
this._setupSettingsExclusivity();
|
|
58
59
|
this._setupNavTooltips();
|
|
59
60
|
this._setupDropdownHoverDelay();
|
|
60
61
|
this._setupFocusTrap();
|
|
@@ -224,6 +225,60 @@ export class Navbar {
|
|
|
224
225
|
});
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Mobile: opening Settings from inside a nav panel must not leave the two
|
|
230
|
+
* stacked on top of each other.
|
|
231
|
+
*
|
|
232
|
+
* #bdNavbar (and the optional unified drawer) and #info-section are both
|
|
233
|
+
* `offcanvas-end` at Bootstrap's $zindex-offcanvas. #bdNavbar additionally
|
|
234
|
+
* lives inside the fixed header, which _navbar.scss lifts above the
|
|
235
|
+
* offcanvas backdrop while the menu is open — so a tap on the menu's own
|
|
236
|
+
* "Settings" item used to open Settings *behind* the still-open menu, with
|
|
237
|
+
* nothing on screen to show it had worked.
|
|
238
|
+
*
|
|
239
|
+
* Intercept the Bootstrap `show` event (capture phase, before Bootstrap's
|
|
240
|
+
* own handler runs) and defer it until the host panel has finished sliding
|
|
241
|
+
* out. Doing it on the event rather than on the trigger keeps the plain
|
|
242
|
+
* data-api working for every other entry point — footer, keyboard, a
|
|
243
|
+
* consumer's own button — and leaves the markup functional if this module
|
|
244
|
+
* never loads.
|
|
245
|
+
*/
|
|
246
|
+
_setupSettingsExclusivity() {
|
|
247
|
+
const settingsEl = document.getElementById('info-section');
|
|
248
|
+
if (!settingsEl) return;
|
|
249
|
+
|
|
250
|
+
const hostEls = ['bdNavbar', 'zer0UnifiedDrawer']
|
|
251
|
+
.map((id) => document.getElementById(id))
|
|
252
|
+
.filter(Boolean);
|
|
253
|
+
if (!hostEls.length) return;
|
|
254
|
+
|
|
255
|
+
this._on(
|
|
256
|
+
settingsEl,
|
|
257
|
+
'show.bs.offcanvas',
|
|
258
|
+
(event) => {
|
|
259
|
+
const Offcanvas = window.bootstrap?.Offcanvas;
|
|
260
|
+
if (!Offcanvas) return;
|
|
261
|
+
const openHost = hostEls.find((el) => el.classList.contains('show'));
|
|
262
|
+
if (!openHost) return;
|
|
263
|
+
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
event.stopImmediatePropagation();
|
|
266
|
+
|
|
267
|
+
const hostInstance = Offcanvas.getInstance(openHost);
|
|
268
|
+
const show = () => Offcanvas.getOrCreateInstance(settingsEl).show();
|
|
269
|
+
if (!hostInstance) {
|
|
270
|
+
show();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// `hidden.bs.offcanvas` fires after the slide-out transition, so
|
|
274
|
+
// the header's z-index lift has already been released by then.
|
|
275
|
+
openHost.addEventListener('hidden.bs.offcanvas', show, { once: true });
|
|
276
|
+
hostInstance.hide();
|
|
277
|
+
},
|
|
278
|
+
true,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
227
282
|
_setupNavTooltips() {
|
|
228
283
|
if (typeof window.bootstrap === 'undefined' || !window.bootstrap.Tooltip) return;
|
|
229
284
|
const navLinks = document.querySelectorAll('#bdNavbar .nav-link[title]');
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Feature: ZER0-083
|
|
2
|
+
/*
|
|
3
|
+
* ===================================================================
|
|
4
|
+
* page-views.js — page-view counter (tracking + display)
|
|
5
|
+
* ===================================================================
|
|
6
|
+
*
|
|
7
|
+
* File: assets/js/page-views.js
|
|
8
|
+
* Purpose: Record a view for the current page and render the count into
|
|
9
|
+
* every `[data-page-views]` badge on the page.
|
|
10
|
+
*
|
|
11
|
+
* Reads one JSON block injected by _includes/components/page-views-init.html:
|
|
12
|
+
* #pageViewsConfig — provider, endpoint, privacy gates, labels
|
|
13
|
+
*
|
|
14
|
+
* Providers
|
|
15
|
+
* local (default) counts live in this visitor's localStorage. Zero
|
|
16
|
+
* config, zero network, GitHub-Pages safe — the count is "how
|
|
17
|
+
* often YOU opened this page", so it is honest without a server.
|
|
18
|
+
* remote counts come from an HTTP counter endpoint (the chat-proxy
|
|
19
|
+
* worker, a self-hosted function, CounterAPI, …). The endpoint
|
|
20
|
+
* owns the truth; this script only asks for it.
|
|
21
|
+
*
|
|
22
|
+
* Privacy
|
|
23
|
+
* Do Not Track, Global Privacy Control, and (optionally) the theme's
|
|
24
|
+
* cookie-consent "analytics" category all suppress RECORDING. Display of
|
|
25
|
+
* an already-known count is never suppressed — nothing is collected to
|
|
26
|
+
* show it. When consent is required and later granted, the pending view
|
|
27
|
+
* is recorded on the `cookieConsentChanged` event.
|
|
28
|
+
*
|
|
29
|
+
* Public API (also what the Playwright spec drives):
|
|
30
|
+
* window.zer0PageViews.get(path) -> number | null
|
|
31
|
+
* window.zer0PageViews.all() -> { path: count }
|
|
32
|
+
* window.zer0PageViews.refresh() -> re-render every badge
|
|
33
|
+
* window.zer0PageViews.reset() -> clear the local store
|
|
34
|
+
* Event: `zer0:page-views` on document, detail { path, count, provider }.
|
|
35
|
+
* ===================================================================
|
|
36
|
+
*/
|
|
37
|
+
(function () {
|
|
38
|
+
"use strict";
|
|
39
|
+
|
|
40
|
+
var STORE_KEY = "zer0-page-views";
|
|
41
|
+
var SESSION_KEY = "zer0-page-views-session";
|
|
42
|
+
var STORE_VERSION = 1;
|
|
43
|
+
|
|
44
|
+
function readJSON(id, fallback) {
|
|
45
|
+
var el = document.getElementById(id);
|
|
46
|
+
if (!el) return fallback;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(el.textContent);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
var CONFIG = readJSON("pageViewsConfig", null);
|
|
55
|
+
if (!CONFIG || !CONFIG.enabled) return;
|
|
56
|
+
|
|
57
|
+
// --- storage helpers --------------------------------------------------
|
|
58
|
+
// Private-mode Safari and "block all cookies" throw on access, not on use,
|
|
59
|
+
// so every touch is wrapped: the widget degrades to "no count" rather than
|
|
60
|
+
// taking the page down with it.
|
|
61
|
+
function safeStorage(kind) {
|
|
62
|
+
try {
|
|
63
|
+
var s = window[kind];
|
|
64
|
+
var probe = "__zer0_probe__";
|
|
65
|
+
s.setItem(probe, "1");
|
|
66
|
+
s.removeItem(probe);
|
|
67
|
+
return s;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var local = safeStorage("localStorage");
|
|
74
|
+
var session = safeStorage("sessionStorage");
|
|
75
|
+
|
|
76
|
+
function readStore() {
|
|
77
|
+
if (!local) return { v: STORE_VERSION, paths: {} };
|
|
78
|
+
try {
|
|
79
|
+
var raw = JSON.parse(local.getItem(STORE_KEY));
|
|
80
|
+
if (raw && raw.paths && typeof raw.paths === "object") return raw;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
/* corrupt payload — start clean rather than throwing */
|
|
83
|
+
}
|
|
84
|
+
return { v: STORE_VERSION, paths: {} };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeStore(store) {
|
|
88
|
+
if (!local) return;
|
|
89
|
+
try {
|
|
90
|
+
local.setItem(STORE_KEY, JSON.stringify(store));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
/* quota exceeded — the count is a nicety, never a hard failure */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Keep the store bounded: evict the least-recently-viewed paths first.
|
|
97
|
+
function prune(store) {
|
|
98
|
+
var max = CONFIG.maxEntries > 0 ? CONFIG.maxEntries : 500;
|
|
99
|
+
var keys = Object.keys(store.paths);
|
|
100
|
+
if (keys.length <= max) return store;
|
|
101
|
+
keys
|
|
102
|
+
.sort(function (a, b) {
|
|
103
|
+
return (store.paths[a].t || 0) - (store.paths[b].t || 0);
|
|
104
|
+
})
|
|
105
|
+
.slice(0, keys.length - max)
|
|
106
|
+
.forEach(function (key) {
|
|
107
|
+
delete store.paths[key];
|
|
108
|
+
});
|
|
109
|
+
return store;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- paths ------------------------------------------------------------
|
|
113
|
+
// Badges carry site-relative, baseurl-free paths (page.url), so the
|
|
114
|
+
// runtime location has to be normalized the same way or a project-pages
|
|
115
|
+
// site would count "/repo/foo/" separately from "/foo/".
|
|
116
|
+
function normalizePath(input) {
|
|
117
|
+
var path = input;
|
|
118
|
+
try {
|
|
119
|
+
path = new URL(input, window.location.href).pathname;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
/* already a bare path */
|
|
122
|
+
}
|
|
123
|
+
var base = CONFIG.baseurl || "";
|
|
124
|
+
if (base && path.indexOf(base) === 0) path = path.slice(base.length);
|
|
125
|
+
path = path.replace(/index\.html?$/, "");
|
|
126
|
+
if (path.charAt(0) !== "/") path = "/" + path;
|
|
127
|
+
if (path.charAt(path.length - 1) !== "/" && !/\.[a-z0-9]+$/i.test(path)) path += "/";
|
|
128
|
+
return path;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
var CURRENT = normalizePath(window.location.pathname);
|
|
132
|
+
|
|
133
|
+
// --- privacy gates ----------------------------------------------------
|
|
134
|
+
function dntEnabled() {
|
|
135
|
+
if (!CONFIG.respectDnt) return false;
|
|
136
|
+
var dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
|
|
137
|
+
return dnt === "1" || dnt === "yes";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function gpcEnabled() {
|
|
141
|
+
return !!CONFIG.respectGpc && navigator.globalPrivacyControl === true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function analyticsConsented() {
|
|
145
|
+
if (window.cookieConsent && typeof window.cookieConsent.analytics === "boolean") {
|
|
146
|
+
return window.cookieConsent.analytics;
|
|
147
|
+
}
|
|
148
|
+
if (!local) return false;
|
|
149
|
+
try {
|
|
150
|
+
var stored = JSON.parse(local.getItem("zer0-cookie-consent"));
|
|
151
|
+
return !!(stored && stored.analytics);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mayRecord() {
|
|
158
|
+
if (!CONFIG.track) return false;
|
|
159
|
+
if (dntEnabled() || gpcEnabled()) return false;
|
|
160
|
+
if (CONFIG.requireConsent && !analyticsConsented()) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// One view per path per browser session, so a reload or a back-navigation
|
|
165
|
+
// does not inflate the number. `dedupe: "never"` counts every load.
|
|
166
|
+
function alreadyCountedThisSession(path) {
|
|
167
|
+
if (CONFIG.dedupe !== "session" || !session) return false;
|
|
168
|
+
try {
|
|
169
|
+
var seen = JSON.parse(session.getItem(SESSION_KEY)) || [];
|
|
170
|
+
return seen.indexOf(path) !== -1;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function markCountedThisSession(path) {
|
|
177
|
+
if (CONFIG.dedupe !== "session" || !session) return;
|
|
178
|
+
try {
|
|
179
|
+
var seen = JSON.parse(session.getItem(SESSION_KEY)) || [];
|
|
180
|
+
if (seen.indexOf(path) === -1) seen.push(path);
|
|
181
|
+
session.setItem(SESSION_KEY, JSON.stringify(seen));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
/* non-fatal */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- counts -----------------------------------------------------------
|
|
188
|
+
var counts = {};
|
|
189
|
+
|
|
190
|
+
function seedFromLocalStore() {
|
|
191
|
+
var store = readStore();
|
|
192
|
+
Object.keys(store.paths).forEach(function (path) {
|
|
193
|
+
var entry = store.paths[path];
|
|
194
|
+
if (entry && typeof entry.c === "number") counts[path] = entry.c;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function recordLocal(path) {
|
|
199
|
+
var store = readStore();
|
|
200
|
+
var entry = store.paths[path] || { c: 0, t: 0 };
|
|
201
|
+
entry.c += 1;
|
|
202
|
+
entry.t = Date.now();
|
|
203
|
+
store.paths[path] = entry;
|
|
204
|
+
writeStore(prune(store));
|
|
205
|
+
return entry.c;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Pull a count out of an arbitrary JSON body, e.g. countKey "data.views".
|
|
209
|
+
function pluck(payload, key) {
|
|
210
|
+
var value = payload;
|
|
211
|
+
var parts = String(key || "views").split(".");
|
|
212
|
+
for (var i = 0; i < parts.length; i++) {
|
|
213
|
+
if (value === null || typeof value !== "object") return null;
|
|
214
|
+
value = value[parts[i]];
|
|
215
|
+
}
|
|
216
|
+
return typeof value === "number" && isFinite(value) ? value : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function remoteUrl(path) {
|
|
220
|
+
var endpoint = CONFIG.endpoint || "";
|
|
221
|
+
if (endpoint.indexOf("{path}") !== -1) {
|
|
222
|
+
return endpoint.replace("{path}", encodeURIComponent(path));
|
|
223
|
+
}
|
|
224
|
+
return endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + "path=" + encodeURIComponent(path);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function recordRemote(path, record) {
|
|
228
|
+
var method = record ? CONFIG.method || "POST" : "GET";
|
|
229
|
+
var init = { method: method, credentials: "omit", mode: "cors" };
|
|
230
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
231
|
+
init.headers = { "Content-Type": "application/json" };
|
|
232
|
+
init.body = JSON.stringify({ path: path });
|
|
233
|
+
}
|
|
234
|
+
return fetch(remoteUrl(path), init)
|
|
235
|
+
.then(function (res) {
|
|
236
|
+
if (!res.ok) throw new Error("page-views: endpoint returned " + res.status);
|
|
237
|
+
return res.json();
|
|
238
|
+
})
|
|
239
|
+
.then(function (payload) {
|
|
240
|
+
var count = pluck(payload, CONFIG.countKey);
|
|
241
|
+
if (count === null) throw new Error("page-views: no count at '" + CONFIG.countKey + "'");
|
|
242
|
+
return count;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- rendering --------------------------------------------------------
|
|
247
|
+
var formatter = null;
|
|
248
|
+
try {
|
|
249
|
+
formatter = new Intl.NumberFormat();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
formatter = null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function format(count) {
|
|
255
|
+
return formatter ? formatter.format(count) : String(count);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function render() {
|
|
259
|
+
var badges = document.querySelectorAll("[data-page-views]");
|
|
260
|
+
Array.prototype.forEach.call(badges, function (badge) {
|
|
261
|
+
var path = normalizePath(badge.getAttribute("data-page-views") || CURRENT);
|
|
262
|
+
var count = counts[path];
|
|
263
|
+
if (typeof count !== "number") return; // unknown → stays hidden, no "0 views" flash
|
|
264
|
+
var countEl = badge.querySelector("[data-page-views-count]");
|
|
265
|
+
var labelEl = badge.querySelector("[data-page-views-label]");
|
|
266
|
+
if (countEl) countEl.textContent = format(count);
|
|
267
|
+
if (labelEl) labelEl.textContent = count === 1 ? CONFIG.labelOne : CONFIG.labelOther;
|
|
268
|
+
badge.removeAttribute("hidden");
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function publish(path, count) {
|
|
273
|
+
counts[path] = count;
|
|
274
|
+
render();
|
|
275
|
+
document.dispatchEvent(
|
|
276
|
+
new CustomEvent("zer0:page-views", {
|
|
277
|
+
detail: { path: path, count: count, provider: CONFIG.provider }
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- orchestration ----------------------------------------------------
|
|
283
|
+
var recorded = false;
|
|
284
|
+
|
|
285
|
+
function recordCurrentView() {
|
|
286
|
+
if (recorded || !mayRecord()) return;
|
|
287
|
+
if (alreadyCountedThisSession(CURRENT)) return;
|
|
288
|
+
recorded = true;
|
|
289
|
+
markCountedThisSession(CURRENT);
|
|
290
|
+
|
|
291
|
+
if (CONFIG.provider === "remote") {
|
|
292
|
+
recordRemote(CURRENT, true)
|
|
293
|
+
.then(function (count) {
|
|
294
|
+
publish(CURRENT, count);
|
|
295
|
+
})
|
|
296
|
+
.catch(function (err) {
|
|
297
|
+
if (CONFIG.fallbackLocal) publish(CURRENT, recordLocal(CURRENT));
|
|
298
|
+
if (window.console && console.debug) console.debug(String(err));
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
publish(CURRENT, recordLocal(CURRENT));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readOnlyRemote() {
|
|
306
|
+
// Recording is blocked (DNT/GPC/consent) but a remote count is public
|
|
307
|
+
// information — show it without contributing to it.
|
|
308
|
+
recordRemote(CURRENT, false)
|
|
309
|
+
.then(function (count) {
|
|
310
|
+
publish(CURRENT, count);
|
|
311
|
+
})
|
|
312
|
+
.catch(function () {
|
|
313
|
+
/* endpoint unavailable — badges stay hidden */
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function start() {
|
|
318
|
+
if (CONFIG.provider !== "remote") seedFromLocalStore();
|
|
319
|
+
render();
|
|
320
|
+
|
|
321
|
+
if (mayRecord()) {
|
|
322
|
+
recordCurrentView();
|
|
323
|
+
} else if (CONFIG.provider === "remote") {
|
|
324
|
+
readOnlyRemote();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Consent can arrive after load — record the pending view when it does.
|
|
328
|
+
if (CONFIG.requireConsent) {
|
|
329
|
+
document.addEventListener("cookieConsentChanged", function () {
|
|
330
|
+
recordCurrentView();
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
window.zer0PageViews = {
|
|
336
|
+
get: function (path) {
|
|
337
|
+
var count = counts[normalizePath(path || CURRENT)];
|
|
338
|
+
return typeof count === "number" ? count : null;
|
|
339
|
+
},
|
|
340
|
+
all: function () {
|
|
341
|
+
var copy = {};
|
|
342
|
+
Object.keys(counts).forEach(function (path) {
|
|
343
|
+
copy[path] = counts[path];
|
|
344
|
+
});
|
|
345
|
+
return copy;
|
|
346
|
+
},
|
|
347
|
+
refresh: render,
|
|
348
|
+
reset: function () {
|
|
349
|
+
counts = {};
|
|
350
|
+
if (local) {
|
|
351
|
+
try {
|
|
352
|
+
local.removeItem(STORE_KEY);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
/* non-fatal */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (session) {
|
|
358
|
+
try {
|
|
359
|
+
session.removeItem(SESSION_KEY);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
/* non-fatal */
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (document.readyState === "loading") {
|
|
368
|
+
document.addEventListener("DOMContentLoaded", start);
|
|
369
|
+
} else {
|
|
370
|
+
start();
|
|
371
|
+
}
|
|
372
|
+
})();
|