@rathnasgala2/template 3.0.1 → 3.2.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 +144 -49
- package/contracts/render-policy.jcs +1 -1
- package/package.json +4 -3
- package/src/core/index.js +122 -17
- package/src/core/internal/appearance/base-layer.js +82 -0
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/eleventy-render.js +5 -3
- package/src/core/internal/feeds.js +8 -18
- package/src/core/internal/icons.js +2 -0
- package/src/core/internal/interactions.js +349 -0
- package/src/core/internal/media/content-images.js +262 -0
- package/src/core/internal/media/gif-probe.js +162 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +21 -0
- package/src/core/internal/media/pipeline.js +73 -40
- package/src/core/internal/media/sniff.js +15 -2
- package/src/core/internal/messages.js +16 -1
- package/src/core/internal/page-kinds.js +121 -26
- package/src/core/internal/render-policy-content.js +40 -5
- package/src/core/internal/skeleton.js +25 -4
- package/src/core/internal/source-inventory.js +4 -0
- package/src/modules/interactions/browser/gala-interactions.js +2176 -0
- package/types/index.d.ts +61 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content images: the `<img>` elements a rendered body carries.
|
|
3
|
+
*
|
|
4
|
+
* A `renderableBody.body` is already render-policy-conformant HTML, so its
|
|
5
|
+
* only image form is `<img src="..." alt="...">` with a repository-relative
|
|
6
|
+
* `src` (`content-security.js` admits no scheme for `img[src]` and strips
|
|
7
|
+
* the `src` of a remote image). This module resolves every such `src`
|
|
8
|
+
* against the image assets `build-input` inventories for that document
|
|
9
|
+
* (`content[].media[]`, each a digest-pinned repository file), before
|
|
10
|
+
* anything is written, and fails closed on any reference that names no
|
|
11
|
+
* inventoried image: a body image that cannot be resolved never reaches a
|
|
12
|
+
* page as a broken `<img>`. The resolved references join the media
|
|
13
|
+
* pipeline's own reference set (`pipeline.js`), and the pages and feeds
|
|
14
|
+
* then replace each `<img>` with markup naming the pipeline's derivatives
|
|
15
|
+
* (`components.js#createContentImage`); the source file itself is never
|
|
16
|
+
* copied into the output.
|
|
17
|
+
*
|
|
18
|
+
* Resolution rule (a build-input producer inventories a document's images
|
|
19
|
+
* by the same rule): the `src` attribute is entity-decoded;
|
|
20
|
+
* a scheme, a protocol-relative `//`, a query or a fragment never resolves;
|
|
21
|
+
* one leading `/` or `./` is dropped (the path is repository-root-relative
|
|
22
|
+
* either way); the rest is percent-decoded and NFC-normalized; an empty,
|
|
23
|
+
* `.` or `..` segment never resolves; the result must equal the `path` of an
|
|
24
|
+
* image entry (`image/png`, `image/jpeg`, `image/webp`, `image/avif`,
|
|
25
|
+
* `image/gif`) in the same document's `media[]`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
BuildInputValidationError,
|
|
30
|
+
RenderPolicyViolationError,
|
|
31
|
+
} from '../../errors.js';
|
|
32
|
+
import { unescapeHtml } from '../skeleton.js';
|
|
33
|
+
|
|
34
|
+
/** @typedef {{path: string, sourceDigest: string}} ImageReference */
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One body this module scans, with the image assets it may reference.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {object} BodyScope
|
|
40
|
+
* @property {string} pointer the body's JSON pointer inside `build-input`
|
|
41
|
+
* @property {string} sourcePath the body's own repository source path
|
|
42
|
+
* @property {string} body the body HTML
|
|
43
|
+
* @property {ReadonlyMap<string, ImageReference>} inventory the image
|
|
44
|
+
* assets this body may reference, keyed by repository path
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** The `media[]` media types a body image may resolve to. */
|
|
48
|
+
const IMAGE_MEDIA_TYPES = new Set([
|
|
49
|
+
'image/png',
|
|
50
|
+
'image/jpeg',
|
|
51
|
+
'image/webp',
|
|
52
|
+
'image/avif',
|
|
53
|
+
'image/gif',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Every `<img>` element of a policy-conformant body. */
|
|
57
|
+
const IMG_ELEMENT = /<img\b[^>]*>/g;
|
|
58
|
+
|
|
59
|
+
/** One double-quoted attribute (the only form sanitized HTML carries). */
|
|
60
|
+
const ATTRIBUTE = /\s([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
|
|
61
|
+
|
|
62
|
+
/** Where a reader of an unresolved-reference diagnostic finds the rule. */
|
|
63
|
+
const DOCUMENTATION_URL =
|
|
64
|
+
'https://github.com/rathnasgala2/template#content-images';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} element one `<img ...>` element
|
|
68
|
+
* @returns {{src?: string, alt?: string}} its raw `src` and `alt` values
|
|
69
|
+
* (still entity-encoded, exactly as they appear in the body)
|
|
70
|
+
*/
|
|
71
|
+
function attributesOf(element) {
|
|
72
|
+
/** @type {{src?: string, alt?: string}} */
|
|
73
|
+
const attributes = {};
|
|
74
|
+
for (const [, name, value] of element.matchAll(ATTRIBUTE)) {
|
|
75
|
+
if (name === 'src' || name === 'alt') attributes[name] = value;
|
|
76
|
+
}
|
|
77
|
+
return attributes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Turn a raw `src` attribute value into the repository-relative path it
|
|
82
|
+
* names, or `undefined` when it is not a repository-relative reference.
|
|
83
|
+
*
|
|
84
|
+
* @param {string | undefined} rawSource the attribute value as it appears
|
|
85
|
+
* in the body
|
|
86
|
+
* @returns {string | undefined} the NFC repository-relative path
|
|
87
|
+
*/
|
|
88
|
+
export function repositoryPathOfImageSource(rawSource) {
|
|
89
|
+
if (rawSource === undefined) return undefined;
|
|
90
|
+
const source = unescapeHtml(rawSource);
|
|
91
|
+
if (
|
|
92
|
+
source === '' ||
|
|
93
|
+
source.startsWith('//') ||
|
|
94
|
+
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(source) ||
|
|
95
|
+
/[?#\\]/.test(source)
|
|
96
|
+
) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const relative = source.startsWith('/')
|
|
100
|
+
? source.slice(1)
|
|
101
|
+
: source.startsWith('./')
|
|
102
|
+
? source.slice(2)
|
|
103
|
+
: source;
|
|
104
|
+
let decoded;
|
|
105
|
+
try {
|
|
106
|
+
decoded = decodeURIComponent(relative).normalize('NFC');
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const segments = decoded.split('/');
|
|
111
|
+
if (
|
|
112
|
+
decoded.includes('\\') ||
|
|
113
|
+
decoded.includes('\u0000') ||
|
|
114
|
+
segments.some((segment) => ['', '.', '..'].includes(segment))
|
|
115
|
+
) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
return decoded;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {readonly {path: string, sourceDigest: string, mediaType: string}[] | undefined} media
|
|
123
|
+
* one document's `media[]`
|
|
124
|
+
* @returns {Map<string, ImageReference>} its image entries, keyed by path
|
|
125
|
+
*/
|
|
126
|
+
function inventoryOf(media) {
|
|
127
|
+
/** @type {Map<string, ImageReference>} */
|
|
128
|
+
const inventory = new Map();
|
|
129
|
+
for (const entry of media ?? []) {
|
|
130
|
+
if (IMAGE_MEDIA_TYPES.has(entry.mediaType)) {
|
|
131
|
+
inventory.set(entry.path, {
|
|
132
|
+
path: entry.path,
|
|
133
|
+
sourceDigest: entry.sourceDigest,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return inventory;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every body this renderer renders, with the image assets each may
|
|
142
|
+
* reference: the publication profile, the footer card when enabled, and
|
|
143
|
+
* every content record. The profile and footer card have no media inventory
|
|
144
|
+
* of their own, so an image in either never resolves.
|
|
145
|
+
*
|
|
146
|
+
* @param {import('../../../../types/index.d.ts').NormalizedBuildInput} buildInput
|
|
147
|
+
* the validated build input
|
|
148
|
+
* @returns {BodyScope[]} every body, in document order
|
|
149
|
+
*/
|
|
150
|
+
function bodyScopes(buildInput) {
|
|
151
|
+
const { profile, footerCard } = buildInput.publication;
|
|
152
|
+
/** @type {BodyScope[]} */
|
|
153
|
+
const scopes = [];
|
|
154
|
+
if (profile) {
|
|
155
|
+
scopes.push({
|
|
156
|
+
pointer: '/publication/profile/body/body',
|
|
157
|
+
sourcePath: profile.body.sourcePath,
|
|
158
|
+
body: profile.body.body,
|
|
159
|
+
inventory: new Map(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (footerCard?.enabled) {
|
|
163
|
+
scopes.push({
|
|
164
|
+
pointer: '/publication/footerCard/body/body',
|
|
165
|
+
sourcePath: footerCard.body.sourcePath,
|
|
166
|
+
body: footerCard.body.body,
|
|
167
|
+
inventory: new Map(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
buildInput.content.forEach((record, index) => {
|
|
171
|
+
scopes.push({
|
|
172
|
+
pointer: `/content/${index}/body`,
|
|
173
|
+
sourcePath: record.sourcePath,
|
|
174
|
+
body: record.body,
|
|
175
|
+
inventory: inventoryOf(record.media),
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
return scopes;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {BodyScope} scope the body the reference sits in
|
|
183
|
+
* @param {string | undefined} rawSource the reference's raw `src` value
|
|
184
|
+
* @returns {Record<string, string>} one `MEDIA_REFERENCE_UNRESOLVED`
|
|
185
|
+
* diagnostic, shaped like a schema validation diagnostic
|
|
186
|
+
*/
|
|
187
|
+
function unresolvedDiagnostic(scope, rawSource) {
|
|
188
|
+
const reference = rawSource === undefined ? '' : unescapeHtml(rawSource);
|
|
189
|
+
return {
|
|
190
|
+
code: 'MEDIA_REFERENCE_UNRESOLVED',
|
|
191
|
+
severity: 'ERROR',
|
|
192
|
+
instancePointer: scope.pointer,
|
|
193
|
+
actualValueClass: 'string',
|
|
194
|
+
rule: 'template:content-image-reference',
|
|
195
|
+
reference,
|
|
196
|
+
remediation:
|
|
197
|
+
reference === ''
|
|
198
|
+
? `An image in ${scope.sourcePath} has no repository source (remote images are not published). Commit the image and reference it by its repository-relative path, or remove it.`
|
|
199
|
+
: `The image "${reference}" in ${scope.sourcePath} names no image file inventoried for that document. Commit the image and reference it by its repository-relative path, or remove it.`,
|
|
200
|
+
documentationUrl: DOCUMENTATION_URL,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Resolve every image every body references, before anything is written.
|
|
206
|
+
* Fails closed with one `MEDIA_REFERENCE_UNRESOLVED` diagnostic per image
|
|
207
|
+
* that names no inventoried image asset.
|
|
208
|
+
*
|
|
209
|
+
* @param {import('../../../../types/index.d.ts').NormalizedBuildInput} buildInput
|
|
210
|
+
* the validated, render-policy-verified build input
|
|
211
|
+
* @returns {Map<string, ImageReference>} each distinct raw `src` value to
|
|
212
|
+
* the asset it resolves to
|
|
213
|
+
* @throws {BuildInputValidationError} when any body image is unresolved
|
|
214
|
+
*/
|
|
215
|
+
export function resolveContentImages(buildInput) {
|
|
216
|
+
/** @type {Map<string, ImageReference>} */
|
|
217
|
+
const resolved = new Map();
|
|
218
|
+
/** @type {Record<string, string>[]} */
|
|
219
|
+
const diagnostics = [];
|
|
220
|
+
for (const scope of bodyScopes(buildInput)) {
|
|
221
|
+
for (const element of scope.body.match(IMG_ELEMENT) ?? []) {
|
|
222
|
+
const { src } = attributesOf(element);
|
|
223
|
+
const path = repositoryPathOfImageSource(src);
|
|
224
|
+
const reference =
|
|
225
|
+
path === undefined ? undefined : scope.inventory.get(path);
|
|
226
|
+
if (src === undefined || reference === undefined) {
|
|
227
|
+
diagnostics.push(unresolvedDiagnostic(scope, src));
|
|
228
|
+
} else if (!resolved.has(src)) {
|
|
229
|
+
resolved.set(src, reference);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (diagnostics.length > 0) {
|
|
234
|
+
throw new BuildInputValidationError(diagnostics);
|
|
235
|
+
}
|
|
236
|
+
return resolved;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Replace every `<img>` in a body with the markup `render` produces for the
|
|
241
|
+
* asset its `src` resolved to.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} body a policy-conformant body
|
|
244
|
+
* @param {ReadonlyMap<string, ImageReference>} resolved the result of
|
|
245
|
+
* {@link resolveContentImages} for the same build input
|
|
246
|
+
* @param {(reference: ImageReference, altAttribute: string) => string} render
|
|
247
|
+
* the replacement markup for one image, given its raw (still
|
|
248
|
+
* entity-encoded) `alt` value
|
|
249
|
+
* @returns {string} the body with every image replaced
|
|
250
|
+
*/
|
|
251
|
+
export function rewriteBodyImages(body, resolved, render) {
|
|
252
|
+
return body.replace(IMG_ELEMENT, (element) => {
|
|
253
|
+
const { src, alt } = attributesOf(element);
|
|
254
|
+
const reference = src === undefined ? undefined : resolved.get(src);
|
|
255
|
+
if (reference === undefined) {
|
|
256
|
+
throw new RenderPolicyViolationError(
|
|
257
|
+
`a body image (${src ?? 'no src'}) reached rendering unresolved; this is a renderer defect`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return render(reference, alt ?? '');
|
|
261
|
+
});
|
|
262
|
+
}
|