jekyll-theme-zer0 1.27.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 +812 -13
- data/_data/backlog.yml +356 -2
- data/_data/consumers.yml +252 -0
- data/_data/features.yml +234 -5
- data/_data/i18n/fr.yml +103 -0
- data/_data/i18n/manifest.yml +1529 -0
- data/_data/ingredient_densities.yml +122 -0
- data/_data/navigation/docs.yml +2 -0
- data/_data/navigation/main.yml +22 -0
- data/_data/recipe_courses.yml +64 -0
- data/_data/series.yml +19 -0
- data/_data/theme-manifest.yml +387 -277
- data/_data/ui-text.yml +8 -0
- data/_includes/README.md +36 -3
- data/_includes/analytics/google-tag-manager-body.html +11 -2
- data/_includes/analytics/google-tag-manager-head.html +16 -6
- data/_includes/analytics/posthog.html +33 -4
- data/_includes/components/abc-letter.html +43 -0
- data/_includes/components/background-customizer.html +2 -2
- data/_includes/components/book-card.html +42 -0
- data/_includes/components/book-nav.html +80 -0
- data/_includes/components/book-plate.html +31 -0
- data/_includes/components/book-toc.html +47 -0
- data/_includes/components/bookshelf.html +68 -0
- data/_includes/components/card-grid.html +60 -0
- data/_includes/components/cookie-consent.html +4 -4
- data/_includes/components/data-card.html +95 -0
- data/_includes/components/halfmoon.html +5 -1
- data/_includes/components/info-section.html +8 -3
- data/_includes/components/language-toggle.html +168 -21
- data/_includes/components/page-feedback.html +65 -2
- 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-controls-bar.html +10 -2
- data/_includes/components/theme-customizer.html +8 -2
- data/_includes/components/theme-info.html +8 -1
- data/_includes/content/jsonld-software.html +22 -3
- data/_includes/content/seo.html +9 -3
- data/_includes/core/color-mode-init.html +13 -4
- data/_includes/core/favicon.html +46 -0
- data/_includes/core/footer.html +18 -16
- data/_includes/core/head.html +21 -0
- data/_includes/core/header.html +7 -4
- data/_includes/custom/body-end.html +18 -0
- data/_includes/custom/body-start.html +17 -0
- data/_includes/custom/footer.html +18 -0
- data/_includes/custom/head.html +18 -0
- data/_includes/navigation/local-graph.html +28 -2
- data/_includes/navigation/nav-tree.html +3 -3
- data/_includes/navigation/section-sidebar.html +93 -11
- data/_includes/navigation/sidebar-config.html +21 -0
- data/_includes/navigation/sidebar-left.html +2 -1
- data/_includes/navigation/sidebar-nav.html +4 -0
- data/_includes/navigation/sidebar-pagetree.html +150 -0
- data/_includes/navigation/sidebar-right.html +2 -1
- data/_includes/navigation/unified-drawer.html +8 -2
- data/_includes/obsidian/full-graph.html +165 -136
- data/_includes/setup/wizard.html +173 -86
- data/_includes/stats/stats-metrics.html +2 -2
- data/_layouts/404.html +260 -0
- data/_layouts/README.md +4 -0
- data/_layouts/admin.html +2 -2
- data/_layouts/article.html +5 -0
- data/_layouts/book-abc.html +106 -0
- data/_layouts/book-story.html +91 -0
- data/_layouts/book.html +113 -0
- data/_layouts/collection.html +17 -5
- data/_layouts/cookbook.html +88 -0
- data/_layouts/default.html +4 -4
- data/_layouts/home.html +5 -2
- data/_layouts/landing.html +9 -0
- data/_layouts/news.html +155 -31
- data/_layouts/recipe.html +274 -0
- data/_layouts/root.html +38 -5
- data/_layouts/section.html +96 -25
- data/_sass/components/_book.scss +423 -0
- 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 +263 -15
- data/_sass/core/_obsidian.scss +286 -6
- data/_sass/layouts/_landing.scss +2 -2
- data/_sass/theme/_backgrounds.scss +21 -8
- 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 +4 -0
- data/assets/js/auto-hide-nav.js +5 -1
- data/assets/js/halfmoon.js +26 -0
- data/assets/js/modules/navigation/navbar.js +55 -0
- data/assets/js/obsidian-graph.js +702 -264
- data/assets/js/obsidian-local-graph.js +161 -54
- data/assets/js/page-views.js +372 -0
- data/assets/js/recipe-scaler.js +501 -0
- data/assets/js/search-modal.js +14 -1
- 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 +66 -16
- data/scripts/bin/validate +5 -1
- 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/install/README.md +47 -6
- data/scripts/install/ai/client.sh +302 -93
- data/scripts/install/ai/prompts/spec.schema.json +1 -1
- data/scripts/install/ai/wizard.sh +10 -5
- data/scripts/install/apply.sh +7 -3
- data/scripts/install/cli.sh +54 -4
- data/scripts/install/config.sh +167 -0
- data/scripts/install/doctor.sh +38 -0
- data/scripts/install/plan.sh +10 -0
- data/scripts/install/spec.sh +15 -7
- data/scripts/install/template.sh +4 -0
- data/scripts/lib/audit.sh +42 -2
- data/scripts/lint-liquid-raw.rb +137 -0
- data/scripts/propagate.rb +277 -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 +161 -15
- metadata +56 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Feature: ZER0-045
|
|
1
2
|
/*
|
|
2
3
|
* obsidian-local-graph.js
|
|
3
4
|
*
|
|
@@ -12,8 +13,13 @@
|
|
|
12
13
|
* Subgraph:
|
|
13
14
|
* - center = current page (matched against entry.url, falling back to
|
|
14
15
|
* normalized title/basename/aliases for permalink quirks)
|
|
15
|
-
* - depth =
|
|
16
|
-
*
|
|
16
|
+
* - depth = 1–3, user-adjustable in the panel (default from the
|
|
17
|
+
* data-depth attribute / `local_graph_depth` front matter)
|
|
18
|
+
* - direction = outgoing and/or incoming wiki-links, user-toggleable
|
|
19
|
+
*
|
|
20
|
+
* Depth + direction preferences persist to localStorage
|
|
21
|
+
* (zer0.obsidianLocalGraph.v1) and the graph re-renders in place when they
|
|
22
|
+
* change — same for Bootstrap color-mode (data-bs-theme) switches.
|
|
17
23
|
*
|
|
18
24
|
* If the current page is in the wiki-index but has no local links, the panel
|
|
19
25
|
* stays available and renders a single-node graph for the current page.
|
|
@@ -26,6 +32,7 @@
|
|
|
26
32
|
var PANEL_SELECTOR = '[data-obsidian-local-graph-panel]';
|
|
27
33
|
var TOGGLE_SELECTOR = '[data-obsidian-local-graph-toggle]';
|
|
28
34
|
var STATUS_SELECTOR = '[data-obsidian-local-graph-status]';
|
|
35
|
+
var STORAGE_KEY = 'zer0.obsidianLocalGraph.v1';
|
|
29
36
|
|
|
30
37
|
// Cytoscape is vendored under assets/vendor/ (no runtime CDN — matches the
|
|
31
38
|
// Bootstrap / Icons / Mermaid policy). The path is supplied by Liquid via
|
|
@@ -57,6 +64,28 @@
|
|
|
57
64
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
// Depth + direction preferences. Depth falls back to the page's data-depth
|
|
68
|
+
// (front-matter `local_graph_depth`), directions default to both on.
|
|
69
|
+
function loadPrefs(defaultDepth) {
|
|
70
|
+
var prefs = { depth: defaultDepth, outgoing: true, incoming: true };
|
|
71
|
+
try {
|
|
72
|
+
var saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || 'null');
|
|
73
|
+
if (saved && typeof saved === 'object') {
|
|
74
|
+
var d = parseInt(saved.depth, 10);
|
|
75
|
+
if (d >= 1 && d <= 3) prefs.depth = d;
|
|
76
|
+
if (typeof saved.outgoing === 'boolean') prefs.outgoing = saved.outgoing;
|
|
77
|
+
if (typeof saved.incoming === 'boolean') prefs.incoming = saved.incoming;
|
|
78
|
+
}
|
|
79
|
+
} catch (e) { /* private mode / corrupt payload — use defaults */ }
|
|
80
|
+
return prefs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function savePrefs(prefs) {
|
|
84
|
+
try {
|
|
85
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
|
86
|
+
} catch (e) { /* storage unavailable — prefs are session-only */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
function companionElements(container) {
|
|
61
90
|
return {
|
|
62
91
|
panel: container.closest(PANEL_SELECTOR),
|
|
@@ -143,10 +172,11 @@
|
|
|
143
172
|
return palette[name] || '#6c757d';
|
|
144
173
|
}
|
|
145
174
|
|
|
146
|
-
// BFS from the current entry up to `depth` hops
|
|
147
|
-
// outgoing
|
|
148
|
-
// includes one of our keys
|
|
149
|
-
function buildSubgraph(entries, lookup, current, depth) {
|
|
175
|
+
// BFS from the current entry up to `depth` hops. Directions are opt-in:
|
|
176
|
+
// opts.outgoing follows this page's [[links]], opts.incoming follows any
|
|
177
|
+
// other entry whose `outgoing` includes one of our keys.
|
|
178
|
+
function buildSubgraph(entries, lookup, current, depth, opts) {
|
|
179
|
+
opts = opts || { outgoing: true, incoming: true };
|
|
150
180
|
var visited = Object.create(null);
|
|
151
181
|
var queue = [{ entry: current, dist: 0 }];
|
|
152
182
|
var nodes = [];
|
|
@@ -206,45 +236,49 @@
|
|
|
206
236
|
if (item.dist >= depth) continue;
|
|
207
237
|
|
|
208
238
|
// Outgoing edges
|
|
209
|
-
(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (resolved
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
239
|
+
if (opts.outgoing) {
|
|
240
|
+
(entry.outgoing || []).forEach(function (target) {
|
|
241
|
+
var nk = normalize(target);
|
|
242
|
+
var resolved = lookup.byKey[nk];
|
|
243
|
+
if (resolved) {
|
|
244
|
+
if (resolved.url === entry.url) return;
|
|
245
|
+
addEdge(entry.url, resolved.url, false);
|
|
246
|
+
if (!visited[resolved.url]) {
|
|
247
|
+
queue.push({ entry: resolved, dist: item.dist + 1 });
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
var brokenId = '__broken__:' + nk;
|
|
251
|
+
if (!visited[brokenId]) {
|
|
252
|
+
visited[brokenId] = true;
|
|
253
|
+
nodes.push({
|
|
254
|
+
group: 'nodes',
|
|
255
|
+
data: {
|
|
256
|
+
id: brokenId,
|
|
257
|
+
label: target,
|
|
258
|
+
url: null,
|
|
259
|
+
collection: 'broken',
|
|
260
|
+
color: '#dc3545',
|
|
261
|
+
broken: true
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
addEdge(entry.url, brokenId, true);
|
|
217
266
|
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (!visited[brokenId]) {
|
|
221
|
-
visited[brokenId] = true;
|
|
222
|
-
nodes.push({
|
|
223
|
-
group: 'nodes',
|
|
224
|
-
data: {
|
|
225
|
-
id: brokenId,
|
|
226
|
-
label: target,
|
|
227
|
-
url: null,
|
|
228
|
-
collection: 'broken',
|
|
229
|
-
color: '#dc3545',
|
|
230
|
-
broken: true
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
addEdge(entry.url, brokenId, true);
|
|
235
|
-
}
|
|
236
|
-
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
237
269
|
|
|
238
270
|
// Incoming edges (anyone whose outgoing matches one of our keys)
|
|
239
|
-
|
|
240
|
-
(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
271
|
+
if (opts.incoming) {
|
|
272
|
+
keysFor(entry).forEach(function (k) {
|
|
273
|
+
(reverse[k] || []).forEach(function (src) {
|
|
274
|
+
if (src.url === entry.url) return;
|
|
275
|
+
addEdge(src.url, entry.url, false);
|
|
276
|
+
if (!visited[src.url]) {
|
|
277
|
+
queue.push({ entry: src, dist: item.dist + 1 });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
246
280
|
});
|
|
247
|
-
}
|
|
281
|
+
}
|
|
248
282
|
}
|
|
249
283
|
|
|
250
284
|
return nodes.concat(edges);
|
|
@@ -299,6 +333,11 @@
|
|
|
299
333
|
}
|
|
300
334
|
|
|
301
335
|
function render(container, elements, currentUrl) {
|
|
336
|
+
// Re-renders (depth/direction/theme changes) replace the prior instance.
|
|
337
|
+
if (container.__obsidianLocalGraph) {
|
|
338
|
+
try { container.__obsidianLocalGraph.destroy(); } catch (e) { /* ignore */ }
|
|
339
|
+
container.__obsidianLocalGraph = null;
|
|
340
|
+
}
|
|
302
341
|
var theme = readTheme();
|
|
303
342
|
container.style.backgroundColor = theme.canvasBg;
|
|
304
343
|
var motion = prefersReducedMotion() ? '0ms' : '160ms';
|
|
@@ -460,6 +499,59 @@
|
|
|
460
499
|
return nav;
|
|
461
500
|
}
|
|
462
501
|
|
|
502
|
+
// Reflect prefs into the depth radio group + direction switches.
|
|
503
|
+
function syncControls(prefs) {
|
|
504
|
+
var radio = document.getElementById('obsidian-lg-depth-' + prefs.depth);
|
|
505
|
+
if (radio) radio.checked = true;
|
|
506
|
+
var outgoing = document.getElementById('obsidian-lg-outgoing');
|
|
507
|
+
if (outgoing) outgoing.checked = prefs.outgoing;
|
|
508
|
+
var incoming = document.getElementById('obsidian-lg-incoming');
|
|
509
|
+
if (incoming) incoming.checked = prefs.incoming;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function wireControls(prefs, rebuild) {
|
|
513
|
+
[1, 2, 3].forEach(function (d) {
|
|
514
|
+
var radio = document.getElementById('obsidian-lg-depth-' + d);
|
|
515
|
+
if (!radio) return;
|
|
516
|
+
radio.addEventListener('change', function () {
|
|
517
|
+
if (!radio.checked) return;
|
|
518
|
+
prefs.depth = d;
|
|
519
|
+
savePrefs(prefs);
|
|
520
|
+
rebuild();
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
var outgoing = document.getElementById('obsidian-lg-outgoing');
|
|
524
|
+
if (outgoing) {
|
|
525
|
+
outgoing.addEventListener('change', function () {
|
|
526
|
+
prefs.outgoing = outgoing.checked;
|
|
527
|
+
savePrefs(prefs);
|
|
528
|
+
rebuild();
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
var incoming = document.getElementById('obsidian-lg-incoming');
|
|
532
|
+
if (incoming) {
|
|
533
|
+
incoming.addEventListener('change', function () {
|
|
534
|
+
prefs.incoming = incoming.checked;
|
|
535
|
+
savePrefs(prefs);
|
|
536
|
+
rebuild();
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Re-render on Bootstrap color-mode changes so canvas colors track the
|
|
542
|
+
// theme without a reload. Local graphs are small — a full rebuild is cheap.
|
|
543
|
+
function watchTheme(rebuild) {
|
|
544
|
+
var restyle = debounce(rebuild, 60);
|
|
545
|
+
var observer = new MutationObserver(restyle);
|
|
546
|
+
[document.documentElement, document.body].forEach(function (el) {
|
|
547
|
+
observer.observe(el, { attributes: true, attributeFilter: ['data-bs-theme'] });
|
|
548
|
+
});
|
|
549
|
+
if (window.matchMedia) {
|
|
550
|
+
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
551
|
+
if (mq.addEventListener) mq.addEventListener('change', restyle);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
463
555
|
function init() {
|
|
464
556
|
var container = document.getElementById(CONTAINER_ID);
|
|
465
557
|
if (!container) return;
|
|
@@ -478,8 +570,9 @@
|
|
|
478
570
|
resizeGraph(container);
|
|
479
571
|
}, 150));
|
|
480
572
|
|
|
481
|
-
var
|
|
482
|
-
if (!isFinite(
|
|
573
|
+
var defaultDepth = parseInt(container.getAttribute('data-depth') || '1', 10);
|
|
574
|
+
if (!isFinite(defaultDepth) || defaultDepth < 1) defaultDepth = 1;
|
|
575
|
+
var prefs = loadPrefs(Math.min(defaultDepth, 3));
|
|
483
576
|
var indexUrl = container.getAttribute('data-index-url') ||
|
|
484
577
|
((document.querySelector('base') || {}).href || '/') +
|
|
485
578
|
'assets/data/wiki-index.json';
|
|
@@ -496,17 +589,21 @@
|
|
|
496
589
|
// Confirmed in-index: reveal the panel + FAB now.
|
|
497
590
|
setPanelAvailable(container, true);
|
|
498
591
|
setStatus(container, 'Loading graph…', false);
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
var
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
592
|
+
syncControls(prefs);
|
|
593
|
+
|
|
594
|
+
var graphAvailable = null; // unknown until loadCytoscape resolves
|
|
595
|
+
|
|
596
|
+
function rebuild() {
|
|
597
|
+
var elements = buildSubgraph(entries, lookup, current, prefs.depth, {
|
|
598
|
+
outgoing: prefs.outgoing,
|
|
599
|
+
incoming: prefs.incoming
|
|
600
|
+
});
|
|
601
|
+
var nodeCount = elements.filter(function (el) { return el.group === 'nodes'; }).length;
|
|
602
|
+
var edgeCount = elements.filter(function (el) { return el.group === 'edges'; }).length;
|
|
603
|
+
// Accessible text fallback (also the graceful degradation if
|
|
604
|
+
// cytoscape can't load): a list of linked neighbours below the canvas.
|
|
605
|
+
var fallback = renderTextFallback(container, elements, current);
|
|
606
|
+
if (graphAvailable === false) {
|
|
510
607
|
container.hidden = true;
|
|
511
608
|
setStatus(container, 'Showing linked pages (interactive graph unavailable).', false);
|
|
512
609
|
return;
|
|
@@ -515,6 +612,16 @@
|
|
|
515
612
|
setStatus(container, nodeCount + ' pages · ' + edgeCount + ' links', false);
|
|
516
613
|
// Graph is the visual representation; keep the list for AT only.
|
|
517
614
|
if (fallback) fallback.classList.add('visually-hidden');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
wireControls(prefs, rebuild);
|
|
618
|
+
watchTheme(function () {
|
|
619
|
+
if (container.__obsidianLocalGraph) rebuild();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
loadCytoscape(function (ok) {
|
|
623
|
+
graphAvailable = ok !== false;
|
|
624
|
+
rebuild();
|
|
518
625
|
});
|
|
519
626
|
})
|
|
520
627
|
.catch(function (err) {
|
|
@@ -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
|
+
})();
|