@rathnasgala2/template 3.0.1 → 3.1.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/README.md +13 -11
- package/contracts/render-policy.jcs +1 -1
- package/package.json +4 -3
- package/src/core/index.js +74 -9
- package/src/core/internal/appearance/base-layer.js +81 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +5 -3
- package/src/core/internal/icons.js +2 -0
- package/src/core/internal/interactions.js +349 -0
- package/src/core/internal/messages.js +6 -0
- package/src/core/internal/page-kinds.js +24 -1
- package/src/core/internal/render-policy-content.js +40 -5
- package/src/modules/interactions/browser/gala-interactions.js +1891 -0
- package/types/index.d.ts +30 -2
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader interactions, render side: validation of `build-input.modules.interactions`,
|
|
3
|
+
* the server-rendered `<section class="g-interactions">` page contract the
|
|
4
|
+
* browser script (`src/modules/interactions/browser/gala-interactions.js`)
|
|
5
|
+
* enhances, and the script asset itself. The template renders markup only;
|
|
6
|
+
* every request, token and comment lives in the browser script and the API.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
|
|
11
|
+
import { RenderPolicyViolationError } from '../errors.js';
|
|
12
|
+
import { escapeHtml } from './skeleton.js';
|
|
13
|
+
import { icon } from './icons.js';
|
|
14
|
+
|
|
15
|
+
/** @type {string} the emitted script's output-relative path */
|
|
16
|
+
export const INTERACTIONS_SCRIPT_PATH = 'assets/gala-interactions-v1.js';
|
|
17
|
+
|
|
18
|
+
/** @type {string} */
|
|
19
|
+
export const INTERACTIONS_SCRIPT_MEDIA_TYPE =
|
|
20
|
+
'application/javascript; charset=utf-8';
|
|
21
|
+
|
|
22
|
+
const INTERACTIONS_SCRIPT_SOURCE_URL = new URL(
|
|
23
|
+
'../../modules/interactions/browser/gala-interactions.js',
|
|
24
|
+
import.meta.url,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** @type {string} */
|
|
28
|
+
const CONFIG_SCHEMA_ID = 'urn:gala:schema:interactions-config:2.0.0';
|
|
29
|
+
|
|
30
|
+
/** The approved reaction emoji (design section 3). */
|
|
31
|
+
export const APPROVED_REACTION_EMOJI = Object.freeze([
|
|
32
|
+
'\u{1F44D}',
|
|
33
|
+
'❤️',
|
|
34
|
+
'\u{1F4A1}',
|
|
35
|
+
'\u{1F389}',
|
|
36
|
+
'\u{1F602}',
|
|
37
|
+
'\u{1F92F}',
|
|
38
|
+
'\u{1F64F}',
|
|
39
|
+
'\u{1F525}',
|
|
40
|
+
'\u{1F44F}',
|
|
41
|
+
'\u{1F62E}',
|
|
42
|
+
'\u{1F622}',
|
|
43
|
+
'\u{1F914}',
|
|
44
|
+
'✨',
|
|
45
|
+
'\u{1F680}',
|
|
46
|
+
'\u{1F4AF}',
|
|
47
|
+
'\u{1F440}',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {unknown} value any value
|
|
52
|
+
* @returns {value is Record<string, unknown>} whether it is a plain object
|
|
53
|
+
*/
|
|
54
|
+
function isRecord(value) {
|
|
55
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Throw unless `value` is an object with exactly the `allowed` keys (all
|
|
60
|
+
* required).
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} value candidate
|
|
63
|
+
* @param {readonly string[]} keys the exact key set
|
|
64
|
+
* @param {string} where location for the message
|
|
65
|
+
* @returns {Record<string, unknown>} the object
|
|
66
|
+
*/
|
|
67
|
+
function exactObject(value, keys, where) {
|
|
68
|
+
if (!isRecord(value)) {
|
|
69
|
+
throw new RenderPolicyViolationError(`${where} must be an object`);
|
|
70
|
+
}
|
|
71
|
+
for (const key of Object.keys(value)) {
|
|
72
|
+
if (!keys.includes(key)) {
|
|
73
|
+
throw new RenderPolicyViolationError(`${where}.${key} is not allowed`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const key of keys) {
|
|
77
|
+
if (!(key in value)) {
|
|
78
|
+
throw new RenderPolicyViolationError(`${where}.${key} is required`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {unknown} value candidate
|
|
86
|
+
* @param {string} where location for the message
|
|
87
|
+
* @returns {boolean} the boolean
|
|
88
|
+
*/
|
|
89
|
+
function boolean(value, where) {
|
|
90
|
+
if (typeof value !== 'boolean') {
|
|
91
|
+
throw new RenderPolicyViolationError(`${where} must be a boolean`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* An `https` origin, or an `http` origin on localhost / 127.0.0.1 (the local
|
|
98
|
+
* stack), with no path, query or fragment.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} value candidate
|
|
101
|
+
* @param {string} where location for the message
|
|
102
|
+
* @returns {string} the origin
|
|
103
|
+
*/
|
|
104
|
+
function origin(value, where) {
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(String(value));
|
|
108
|
+
} catch {
|
|
109
|
+
throw new RenderPolicyViolationError(`${where} must be an origin URL`);
|
|
110
|
+
}
|
|
111
|
+
const local =
|
|
112
|
+
parsed.protocol === 'http:' &&
|
|
113
|
+
(parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
|
|
114
|
+
if (
|
|
115
|
+
typeof value !== 'string' ||
|
|
116
|
+
parsed.origin !== value ||
|
|
117
|
+
(parsed.protocol !== 'https:' && !local)
|
|
118
|
+
) {
|
|
119
|
+
throw new RenderPolicyViolationError(
|
|
120
|
+
`${where} must be an https origin (or an http localhost origin) with no path`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate `build-input.modules.interactions` (design sections 3 and 4).
|
|
128
|
+
* `placements` stays empty and no other module is admitted; both are checked
|
|
129
|
+
* by the caller.
|
|
130
|
+
*
|
|
131
|
+
* @param {unknown} candidate the module selection value
|
|
132
|
+
* @returns {import('../../../types/index.d.ts').InteractionsModule} the
|
|
133
|
+
* validated, frozen-shape module
|
|
134
|
+
*/
|
|
135
|
+
export function validateInteractionsModule(candidate) {
|
|
136
|
+
const where = 'build-input.modules.interactions';
|
|
137
|
+
const module = exactObject(
|
|
138
|
+
candidate,
|
|
139
|
+
['config', 'apiOrigin', 'appOrigin'],
|
|
140
|
+
where,
|
|
141
|
+
);
|
|
142
|
+
const apiOrigin = origin(module.apiOrigin, `${where}.apiOrigin`);
|
|
143
|
+
const appOrigin = origin(module.appOrigin, `${where}.appOrigin`);
|
|
144
|
+
const config = exactObject(
|
|
145
|
+
module.config,
|
|
146
|
+
['schemaId', 'schemaVersion', 'reactions', 'comments', 'publicCounts'],
|
|
147
|
+
`${where}.config`,
|
|
148
|
+
);
|
|
149
|
+
if (
|
|
150
|
+
config.schemaId !== CONFIG_SCHEMA_ID ||
|
|
151
|
+
config.schemaVersion !== '2.0.0'
|
|
152
|
+
) {
|
|
153
|
+
throw new RenderPolicyViolationError(
|
|
154
|
+
`${where}.config.schemaId must be ${CONFIG_SCHEMA_ID} with schemaVersion 2.0.0`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const reactions = exactObject(
|
|
158
|
+
config.reactions,
|
|
159
|
+
['enabled', 'definitions'],
|
|
160
|
+
`${where}.config.reactions`,
|
|
161
|
+
);
|
|
162
|
+
boolean(reactions.enabled, `${where}.config.reactions.enabled`);
|
|
163
|
+
const definitions = reactions.definitions;
|
|
164
|
+
if (!Array.isArray(definitions) || definitions.length > 16) {
|
|
165
|
+
throw new RenderPolicyViolationError(
|
|
166
|
+
`${where}.config.reactions.definitions must be an array of at most 16`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const keys = new Set();
|
|
170
|
+
const orders = new Set();
|
|
171
|
+
definitions.forEach((definition, index) => {
|
|
172
|
+
const at = `${where}.config.reactions.definitions[${index}]`;
|
|
173
|
+
const item = exactObject(
|
|
174
|
+
definition,
|
|
175
|
+
['key', 'label', 'visual', 'order', 'enabled'],
|
|
176
|
+
at,
|
|
177
|
+
);
|
|
178
|
+
if (
|
|
179
|
+
typeof item.key !== 'string' ||
|
|
180
|
+
!/^[a-z][a-z0-9-]{1,31}$/.test(item.key) ||
|
|
181
|
+
item.key === 'like'
|
|
182
|
+
) {
|
|
183
|
+
throw new RenderPolicyViolationError(
|
|
184
|
+
`${at}.key must match ^[a-z][a-z0-9-]{1,31}$ and must not be "like"`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (keys.has(item.key)) {
|
|
188
|
+
throw new RenderPolicyViolationError(`${at}.key is duplicated`);
|
|
189
|
+
}
|
|
190
|
+
keys.add(item.key);
|
|
191
|
+
if (
|
|
192
|
+
typeof item.label !== 'string' ||
|
|
193
|
+
[...item.label].length < 1 ||
|
|
194
|
+
[...item.label].length > 32
|
|
195
|
+
) {
|
|
196
|
+
throw new RenderPolicyViolationError(
|
|
197
|
+
`${at}.label must be 1 to 32 characters`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const visual = exactObject(item.visual, ['kind', 'token'], `${at}.visual`);
|
|
201
|
+
if (
|
|
202
|
+
visual.kind !== 'emoji' ||
|
|
203
|
+
!APPROVED_REACTION_EMOJI.includes(/** @type {string} */ (visual.token))
|
|
204
|
+
) {
|
|
205
|
+
throw new RenderPolicyViolationError(
|
|
206
|
+
`${at}.visual must be an emoji from the approved list`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
!Number.isInteger(item.order) ||
|
|
211
|
+
/** @type {number} */ (item.order) < 1 ||
|
|
212
|
+
/** @type {number} */ (item.order) > 16
|
|
213
|
+
) {
|
|
214
|
+
throw new RenderPolicyViolationError(
|
|
215
|
+
`${at}.order must be an integer from 1 to 16`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (orders.has(item.order)) {
|
|
219
|
+
throw new RenderPolicyViolationError(`${at}.order is duplicated`);
|
|
220
|
+
}
|
|
221
|
+
orders.add(item.order);
|
|
222
|
+
boolean(item.enabled, `${at}.enabled`);
|
|
223
|
+
});
|
|
224
|
+
const comments = exactObject(
|
|
225
|
+
config.comments,
|
|
226
|
+
['enabled', 'allowReplies', 'maxDepth'],
|
|
227
|
+
`${where}.config.comments`,
|
|
228
|
+
);
|
|
229
|
+
boolean(comments.enabled, `${where}.config.comments.enabled`);
|
|
230
|
+
boolean(comments.allowReplies, `${where}.config.comments.allowReplies`);
|
|
231
|
+
if (
|
|
232
|
+
!Number.isInteger(comments.maxDepth) ||
|
|
233
|
+
/** @type {number} */ (comments.maxDepth) < 1 ||
|
|
234
|
+
/** @type {number} */ (comments.maxDepth) > 4
|
|
235
|
+
) {
|
|
236
|
+
throw new RenderPolicyViolationError(
|
|
237
|
+
`${where}.config.comments.maxDepth must be an integer from 1 to 4`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const counts = exactObject(
|
|
241
|
+
config.publicCounts,
|
|
242
|
+
['reactions', 'comments'],
|
|
243
|
+
`${where}.config.publicCounts`,
|
|
244
|
+
);
|
|
245
|
+
boolean(counts.reactions, `${where}.config.publicCounts.reactions`);
|
|
246
|
+
boolean(counts.comments, `${where}.config.publicCounts.comments`);
|
|
247
|
+
return /** @type {import('../../../types/index.d.ts').InteractionsModule} */ (
|
|
248
|
+
/** @type {unknown} */ ({ config, apiOrigin, appOrigin })
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Whether the module puts anything on article pages: it is present and at
|
|
254
|
+
* least one of reactions and comments is enabled.
|
|
255
|
+
*
|
|
256
|
+
* @param {import('../../../types/index.d.ts').InteractionsModule | undefined} module
|
|
257
|
+
* the validated module, if any
|
|
258
|
+
* @returns {boolean} whether article routes are interaction-bearing
|
|
259
|
+
*/
|
|
260
|
+
export function interactionsActive(module) {
|
|
261
|
+
return (
|
|
262
|
+
module !== undefined &&
|
|
263
|
+
(module.config.reactions.enabled || module.config.comments.enabled)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Render the `<section class="g-interactions">` for one article.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} input render input
|
|
271
|
+
* @param {import('../../../types/index.d.ts').InteractionsModule} input.module
|
|
272
|
+
* the validated module
|
|
273
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} input.messages
|
|
274
|
+
* the chrome message catalog
|
|
275
|
+
* @param {string} input.publicationId the build input's publication id
|
|
276
|
+
* @param {string} input.contentId the article's front matter id
|
|
277
|
+
* @param {string} input.canonicalUrl the absolute canonical article URL
|
|
278
|
+
* @returns {string} the section markup (empty when nothing is enabled)
|
|
279
|
+
*/
|
|
280
|
+
export function renderInteractionsSection({
|
|
281
|
+
module,
|
|
282
|
+
messages,
|
|
283
|
+
publicationId,
|
|
284
|
+
contentId,
|
|
285
|
+
canonicalUrl,
|
|
286
|
+
}) {
|
|
287
|
+
if (!interactionsActive(module)) return '';
|
|
288
|
+
const { config } = module;
|
|
289
|
+
const text = (/** @type {string} */ key) =>
|
|
290
|
+
escapeHtml(/** @type {string} */ (messages[key]));
|
|
291
|
+
const reactionButtons = config.reactions.enabled
|
|
292
|
+
? `<button type="button" class="g-reaction" data-reaction-key="like" aria-pressed="false" disabled>` +
|
|
293
|
+
`<span class="g-reaction__icon" aria-hidden="true">${icon('heart')}</span>` +
|
|
294
|
+
`<span class="g-reaction__label">${text('likeLabel')}</span>` +
|
|
295
|
+
`<span class="g-reaction__count" data-gala-count></span></button>` +
|
|
296
|
+
[...config.reactions.definitions]
|
|
297
|
+
.filter((definition) => definition.enabled)
|
|
298
|
+
.sort((a, b) => a.order - b.order)
|
|
299
|
+
.map(
|
|
300
|
+
(definition) =>
|
|
301
|
+
`<button type="button" class="g-reaction" data-reaction-key="${escapeHtml(definition.key)}" aria-pressed="false" disabled>` +
|
|
302
|
+
`<span class="g-reaction__icon" aria-hidden="true">${escapeHtml(definition.visual.token)}</span>` +
|
|
303
|
+
`<span class="g-reaction__label">${escapeHtml(definition.label)}</span>` +
|
|
304
|
+
`<span class="g-reaction__count" data-gala-count></span></button>`,
|
|
305
|
+
)
|
|
306
|
+
.join('')
|
|
307
|
+
: '';
|
|
308
|
+
const reactions = config.reactions.enabled
|
|
309
|
+
? `<div class="g-reactions" data-gala-reactions role="group" aria-label="${text('reactionsGroupLabel')}">${reactionButtons}</div>`
|
|
310
|
+
: '';
|
|
311
|
+
const comments = config.comments.enabled
|
|
312
|
+
? `<div class="g-comments" data-gala-comments>` +
|
|
313
|
+
`<h3 class="g-comments__title">${text('commentsHeading')} <span class="g-comments__count" data-gala-comment-count></span></h3>` +
|
|
314
|
+
`<p class="g-interactions__notice" data-gala-static-notice>${text('interactionsNeedScriptNotice')}</p>` +
|
|
315
|
+
`<div class="g-composer-slot" data-gala-composer-slot></div>` +
|
|
316
|
+
`<ol class="g-comment-list" data-gala-comment-list></ol>` +
|
|
317
|
+
`<button type="button" class="g-comments__more" data-gala-more hidden>${text('showMoreCommentsLabel')}</button>` +
|
|
318
|
+
`</div>`
|
|
319
|
+
: '';
|
|
320
|
+
const flag = (/** @type {boolean} */ value) => (value ? 'true' : 'false');
|
|
321
|
+
const staticNoticeForReactionsOnly =
|
|
322
|
+
!config.comments.enabled && config.reactions.enabled
|
|
323
|
+
? `<p class="g-interactions__notice" data-gala-static-notice>${text('interactionsNeedScriptNotice')}</p>`
|
|
324
|
+
: '';
|
|
325
|
+
return (
|
|
326
|
+
`<section class="g-interactions" id="comments" aria-labelledby="g-interactions-title" data-gala-interactions ` +
|
|
327
|
+
`data-api-origin="${escapeHtml(module.apiOrigin)}" data-app-origin="${escapeHtml(module.appOrigin)}" ` +
|
|
328
|
+
`data-publication-id="${escapeHtml(publicationId)}" data-content-id="${escapeHtml(contentId)}" ` +
|
|
329
|
+
`data-canonical-url="${escapeHtml(canonicalUrl)}" ` +
|
|
330
|
+
`data-reactions-enabled="${flag(config.reactions.enabled)}" data-comments-enabled="${flag(config.comments.enabled)}" ` +
|
|
331
|
+
`data-count-reactions="${flag(config.publicCounts.reactions)}" data-count-comments="${flag(config.publicCounts.comments)}" ` +
|
|
332
|
+
`data-allow-replies="${flag(config.comments.allowReplies)}" data-max-depth="${config.comments.maxDepth}">` +
|
|
333
|
+
`<h2 id="g-interactions-title" class="g-interactions__title">${text('interactionsHeading')}</h2>` +
|
|
334
|
+
reactions +
|
|
335
|
+
staticNoticeForReactionsOnly +
|
|
336
|
+
comments +
|
|
337
|
+
`<div class="g-interactions__status" role="status" aria-live="polite" data-gala-status></div>` +
|
|
338
|
+
`</section>`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Read the browser script's bytes from the template package.
|
|
344
|
+
*
|
|
345
|
+
* @returns {Promise<Buffer>} the script bytes
|
|
346
|
+
*/
|
|
347
|
+
export function loadInteractionsScript() {
|
|
348
|
+
return readFile(INTERACTIONS_SCRIPT_SOURCE_URL);
|
|
349
|
+
}
|
|
@@ -95,6 +95,12 @@ const CATALOGS = Object.freeze({
|
|
|
95
95
|
keepReadingHeading: 'Keep reading',
|
|
96
96
|
/** @type {(tag: string) => string} */
|
|
97
97
|
moreInTagLabel: (tag) => `More in ${tag}`,
|
|
98
|
+
interactionsHeading: 'Responses',
|
|
99
|
+
reactionsGroupLabel: 'Reactions',
|
|
100
|
+
likeLabel: 'Like',
|
|
101
|
+
commentsHeading: 'Conversation',
|
|
102
|
+
interactionsNeedScriptNotice: 'Reactions and comments need JavaScript.',
|
|
103
|
+
showMoreCommentsLabel: 'Show more comments',
|
|
98
104
|
shareLabel: 'Share',
|
|
99
105
|
copyLinkLabel: 'Copy link to this article',
|
|
100
106
|
saveForLaterLabel: 'Save for later',
|
|
@@ -70,6 +70,10 @@ import {
|
|
|
70
70
|
import { truncateAtWord } from './seo.js';
|
|
71
71
|
import { createStructuredData } from './structured-data.js';
|
|
72
72
|
import { icon } from './icons.js';
|
|
73
|
+
import {
|
|
74
|
+
interactionsActive,
|
|
75
|
+
renderInteractionsSection,
|
|
76
|
+
} from './interactions.js';
|
|
73
77
|
import { getMessages } from './messages.js';
|
|
74
78
|
import { resolveTextDirection } from './text-direction.js';
|
|
75
79
|
import { deriveAuthorSlugs, routeSegmentForLabel } from './route-labels.js';
|
|
@@ -176,6 +180,8 @@ function paginate(items, pageSize) {
|
|
|
176
180
|
* one place that can turn this into an absolute derivative URL, because
|
|
177
181
|
* only it has run the media pipeline and knows the derivative
|
|
178
182
|
* output path a given `sourceDigest` produced)
|
|
183
|
+
* @property {boolean} [interactionBearing] whether this page carries the
|
|
184
|
+
* reader-interactions section (article pages when the module is on)
|
|
179
185
|
* @property {string} [robotsContent] an optional
|
|
180
186
|
* `<meta name="robots">` content value (e.g. `'noindex, follow'` for
|
|
181
187
|
* `status: "unlisted"` content); omitted entirely for ordinarily indexable
|
|
@@ -352,11 +358,17 @@ export function homeDocumentTitle(name, tagline) {
|
|
|
352
358
|
* processed derivative renders as the placeholder/monogram fallback
|
|
353
359
|
* @param {Readonly<Record<string, {width: number, height: number}>>} [options.mediaDimensions]
|
|
354
360
|
* the media pipeline's pixel dimensions per derivative path
|
|
361
|
+
* @param {import('../../../types/index.d.ts').InteractionsModule} [options.interactions]
|
|
362
|
+
* the validated interactions module; when set, every article page carries
|
|
363
|
+
* the interactions section and is interaction-bearing
|
|
355
364
|
* @returns {GeneratedPage[]} every generated page, in a stable, deterministic
|
|
356
365
|
* order
|
|
357
366
|
*/
|
|
358
367
|
export function buildGeneratedPages(validatedInput, options = {}) {
|
|
359
368
|
const { publication, authors, content, basePath, baseUrl } = validatedInput;
|
|
369
|
+
const interactionsModule = interactionsActive(options.interactions)
|
|
370
|
+
? options.interactions
|
|
371
|
+
: undefined;
|
|
360
372
|
const publicBasePath = derivePublicBasePath(baseUrl, basePath);
|
|
361
373
|
const messages = getMessages(publication.defaultLanguage);
|
|
362
374
|
const authorsById = new Map(authors.map((author) => [author.id, author]));
|
|
@@ -788,6 +800,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
|
|
|
788
800
|
|
|
789
801
|
let foot = '';
|
|
790
802
|
let after = '';
|
|
803
|
+
let interactions = '';
|
|
791
804
|
if (isArticle) {
|
|
792
805
|
const authorCards = ui
|
|
793
806
|
.recordAuthors(record)
|
|
@@ -835,7 +848,16 @@ export function buildGeneratedPages(validatedInput, options = {}) {
|
|
|
835
848
|
newer || older
|
|
836
849
|
? `<nav class="g-pager" aria-label="${text('moreArticlesNavigationLabel')}">${newer ? pagerLink(newer, false) : ''}${older ? pagerLink(older, true) : ''}</nav>`
|
|
837
850
|
: '';
|
|
838
|
-
|
|
851
|
+
interactions = interactionsModule
|
|
852
|
+
? renderInteractionsSection({
|
|
853
|
+
module: interactionsModule,
|
|
854
|
+
messages,
|
|
855
|
+
publicationId: publication.id,
|
|
856
|
+
contentId: frontmatter.id,
|
|
857
|
+
canonicalUrl: absolute(recordHref(record)),
|
|
858
|
+
})
|
|
859
|
+
: '';
|
|
860
|
+
foot = `<div class="g-wrap g-article-foot">${authorCards}${interactions}${seriesBox}${pager}</div>`;
|
|
839
861
|
const related = selectRelatedArticles(record, publishedArticles);
|
|
840
862
|
after =
|
|
841
863
|
related.length > 0
|
|
@@ -904,6 +926,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
|
|
|
904
926
|
: undefined,
|
|
905
927
|
robotsContent:
|
|
906
928
|
frontmatter.status === 'unlisted' ? 'noindex, follow' : undefined,
|
|
929
|
+
interactionBearing: Boolean(interactions),
|
|
907
930
|
lastModified: contentLastModified(frontmatter),
|
|
908
931
|
bodyHtml:
|
|
909
932
|
`<article class="g-article">${isArticle ? '<div class="g-progress" aria-hidden="true"></div>' : ''}${head}${cover}` +
|
|
@@ -239,6 +239,14 @@ export const ADMITTED_LANGUAGE_CLASSES = Object.freeze(
|
|
|
239
239
|
HIGHLIGHT_GRAMMARS.map((grammar) => `language-${grammar}`),
|
|
240
240
|
);
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The production Galascribe API origin. The blog CSP always allows it in
|
|
244
|
+
* `connect-src` (reader interactions design D8), interactions on or off.
|
|
245
|
+
*
|
|
246
|
+
* @type {string}
|
|
247
|
+
*/
|
|
248
|
+
export const PRODUCTION_API_ORIGIN = 'https://api.galascribe.com';
|
|
249
|
+
|
|
242
250
|
/**
|
|
243
251
|
* The CSP specification explicitly ignores `frame-ancestors`
|
|
244
252
|
* (and `report-uri`/`sandbox`, neither used here) when a policy is
|
|
@@ -262,16 +270,19 @@ export const CSP_HEADER_ONLY_DIRECTIVES = Object.freeze([
|
|
|
262
270
|
* `<meta http-equiv="Content-Security-Policy">` tag: {@link CSP_HEADER_ONLY_DIRECTIVES}
|
|
263
271
|
* is excluded. Directive order is byte-exact, minus
|
|
264
272
|
* the excluded directive; every directive's value list is already sorted
|
|
265
|
-
* bytewise and duplicate-free
|
|
266
|
-
*
|
|
267
|
-
* byte-identical on every route
|
|
273
|
+
* bytewise and duplicate-free. `connect-src` always admits the production
|
|
274
|
+
* Galascribe API origin (reader interactions, whether or not the module is
|
|
275
|
+
* on), so this string is byte-identical on every route of every production
|
|
276
|
+
* build. Only the local stack's differing interactions `apiOrigin` is
|
|
277
|
+
* appended per build ({@link contentSecurityPolicyMeta}).
|
|
268
278
|
*
|
|
269
279
|
* @type {string}
|
|
270
280
|
*/
|
|
271
281
|
export const CONTENT_SECURITY_POLICY_META_BASELINE =
|
|
272
282
|
"default-src 'none'; base-uri 'none'; object-src 'none'; " +
|
|
273
283
|
"form-action 'none'; script-src 'self'; " +
|
|
274
|
-
"style-src 'self'; img-src 'self'; font-src 'self';
|
|
284
|
+
"style-src 'self'; img-src 'self'; font-src 'self'; " +
|
|
285
|
+
`connect-src 'self' ${PRODUCTION_API_ORIGIN}; ` +
|
|
275
286
|
"media-src 'self'; manifest-src 'self'; worker-src 'none'";
|
|
276
287
|
|
|
277
288
|
/**
|
|
@@ -287,9 +298,33 @@ export const CONTENT_SECURITY_POLICY_META_BASELINE =
|
|
|
287
298
|
export const CONTENT_SECURITY_POLICY_COMPLETE =
|
|
288
299
|
"default-src 'none'; base-uri 'none'; object-src 'none'; " +
|
|
289
300
|
"frame-ancestors 'none'; form-action 'none'; script-src 'self'; " +
|
|
290
|
-
"style-src 'self'; img-src 'self'; font-src 'self';
|
|
301
|
+
"style-src 'self'; img-src 'self'; font-src 'self'; " +
|
|
302
|
+
`connect-src 'self' ${PRODUCTION_API_ORIGIN}; ` +
|
|
291
303
|
"media-src 'self'; manifest-src 'self'; worker-src 'none'";
|
|
292
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The meta-safe CSP for one build. Identical to
|
|
307
|
+
* {@link CONTENT_SECURITY_POLICY_META_BASELINE} unless the interactions
|
|
308
|
+
* module names a different API origin (local stack only), in which case that
|
|
309
|
+
* origin is added to `connect-src`, sorted bytewise with the other sources
|
|
310
|
+
* and de-duplicated.
|
|
311
|
+
*
|
|
312
|
+
* @param {string | undefined} [apiOrigin] the interactions module's
|
|
313
|
+
* `apiOrigin`, when the module is on
|
|
314
|
+
* @returns {string} the CSP directive string for the `<meta>` tag
|
|
315
|
+
*/
|
|
316
|
+
export function contentSecurityPolicyMeta(apiOrigin) {
|
|
317
|
+
if (apiOrigin === undefined || apiOrigin === PRODUCTION_API_ORIGIN) {
|
|
318
|
+
return CONTENT_SECURITY_POLICY_META_BASELINE;
|
|
319
|
+
}
|
|
320
|
+
const sources = [...new Set(["'self'", PRODUCTION_API_ORIGIN, apiOrigin])];
|
|
321
|
+
sources.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
322
|
+
return CONTENT_SECURITY_POLICY_META_BASELINE.replace(
|
|
323
|
+
`connect-src 'self' ${PRODUCTION_API_ORIGIN};`,
|
|
324
|
+
`connect-src ${sources.join(' ')};`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
293
328
|
/**
|
|
294
329
|
* Build the exact plain-object document `contracts/render-policy.jcs`
|
|
295
330
|
* publishes: `name`, `version` byte-equal to the template lock's
|