@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.
@@ -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
+ }