@mindful-web/marko-web-digital-edition 1.95.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.
- package/.eslintignore +1 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/components/ad.marko +81 -0
- package/components/ad.marko.js +102 -0
- package/components/article-body.marko +134 -0
- package/components/article-body.marko.js +186 -0
- package/components/author.marko +30 -0
- package/components/author.marko.js +59 -0
- package/components/gate.marko +158 -0
- package/components/gate.marko.js +147 -0
- package/components/ld-json.marko +5 -0
- package/components/ld-json.marko.js +29 -0
- package/components/marko.json +78 -0
- package/components/reflow.marko +67 -0
- package/components/reflow.marko.js +106 -0
- package/components/replica.marko +86 -0
- package/components/replica.marko.js +128 -0
- package/components/scroll.marko +134 -0
- package/components/scroll.marko.js +23 -0
- package/components/theme.marko +73 -0
- package/components/theme.marko.js +30 -0
- package/components/toc.marko +28 -0
- package/components/toc.marko.js +53 -0
- package/components/toolbar.marko +47 -0
- package/components/toolbar.marko.js +54 -0
- package/components/tracking.marko +211 -0
- package/components/tracking.marko.js +60 -0
- package/lib/authors.js +125 -0
- package/lib/entities.js +97 -0
- package/lib/index.js +453 -0
- package/marko.json +6 -0
- package/package.json +33 -0
- package/routes/index.js +129 -0
- package/scss/index.scss +679 -0
- package/templates/article.marko +108 -0
- package/templates/article.marko.js +238 -0
- package/templates/hub.marko +94 -0
- package/templates/hub.marko.js +219 -0
- package/templates/partial.marko +17 -0
- package/templates/partial.marko.js +44 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Compiled using marko@4.20.2 - DO NOT EDIT
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-digital-edition$1.95.0/components/toolbar.marko",
|
|
6
|
+
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
|
+
module_lib_module = require("../lib"),
|
|
8
|
+
lib_module = module_lib_module.default || module_lib_module,
|
|
9
|
+
editionPath = module_lib_module.editionPath,
|
|
10
|
+
helpers_escape_xml = require("marko/dist/runtime/html/helpers/escape-xml"),
|
|
11
|
+
marko_escapeXml = helpers_escape_xml.x,
|
|
12
|
+
marko_classAttr = require("marko/dist/runtime/html/helpers/class-attr"),
|
|
13
|
+
marko_attr = require("marko/dist/runtime/html/helpers/attr");
|
|
14
|
+
|
|
15
|
+
function render(input, out, __component, component, state) {
|
|
16
|
+
var data = input;
|
|
17
|
+
|
|
18
|
+
const { manifest, view } = input;
|
|
19
|
+
|
|
20
|
+
const readHref = editionPath(manifest.issueId);
|
|
21
|
+
|
|
22
|
+
const printHref = editionPath(manifest.issueId, { view: "print" });
|
|
23
|
+
|
|
24
|
+
const showThemeToggle = input.showThemeToggle !== false;
|
|
25
|
+
|
|
26
|
+
out.w("<div class=\"edition-toolbar\"><div class=\"edition-toolbar__title\"><span class=\"edition-toolbar__pub\">" +
|
|
27
|
+
marko_escapeXml(manifest.publication) +
|
|
28
|
+
"</span><span class=\"edition-toolbar__issue\">" +
|
|
29
|
+
marko_escapeXml(manifest.name) +
|
|
30
|
+
"</span></div><div class=\"edition-toolbar__actions\">");
|
|
31
|
+
|
|
32
|
+
if (showThemeToggle) {
|
|
33
|
+
out.w("<button type=\"button\" class=\"edition-toolbar__theme\" data-edition-theme-toggle aria-live=\"polite\"><span class=\"edition-toolbar__theme__icon\" aria-hidden=\"true\"><span data-when=\"dark\">☀</span><span data-when=\"light\">☾</span></span><span class=\"edition-toolbar__theme__label\"><span data-when=\"dark\">Light</span><span data-when=\"light\">Dark</span></span></button>");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
out.w("<nav class=\"edition-toolbar__views\" aria-label=\"Reading view\"><a" +
|
|
37
|
+
marko_classAttr("edition-toolbar__view " + (view === "read" ? "is-active" : "")) +
|
|
38
|
+
marko_attr("href", readHref) +
|
|
39
|
+
marko_attr("aria-current", view === "read" ? "page" : null) +
|
|
40
|
+
">Read</a><a" +
|
|
41
|
+
marko_classAttr("edition-toolbar__view " + (view === "print" ? "is-active" : "")) +
|
|
42
|
+
marko_attr("href", printHref) +
|
|
43
|
+
marko_attr("aria-current", view === "print" ? "page" : null) +
|
|
44
|
+
">Print view</a></nav></div></div>");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
marko_template._ = marko_renderer(render, {
|
|
48
|
+
d_: true,
|
|
49
|
+
e_: marko_componentType
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
marko_template.meta = {
|
|
53
|
+
id: "/@mindful-web/marko-web-digital-edition$1.95.0/components/toolbar.marko"
|
|
54
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { issueEntity, articleEntity } from "../lib/entities";
|
|
2
|
+
|
|
3
|
+
$ const { site } = out.global;
|
|
4
|
+
$ const { manifest, view, item } = input;
|
|
5
|
+
$ const p1 = site.getAsObject("p1events");
|
|
6
|
+
|
|
7
|
+
<if(p1.enabled)>
|
|
8
|
+
$ const issue = issueEntity(manifest);
|
|
9
|
+
$ const article = item ? articleEntity(manifest, item) : null;
|
|
10
|
+
$ const issueView = { action: "View", category: "Digital Edition", label: issue.name, entity: issue, props: { issueId: issue.id, view, pageCount: issue.props.pageCount } };
|
|
11
|
+
$ const articleView = article ? { action: "View", category: "Digital Edition Article", label: item.slug, entity: article, props: { view, via: "page" } } : null;
|
|
12
|
+
$ const articleViewScript = articleView ? `p1events('track', ${JSON.stringify(articleView)});` : "";
|
|
13
|
+
|
|
14
|
+
<!--
|
|
15
|
+
Event model (see digital-edition-spike/TRACKING-PLAN.md):
|
|
16
|
+
|
|
17
|
+
issue View (hub / replica / article page), Page View (replica page dwell)
|
|
18
|
+
article View (article page load here; each article entering the scroll reader
|
|
19
|
+
in scroll.marko), Scroll 25/50/75/90 of the FULL body only, Engaged View
|
|
20
|
+
ad Impression (25% in view, "served"), Active View (IAB: 50% of the unit —
|
|
21
|
+
30% for units over 242,500 px² — for one continuous second), Click
|
|
22
|
+
(p1events link listener via data-p1-* on the anchors)
|
|
23
|
+
|
|
24
|
+
Entities are built server-side (lib/entities.js) and stamped on the elements as
|
|
25
|
+
data-edition-entity, so the browser never rebuilds them and the namespace always
|
|
26
|
+
comes from the tenant. Everything goes through the site's p1events queue, so it
|
|
27
|
+
carries the reader identity — the reason this targets Mindful ads rather than GAM.
|
|
28
|
+
Ad categories mirror the Mindful ad server's so a later swap to real
|
|
29
|
+
ADVERTISING_CREATIVE delivery is a rename, not a redesign.
|
|
30
|
+
-->
|
|
31
|
+
<script>
|
|
32
|
+
$!{`
|
|
33
|
+
(function () {
|
|
34
|
+
if (!window.p1events) return;
|
|
35
|
+
p1events('track', ${JSON.stringify(issueView)});
|
|
36
|
+
${articleViewScript}
|
|
37
|
+
|
|
38
|
+
var ISSUE = ${JSON.stringify(issue)};
|
|
39
|
+
var hasIO = 'IntersectionObserver' in window;
|
|
40
|
+
|
|
41
|
+
function entityOf(el) {
|
|
42
|
+
try { return JSON.parse(el.getAttribute('data-edition-entity')); } catch (e) { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- ads: served impression + IAB active view -------------------------------
|
|
46
|
+
var LARGE_AD_PX = 242500;
|
|
47
|
+
var seen = Object.create(null);
|
|
48
|
+
var active = Object.create(null);
|
|
49
|
+
var timers = Object.create(null);
|
|
50
|
+
|
|
51
|
+
function adEvent(action, el) {
|
|
52
|
+
var entity = entityOf(el);
|
|
53
|
+
if (!entity) return;
|
|
54
|
+
p1events('track', {
|
|
55
|
+
action: action,
|
|
56
|
+
category: 'Digital Edition Ad Unit',
|
|
57
|
+
label: el.getAttribute('data-advertiser') || '',
|
|
58
|
+
entity: entity,
|
|
59
|
+
props: {
|
|
60
|
+
issueId: ISSUE.id,
|
|
61
|
+
page: Number(el.getAttribute('data-edition-page')) || null,
|
|
62
|
+
advertiser: el.getAttribute('data-advertiser') || null,
|
|
63
|
+
placement: el.getAttribute('data-edition-placement') || 'after'
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function impression(el) {
|
|
69
|
+
var id = el.getAttribute('data-edition-ad-id') + '@' + (el.getAttribute('data-edition-placement') || '');
|
|
70
|
+
if (seen[id]) return;
|
|
71
|
+
seen[id] = 1;
|
|
72
|
+
adEvent('Impression', el);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The unit must hold its ratio for one continuous second: start a timer when it
|
|
76
|
+
// crosses the threshold, cancel it when it drops back below.
|
|
77
|
+
function activeRatioFor(el) {
|
|
78
|
+
var r = el.getBoundingClientRect();
|
|
79
|
+
return (r.width * r.height) > LARGE_AD_PX ? 0.3 : 0.5;
|
|
80
|
+
}
|
|
81
|
+
function onAdIntersect(entries) {
|
|
82
|
+
entries.forEach(function (e) {
|
|
83
|
+
var el = e.target;
|
|
84
|
+
var id = el.getAttribute('data-edition-ad-id') + '@' + (el.getAttribute('data-edition-placement') || '');
|
|
85
|
+
if (e.isIntersecting && e.intersectionRatio >= 0.25) impression(el);
|
|
86
|
+
if (active[id]) return;
|
|
87
|
+
var viewable = e.isIntersecting && e.intersectionRatio >= activeRatioFor(el);
|
|
88
|
+
if (viewable && !timers[id]) {
|
|
89
|
+
timers[id] = setTimeout(function () {
|
|
90
|
+
timers[id] = null;
|
|
91
|
+
active[id] = 1;
|
|
92
|
+
adEvent('Active View', el);
|
|
93
|
+
adIO.unobserve(el);
|
|
94
|
+
}, 1000);
|
|
95
|
+
} else if (!viewable && timers[id]) {
|
|
96
|
+
clearTimeout(timers[id]);
|
|
97
|
+
timers[id] = null;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
var adIO = hasIO ? new IntersectionObserver(onAdIntersect, { threshold: [0, 0.25, 0.3, 0.5, 0.75, 1] }) : null;
|
|
102
|
+
|
|
103
|
+
function scanAds() {
|
|
104
|
+
var ads = document.querySelectorAll('[data-edition-ad-id]:not([data-edition-ad-tracked])');
|
|
105
|
+
for (var i = 0; i < ads.length; i += 1) {
|
|
106
|
+
ads[i].setAttribute('data-edition-ad-tracked', '1');
|
|
107
|
+
if (adIO) adIO.observe(ads[i]);
|
|
108
|
+
else impression(ads[i]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- articles: scroll depth over the FULL body only ----------------------------
|
|
113
|
+
// Thresholds mirror web articles (25/50/75/90). Depth is measured against the
|
|
114
|
+
// article's own body, not the page, and only bodies rendered for an entitled
|
|
115
|
+
// reader carry data-edition-full-body — a "90%" on a teaser would be a false read.
|
|
116
|
+
var DEPTHS = [0.25, 0.5, 0.75, 0.9];
|
|
117
|
+
var tracked = [];
|
|
118
|
+
|
|
119
|
+
function scanArticles() {
|
|
120
|
+
var arts = document.querySelectorAll('[data-edition-entity][data-edition-full-body]:not([data-edition-scroll-tracked])');
|
|
121
|
+
for (var i = 0; i < arts.length; i += 1) {
|
|
122
|
+
var art = arts[i];
|
|
123
|
+
art.setAttribute('data-edition-scroll-tracked', '1');
|
|
124
|
+
var body = art.querySelector('.edition-article__body');
|
|
125
|
+
var entity = entityOf(art);
|
|
126
|
+
if (!body || !entity) continue;
|
|
127
|
+
tracked.push({ body: body, entity: entity, slug: art.getAttribute('data-edition-article-slug') || '', fired: {} });
|
|
128
|
+
}
|
|
129
|
+
measure();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// rAF must be called as a method of window: a detached reference throws
|
|
133
|
+
// "Illegal invocation" and would leave the pending flag stuck true.
|
|
134
|
+
var raf = window.requestAnimationFrame
|
|
135
|
+
? function (fn) { window.requestAnimationFrame(fn); }
|
|
136
|
+
: function (fn) { setTimeout(fn, 16); };
|
|
137
|
+
var pending = false;
|
|
138
|
+
function measure() {
|
|
139
|
+
pending = false;
|
|
140
|
+
var vh = window.innerHeight || document.documentElement.clientHeight;
|
|
141
|
+
for (var i = 0; i < tracked.length; i += 1) {
|
|
142
|
+
var t = tracked[i];
|
|
143
|
+
if (t.done) continue;
|
|
144
|
+
var r = t.body.getBoundingClientRect();
|
|
145
|
+
if (!r.height || r.top > vh) continue;
|
|
146
|
+
var depth = Math.min(1, (vh - r.top) / r.height);
|
|
147
|
+
var remaining = 0;
|
|
148
|
+
for (var d = 0; d < DEPTHS.length; d += 1) {
|
|
149
|
+
var k = DEPTHS[d];
|
|
150
|
+
if (t.fired[k]) continue;
|
|
151
|
+
if (depth >= k) {
|
|
152
|
+
t.fired[k] = 1;
|
|
153
|
+
p1events('track', {
|
|
154
|
+
action: 'Scroll',
|
|
155
|
+
category: 'Digital Edition Article',
|
|
156
|
+
label: (k * 100) + ' percent',
|
|
157
|
+
entity: t.entity,
|
|
158
|
+
props: { slug: t.slug, depth: k }
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
remaining += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!remaining) t.done = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function onScroll() {
|
|
168
|
+
if (pending) return;
|
|
169
|
+
pending = true;
|
|
170
|
+
raf(measure);
|
|
171
|
+
}
|
|
172
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
173
|
+
window.addEventListener('resize', onScroll, { passive: true });
|
|
174
|
+
|
|
175
|
+
scanAds();
|
|
176
|
+
scanArticles();
|
|
177
|
+
// The scroll reader appends whole articles (with their ads) after this runs.
|
|
178
|
+
document.addEventListener('edition:content-appended', function () { scanAds(); scanArticles(); });
|
|
179
|
+
|
|
180
|
+
// ---- replica: report the page a reader actually dwells on ----------------------
|
|
181
|
+
var pages = document.querySelectorAll('.edition-replica__page[data-page]');
|
|
182
|
+
if (pages.length && hasIO) {
|
|
183
|
+
var pageSeen = Object.create(null);
|
|
184
|
+
var pio = new IntersectionObserver(function (entries) {
|
|
185
|
+
entries.forEach(function (e) {
|
|
186
|
+
if (!e.isIntersecting) return;
|
|
187
|
+
var n = e.target.getAttribute('data-page');
|
|
188
|
+
if (pageSeen[n]) return;
|
|
189
|
+
pageSeen[n] = 1;
|
|
190
|
+
p1events('track', {
|
|
191
|
+
action: 'Page View',
|
|
192
|
+
category: 'Digital Edition',
|
|
193
|
+
label: 'Page ' + n,
|
|
194
|
+
entity: ISSUE,
|
|
195
|
+
props: { issueId: ISSUE.id, page: Number(n) }
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}, { threshold: 0.5 });
|
|
199
|
+
for (var k = 0; k < pages.length; k += 1) pio.observe(pages[k]);
|
|
200
|
+
}
|
|
201
|
+
}());
|
|
202
|
+
`}
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<!-- One Engaged View per page load, on the page's primary entity. -->
|
|
206
|
+
<marko-web-p1-events-track-engagement
|
|
207
|
+
category=(article ? "Digital Edition Article" : "Digital Edition")
|
|
208
|
+
entity=(article || issue)
|
|
209
|
+
label=(article ? item.slug : issue.name)
|
|
210
|
+
/>
|
|
211
|
+
</if>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Compiled using marko@4.20.2 - DO NOT EDIT
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-digital-edition$1.95.0/components/tracking.marko",
|
|
6
|
+
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
|
+
module_entities_module = require("../lib/entities"),
|
|
8
|
+
entities_module = module_entities_module.default || module_entities_module,
|
|
9
|
+
issueEntity = module_entities_module.issueEntity,
|
|
10
|
+
articleEntity = module_entities_module.articleEntity,
|
|
11
|
+
helpers_escape_xml = require("marko/dist/runtime/html/helpers/escape-xml"),
|
|
12
|
+
marko_escapeXml = helpers_escape_xml.x,
|
|
13
|
+
marko_escapeScript = require("marko/dist/runtime/html/helpers/escape-script-placeholder"),
|
|
14
|
+
marko_web_p1_events_track_engagement_template = require("@mindful-web/marko-web-p1-events/components/track-engagement.marko"),
|
|
15
|
+
marko_loadTag = require("marko/dist/runtime/helpers/load-tag"),
|
|
16
|
+
marko_web_p1_events_track_engagement_tag = marko_loadTag(marko_web_p1_events_track_engagement_template);
|
|
17
|
+
|
|
18
|
+
function render(input, out, __component, component, state) {
|
|
19
|
+
var data = input;
|
|
20
|
+
|
|
21
|
+
const { site } = out.global;
|
|
22
|
+
|
|
23
|
+
const { manifest, view, item } = input;
|
|
24
|
+
|
|
25
|
+
const p1 = site.getAsObject("p1events");
|
|
26
|
+
|
|
27
|
+
if (p1.enabled) {
|
|
28
|
+
const issue = issueEntity(manifest);
|
|
29
|
+
|
|
30
|
+
const article = item ? articleEntity(manifest, item) : null;
|
|
31
|
+
|
|
32
|
+
const issueView = { action: "View", category: "Digital Edition", label: issue.name, entity: issue, props: { issueId: issue.id, view, pageCount: issue.props.pageCount } };
|
|
33
|
+
|
|
34
|
+
const articleView = article ? { action: "View", category: "Digital Edition Article", label: item.slug, entity: article, props: { view, via: "page" } } : null;
|
|
35
|
+
|
|
36
|
+
const articleViewScript = articleView ? `p1events('track', ${JSON.stringify(articleView)});` : "";
|
|
37
|
+
|
|
38
|
+
out.w("<script>" +
|
|
39
|
+
marko_escapeScript(("\n " + (((((("\n (function () {\n if (!window.p1events) return;\n p1events('track', " + JSON.stringify(issueView)) + ");\n ") + articleViewScript) + "\n\n var ISSUE = ") + JSON.stringify(issue)) + ";\n var hasIO = 'IntersectionObserver' in window;\n\n function entityOf(el) {\n try { return JSON.parse(el.getAttribute('data-edition-entity')); } catch (e) { return null; }\n }\n\n // ---- ads: served impression + IAB active view -------------------------------\n var LARGE_AD_PX = 242500;\n var seen = Object.create(null);\n var active = Object.create(null);\n var timers = Object.create(null);\n\n function adEvent(action, el) {\n var entity = entityOf(el);\n if (!entity) return;\n p1events('track', {\n action: action,\n category: 'Digital Edition Ad Unit',\n label: el.getAttribute('data-advertiser') || '',\n entity: entity,\n props: {\n issueId: ISSUE.id,\n page: Number(el.getAttribute('data-edition-page')) || null,\n advertiser: el.getAttribute('data-advertiser') || null,\n placement: el.getAttribute('data-edition-placement') || 'after'\n }\n });\n }\n\n function impression(el) {\n var id = el.getAttribute('data-edition-ad-id') + '@' + (el.getAttribute('data-edition-placement') || '');\n if (seen[id]) return;\n seen[id] = 1;\n adEvent('Impression', el);\n }\n\n // The unit must hold its ratio for one continuous second: start a timer when it\n // crosses the threshold, cancel it when it drops back below.\n function activeRatioFor(el) {\n var r = el.getBoundingClientRect();\n return (r.width * r.height) > LARGE_AD_PX ? 0.3 : 0.5;\n }\n function onAdIntersect(entries) {\n entries.forEach(function (e) {\n var el = e.target;\n var id = el.getAttribute('data-edition-ad-id') + '@' + (el.getAttribute('data-edition-placement') || '');\n if (e.isIntersecting && e.intersectionRatio >= 0.25) impression(el);\n if (active[id]) return;\n var viewable = e.isIntersecting && e.intersectionRatio >= activeRatioFor(el);\n if (viewable && !timers[id]) {\n timers[id] = setTimeout(function () {\n timers[id] = null;\n active[id] = 1;\n adEvent('Active View', el);\n adIO.unobserve(el);\n }, 1000);\n } else if (!viewable && timers[id]) {\n clearTimeout(timers[id]);\n timers[id] = null;\n }\n });\n }\n var adIO = hasIO ? new IntersectionObserver(onAdIntersect, { threshold: [0, 0.25, 0.3, 0.5, 0.75, 1] }) : null;\n\n function scanAds() {\n var ads = document.querySelectorAll('[data-edition-ad-id]:not([data-edition-ad-tracked])');\n for (var i = 0; i < ads.length; i += 1) {\n ads[i].setAttribute('data-edition-ad-tracked', '1');\n if (adIO) adIO.observe(ads[i]);\n else impression(ads[i]);\n }\n }\n\n // ---- articles: scroll depth over the FULL body only ----------------------------\n // Thresholds mirror web articles (25/50/75/90). Depth is measured against the\n // article's own body, not the page, and only bodies rendered for an entitled\n // reader carry data-edition-full-body — a \"90%\" on a teaser would be a false read.\n var DEPTHS = [0.25, 0.5, 0.75, 0.9];\n var tracked = [];\n\n function scanArticles() {\n var arts = document.querySelectorAll('[data-edition-entity][data-edition-full-body]:not([data-edition-scroll-tracked])');\n for (var i = 0; i < arts.length; i += 1) {\n var art = arts[i];\n art.setAttribute('data-edition-scroll-tracked', '1');\n var body = art.querySelector('.edition-article__body');\n var entity = entityOf(art);\n if (!body || !entity) continue;\n tracked.push({ body: body, entity: entity, slug: art.getAttribute('data-edition-article-slug') || '', fired: {} });\n }\n measure();\n }\n\n // rAF must be called as a method of window: a detached reference throws\n // \"Illegal invocation\" and would leave the pending flag stuck true.\n var raf = window.requestAnimationFrame\n ? function (fn) { window.requestAnimationFrame(fn); }\n : function (fn) { setTimeout(fn, 16); };\n var pending = false;\n function measure() {\n pending = false;\n var vh = window.innerHeight || document.documentElement.clientHeight;\n for (var i = 0; i < tracked.length; i += 1) {\n var t = tracked[i];\n if (t.done) continue;\n var r = t.body.getBoundingClientRect();\n if (!r.height || r.top > vh) continue;\n var depth = Math.min(1, (vh - r.top) / r.height);\n var remaining = 0;\n for (var d = 0; d < DEPTHS.length; d += 1) {\n var k = DEPTHS[d];\n if (t.fired[k]) continue;\n if (depth >= k) {\n t.fired[k] = 1;\n p1events('track', {\n action: 'Scroll',\n category: 'Digital Edition Article',\n label: (k * 100) + ' percent',\n entity: t.entity,\n props: { slug: t.slug, depth: k }\n });\n } else {\n remaining += 1;\n }\n }\n if (!remaining) t.done = true;\n }\n }\n function onScroll() {\n if (pending) return;\n pending = true;\n raf(measure);\n }\n window.addEventListener('scroll', onScroll, { passive: true });\n window.addEventListener('resize', onScroll, { passive: true });\n\n scanAds();\n scanArticles();\n // The scroll reader appends whole articles (with their ads) after this runs.\n document.addEventListener('edition:content-appended', function () { scanAds(); scanArticles(); });\n\n // ---- replica: report the page a reader actually dwells on ----------------------\n var pages = document.querySelectorAll('.edition-replica__page[data-page]');\n if (pages.length && hasIO) {\n var pageSeen = Object.create(null);\n var pio = new IntersectionObserver(function (entries) {\n entries.forEach(function (e) {\n if (!e.isIntersecting) return;\n var n = e.target.getAttribute('data-page');\n if (pageSeen[n]) return;\n pageSeen[n] = 1;\n p1events('track', {\n action: 'Page View',\n category: 'Digital Edition',\n label: 'Page ' + n,\n entity: ISSUE,\n props: { issueId: ISSUE.id, page: Number(n) }\n });\n });\n }, { threshold: 0.5 });\n for (var k = 0; k < pages.length; k += 1) pio.observe(pages[k]);\n }\n }());\n ")) + "\n ") +
|
|
40
|
+
"</script>");
|
|
41
|
+
|
|
42
|
+
marko_web_p1_events_track_engagement_tag({
|
|
43
|
+
category: article ? "Digital Edition Article" : "Digital Edition",
|
|
44
|
+
entity: article || issue,
|
|
45
|
+
label: article ? item.slug : issue.name
|
|
46
|
+
}, out, __component, "0");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
marko_template._ = marko_renderer(render, {
|
|
51
|
+
d_: true,
|
|
52
|
+
e_: marko_componentType
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
marko_template.meta = {
|
|
56
|
+
id: "/@mindful-web/marko-web-digital-edition$1.95.0/components/tracking.marko",
|
|
57
|
+
tags: [
|
|
58
|
+
"@mindful-web/marko-web-p1-events/components/track-engagement.marko"
|
|
59
|
+
]
|
|
60
|
+
};
|
package/lib/authors.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const gql = require('graphql-tag');
|
|
2
|
+
const { getAsArray } = require('@mindful-web/object-path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a print byline string to a CMS contact record, so the edition can render a
|
|
6
|
+
* linked name and headshot instead of plain text.
|
|
7
|
+
*
|
|
8
|
+
* The compat API has no name-search query for contacts, so the org's contact list is
|
|
9
|
+
* fetched once and matched in Node. AB Media has ~306 records, which is small enough
|
|
10
|
+
* to hold in memory — but it must NOT be fetched per request, hence the TTL cache.
|
|
11
|
+
*
|
|
12
|
+
* Matching is exact on a normalized name (plus "first last"). No fuzzy matching:
|
|
13
|
+
* attributing an article to the wrong person is worse than showing plain text.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const CONTACTS = gql`
|
|
17
|
+
query EditionContacts($siteId: ObjectID!, $limit: Int!, $after: String) {
|
|
18
|
+
allPublishedContent(input: {
|
|
19
|
+
siteId: $siteId
|
|
20
|
+
includeContentTypes: [Contact]
|
|
21
|
+
pagination: { limit: $limit, after: $after }
|
|
22
|
+
}) {
|
|
23
|
+
pageInfo { hasNextPage endCursor }
|
|
24
|
+
edges {
|
|
25
|
+
node {
|
|
26
|
+
id
|
|
27
|
+
name
|
|
28
|
+
siteContext { path }
|
|
29
|
+
... on Contactable { title firstName lastName }
|
|
30
|
+
primaryImage {
|
|
31
|
+
id
|
|
32
|
+
src(input: { options: { auto: "format,compress" } })
|
|
33
|
+
alt(input: { append: "Headshot" })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
const TTL_MS = 10 * 60 * 1000;
|
|
42
|
+
const cache = new Map(); // siteId -> { at, index }
|
|
43
|
+
|
|
44
|
+
const normalize = (s) => String(s || '')
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/^\s*by[\s:]+/i, '')
|
|
47
|
+
.replace(/[’']/g, '')
|
|
48
|
+
.replace(/[^a-z\s-]/g, ' ')
|
|
49
|
+
.replace(/\s+/g, ' ')
|
|
50
|
+
.trim();
|
|
51
|
+
|
|
52
|
+
/** Fetch every contact for the site and index by normalized name. */
|
|
53
|
+
async function loadIndex({ apollo, siteId }) {
|
|
54
|
+
const index = new Map();
|
|
55
|
+
let after;
|
|
56
|
+
for (let i = 0; i < 10; i += 1) {
|
|
57
|
+
// eslint-disable-next-line no-await-in-loop
|
|
58
|
+
const { data } = await apollo.query({
|
|
59
|
+
query: CONTACTS,
|
|
60
|
+
variables: { siteId, limit: 100, after },
|
|
61
|
+
});
|
|
62
|
+
const conn = data.allPublishedContent;
|
|
63
|
+
getAsArray(conn, 'edges').forEach((edge) => {
|
|
64
|
+
const { node } = edge;
|
|
65
|
+
if (!node) return;
|
|
66
|
+
[
|
|
67
|
+
normalize(node.name),
|
|
68
|
+
normalize([node.firstName, node.lastName].filter(Boolean).join(' ')),
|
|
69
|
+
].forEach((key) => {
|
|
70
|
+
if (key && !index.has(key)) index.set(key, node);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
if (!conn.pageInfo || !conn.pageInfo.hasNextPage) break;
|
|
74
|
+
after = conn.pageInfo.endCursor;
|
|
75
|
+
}
|
|
76
|
+
return index;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function getIndex({ apollo, siteId }) {
|
|
80
|
+
const hit = cache.get(siteId);
|
|
81
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.index;
|
|
82
|
+
try {
|
|
83
|
+
const index = await loadIndex({ apollo, siteId });
|
|
84
|
+
cache.set(siteId, { at: Date.now(), index });
|
|
85
|
+
return index;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// A contact-lookup failure must never take the edition down — fall back to
|
|
88
|
+
// plain-text bylines. Reuse a stale index if we have one.
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.error(`[edition] contact lookup failed: ${e.message}`);
|
|
91
|
+
return hit ? hit.index : new Map();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Attach a resolved contact record to each article item as `authorNode`.
|
|
97
|
+
*
|
|
98
|
+
* For an article matched to existing CMS content, the real `authors` connection on
|
|
99
|
+
* that content is higher fidelity than matching the print byline string — pass it in
|
|
100
|
+
* via `authorsByContentId` and it wins.
|
|
101
|
+
*/
|
|
102
|
+
async function attachAuthors({
|
|
103
|
+
apollo, siteId, items, authorsByContentId = new Map(),
|
|
104
|
+
}) {
|
|
105
|
+
const bylined = items.filter((i) => i.kind === 'article' && (i.byline || (i.match && i.match.contentId)));
|
|
106
|
+
if (!bylined.length) return items;
|
|
107
|
+
|
|
108
|
+
const index = await getIndex({ apollo, siteId });
|
|
109
|
+
|
|
110
|
+
bylined.forEach((item) => {
|
|
111
|
+
const matchedId = item.match && item.match.contentId;
|
|
112
|
+
const fromMatch = matchedId
|
|
113
|
+
? getAsArray(authorsByContentId.get(matchedId), 'edges').map((e) => e.node)[0]
|
|
114
|
+
: null;
|
|
115
|
+
/* eslint-disable no-param-reassign */
|
|
116
|
+
item.authorNode = fromMatch || index.get(normalize(item.byline)) || null;
|
|
117
|
+
if (fromMatch) item.authorSource = 'cms-content-authors';
|
|
118
|
+
else if (item.authorNode) item.authorSource = 'byline-name-match';
|
|
119
|
+
else item.authorSource = 'none';
|
|
120
|
+
/* eslint-enable no-param-reassign */
|
|
121
|
+
});
|
|
122
|
+
return items;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { attachAuthors, normalize, CONTACTS };
|
package/lib/entities.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const ns = require('@mindful-web/marko-web-p1-events/utils/create-namespace');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* p1events entities for the digital edition.
|
|
5
|
+
*
|
|
6
|
+
* One place builds every entity so the templates, the scroll reader and the browser
|
|
7
|
+
* trackers all describe the same thing the same way. Namespaces come from the p1events
|
|
8
|
+
* `create-namespace` util (tenant from the process), never a literal — this package
|
|
9
|
+
* ships to every tenant.
|
|
10
|
+
*
|
|
11
|
+
* issue base.<tenant>.magazine-issue id <issueId>
|
|
12
|
+
* article base.<tenant>.digital-edition-article id <issueId>.<slug>
|
|
13
|
+
* refs: issue, content (matched web article), authors
|
|
14
|
+
* ad base.<tenant>.digital-edition-ad id <issueId>.<adId>
|
|
15
|
+
* refs: issue, company (directory match)
|
|
16
|
+
*
|
|
17
|
+
* The article's `content` ref is the matched web article (when the ingest matched one),
|
|
18
|
+
* which is what lets reporting show print-edition reads beside web reads of a story.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const issueId = (manifest) => String(manifest.issueId);
|
|
22
|
+
const issueName = (manifest) => `${manifest.publication} ${manifest.name}`;
|
|
23
|
+
|
|
24
|
+
const issueEntity = (manifest) => ({
|
|
25
|
+
id: issueId(manifest),
|
|
26
|
+
ns: ns('magazine-issue'),
|
|
27
|
+
name: issueName(manifest),
|
|
28
|
+
props: {
|
|
29
|
+
issueId: issueId(manifest),
|
|
30
|
+
publication: manifest.publication,
|
|
31
|
+
issueName: manifest.name,
|
|
32
|
+
pageCount: manifest.pdf && manifest.pdf.pageCount,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const issueRef = (manifest) => ({ id: issueId(manifest), ns: ns('magazine-issue'), name: issueName(manifest) });
|
|
37
|
+
|
|
38
|
+
/** The content type segment of a Mindful content path: /section/article/12345/slug → article. */
|
|
39
|
+
const contentTypeFromPath = (path) => {
|
|
40
|
+
const m = /\/([a-z-]+)\/\d{5,}(?:\/|$)/.exec(String(path || ''));
|
|
41
|
+
return m ? m[1] : 'article';
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const articleEntity = (manifest, item) => {
|
|
45
|
+
const match = item.match && item.match.contentId ? item.match : null;
|
|
46
|
+
const author = item.authorNode && item.authorNode.id ? item.authorNode : null;
|
|
47
|
+
return {
|
|
48
|
+
id: `${issueId(manifest)}.${item.slug}`,
|
|
49
|
+
ns: ns('digital-edition-article'),
|
|
50
|
+
name: item.headline || item.slug,
|
|
51
|
+
props: {
|
|
52
|
+
issueId: issueId(manifest),
|
|
53
|
+
slug: item.slug,
|
|
54
|
+
printPage: item.printPage,
|
|
55
|
+
publication: manifest.publication,
|
|
56
|
+
issueName: manifest.name,
|
|
57
|
+
matched: Boolean(match),
|
|
58
|
+
},
|
|
59
|
+
refs: {
|
|
60
|
+
issue: issueRef(manifest),
|
|
61
|
+
content: match
|
|
62
|
+
? { id: String(match.contentId), ns: ns(`content-${contentTypeFromPath(match.path)}`), name: match.contentName || null }
|
|
63
|
+
: null,
|
|
64
|
+
authors: author ? [{ id: String(author.id), ns: ns('content-contact'), name: author.name }] : [],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const adEntity = (manifest, item, { placement = null } = {}) => ({
|
|
70
|
+
id: `${issueId(manifest)}.${item.id}`,
|
|
71
|
+
ns: ns('digital-edition-ad'),
|
|
72
|
+
name: item.advertiserName || item.id,
|
|
73
|
+
props: {
|
|
74
|
+
issueId: issueId(manifest),
|
|
75
|
+
page: item.printPage,
|
|
76
|
+
placement,
|
|
77
|
+
advertiser: item.advertiserName || null,
|
|
78
|
+
landingUrl: item.landingUrl || null,
|
|
79
|
+
},
|
|
80
|
+
refs: {
|
|
81
|
+
issue: issueRef(manifest),
|
|
82
|
+
company: item.company && item.company.id
|
|
83
|
+
? { id: String(item.company.id), ns: ns('content-company'), name: item.company.name }
|
|
84
|
+
: null,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** The `ns*id` form the p1events link listener reads from `data-p1-entity`. */
|
|
89
|
+
const entityAttr = (entity) => `${entity.ns}*${entity.id}`;
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
issueEntity,
|
|
93
|
+
articleEntity,
|
|
94
|
+
adEntity,
|
|
95
|
+
entityAttr,
|
|
96
|
+
contentTypeFromPath,
|
|
97
|
+
};
|