@rathnasgala2/template 3.1.0 → 3.2.1

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,297 @@
1
+ /**
2
+ * Editions: repository documents of `kind: "edition"` that restate one
3
+ * article at another depth (a quick read, a standard edition, a deep dive).
4
+ *
5
+ * An edition is an ordinary rendered document, not a module: the build input
6
+ * already carries it as a `content[]` record whose front matter names the
7
+ * article it restates (`edition.of`, the article's slug) and its depth
8
+ * (`edition.kind`). Whether the edition is current (generated from the
9
+ * article's present text) is decided upstream, before the build input exists,
10
+ * so an edition that arrives here is rendered as given.
11
+ *
12
+ * What this renderer does with one:
13
+ *
14
+ * - it renders at `<article route>/<segment>` where the segment is
15
+ * `quick-read`, `standard` or `deep-dive`, built from `edition.of`
16
+ * ({@link editionRoute}), so `/post` has `/post/quick-read` beside it;
17
+ * - its `<link rel="canonical">` names the article and its robots directive is
18
+ * `noindex, follow`, so the article stays the indexed reference;
19
+ * - it never appears in a listing, tag or series page, a feed, the sitemap, the
20
+ * search index or the `llms` documents (each of those selects `kind:
21
+ * "article"` / `"page"` records, or skips `noindex` pages);
22
+ * - the article and each of its editions carry the same accessible selector,
23
+ * a `<nav aria-label="Editions">` of links in the core `edition-selector`
24
+ * slot, listing the original and each edition that exists, the current page
25
+ * marked `aria-current="page"`; an edition page also carries a one-line
26
+ * label saying the page is a generated edition and the original is the
27
+ * reference.
28
+ *
29
+ * Styling is the base layer's: the selector uses the existing pill hooks, so
30
+ * the published theme styling contract does not change.
31
+ *
32
+ * An edition that cannot be placed is a malformed build input and fails closed
33
+ * before anything is written ({@link resolveEditions}).
34
+ */
35
+
36
+ import { BuildInputValidationError } from '../errors.js';
37
+ import { icon } from './icons.js';
38
+ import { escapeHtml, renderSlot } from './skeleton.js';
39
+
40
+ /** @typedef {'QUICK_READ' | 'STANDARD' | 'DEEP_DIVE'} EditionKind */
41
+
42
+ /**
43
+ * One edition placed beside its article.
44
+ *
45
+ * @typedef {object} EditionEntry
46
+ * @property {import('../../../types/index.d.ts').ContentBuildRecord} record
47
+ * the edition document
48
+ * @property {import('../../../types/index.d.ts').ContentBuildRecord} original
49
+ * the article it restates
50
+ * @property {EditionKind} kind its depth
51
+ * @property {string} route its un-joined route
52
+ */
53
+
54
+ /**
55
+ * Every edition of a build input.
56
+ *
57
+ * @typedef {object} EditionResolution
58
+ * @property {ReadonlyMap<import('../../../types/index.d.ts').ContentBuildRecord, EditionEntry>} byRecord
59
+ * each edition record's placement
60
+ * @property {ReadonlyMap<import('../../../types/index.d.ts').ContentBuildRecord, readonly EditionEntry[]>} byOriginal
61
+ * each article's editions, in selector order (quick read, standard, deep dive)
62
+ */
63
+
64
+ /**
65
+ * The edition kinds in the order the selector lists them.
66
+ *
67
+ * @type {readonly EditionKind[]}
68
+ */
69
+ export const EDITION_KINDS = Object.freeze([
70
+ 'QUICK_READ',
71
+ 'STANDARD',
72
+ 'DEEP_DIVE',
73
+ ]);
74
+
75
+ /** @type {Readonly<Record<EditionKind, string>>} */
76
+ const EDITION_SEGMENTS = Object.freeze({
77
+ QUICK_READ: 'quick-read',
78
+ STANDARD: 'standard',
79
+ DEEP_DIVE: 'deep-dive',
80
+ });
81
+
82
+ /** @type {Readonly<Record<EditionKind, string>>} */
83
+ const EDITION_LABEL_KEYS = Object.freeze({
84
+ QUICK_READ: 'editionQuickReadLabel',
85
+ STANDARD: 'editionStandardLabel',
86
+ DEEP_DIVE: 'editionDeepDiveLabel',
87
+ });
88
+
89
+ /** Where a reader of an edition diagnostic finds the rule. */
90
+ const DOCUMENTATION_URL = 'https://github.com/rathnasgala2/template#editions';
91
+
92
+ /**
93
+ * @param {{of: string, kind: EditionKind}} edition an edition's front-matter
94
+ * `edition` object
95
+ * @returns {string} the edition's un-joined route, `/<of>/<segment>`
96
+ */
97
+ export function editionRoute(edition) {
98
+ return `/${edition.of}/${EDITION_SEGMENTS[edition.kind]}`;
99
+ }
100
+
101
+ /**
102
+ * @param {EditionKind} kind an edition kind
103
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
104
+ * the resolved message catalog
105
+ * @returns {string} the kind's plain-text label ("Quick read")
106
+ */
107
+ export function editionKindLabel(kind, messages) {
108
+ return /** @type {string} */ (messages[EDITION_LABEL_KEYS[kind]]);
109
+ }
110
+
111
+ /**
112
+ * @param {string} code the diagnostic code
113
+ * @param {number} index the record's index in `content`
114
+ * @param {string} sourcePath the record's repository source path
115
+ * @param {string} remediation what the author or producer should change
116
+ * @returns {Record<string, string>} one diagnostic, shaped like a schema
117
+ * validation diagnostic
118
+ */
119
+ function diagnostic(code, index, sourcePath, remediation) {
120
+ return {
121
+ code,
122
+ severity: 'ERROR',
123
+ instancePointer: `/content/${index}`,
124
+ actualValueClass: 'object',
125
+ rule: 'template:edition',
126
+ sourcePath,
127
+ remediation,
128
+ documentationUrl: DOCUMENTATION_URL,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Place every edition beside the article it restates, before anything is
134
+ * written. Fails closed with one diagnostic per edition that cannot be placed:
135
+ * its `edition.of` names no `kind: "article"` document in this build
136
+ * (`EDITION_ORIGINAL_UNRESOLVED`), two editions share an article and a depth
137
+ * (`EDITION_DUPLICATE`), or its route is already another document's route or
138
+ * redirect (`EDITION_ROUTE_CONFLICT`).
139
+ *
140
+ * @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} content
141
+ * the build input's `content[]`
142
+ * @param {(frontmatter: import('../../../types/index.d.ts').ContentFrontmatterNormalized) => string} routeOf
143
+ * a document's own un-joined route
144
+ * @returns {EditionResolution} every edition's placement
145
+ * @throws {BuildInputValidationError} when an edition cannot be placed
146
+ */
147
+ export function resolveEditions(content, routeOf) {
148
+ /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord>} */
149
+ const articles = new Map();
150
+ /** @type {Set<string>} */
151
+ const occupied = new Set();
152
+ for (const record of content) {
153
+ const { frontmatter } = record;
154
+ if (frontmatter.kind === 'edition') continue;
155
+ if (frontmatter.kind === 'article') articles.set(frontmatter.slug, record);
156
+ occupied.add(routeOf(frontmatter));
157
+ for (const source of frontmatter.redirects) occupied.add(source);
158
+ }
159
+
160
+ /** @type {Map<import('../../../types/index.d.ts').ContentBuildRecord, EditionEntry>} */
161
+ const byRecord = new Map();
162
+ /** @type {Map<import('../../../types/index.d.ts').ContentBuildRecord, EditionEntry[]>} */
163
+ const byOriginal = new Map();
164
+ /** @type {Set<string>} */
165
+ const claimed = new Set();
166
+ /** @type {Record<string, string>[]} */
167
+ const diagnostics = [];
168
+
169
+ content.forEach((record, index) => {
170
+ const { frontmatter } = record;
171
+ if (frontmatter.kind !== 'edition') return;
172
+ const { edition } = frontmatter;
173
+ const original = edition ? articles.get(edition.of) : undefined;
174
+ if (!edition || !original) {
175
+ diagnostics.push(
176
+ diagnostic(
177
+ 'EDITION_ORIGINAL_UNRESOLVED',
178
+ index,
179
+ record.sourcePath,
180
+ `The edition ${record.sourcePath} restates "${edition?.of ?? ''}", which is not an article in this build. Publish the article with it, or remove the edition.`,
181
+ ),
182
+ );
183
+ return;
184
+ }
185
+ const route = editionRoute(edition);
186
+ if (occupied.has(route)) {
187
+ diagnostics.push(
188
+ diagnostic(
189
+ 'EDITION_ROUTE_CONFLICT',
190
+ index,
191
+ record.sourcePath,
192
+ `The edition ${record.sourcePath} would render at ${route}, which another document already uses. Rename or move that document.`,
193
+ ),
194
+ );
195
+ return;
196
+ }
197
+ if (claimed.has(route)) {
198
+ diagnostics.push(
199
+ diagnostic(
200
+ 'EDITION_DUPLICATE',
201
+ index,
202
+ record.sourcePath,
203
+ `The article "${edition.of}" already has a ${edition.kind} edition. Keep one.`,
204
+ ),
205
+ );
206
+ return;
207
+ }
208
+ claimed.add(route);
209
+ /** @type {EditionEntry} */
210
+ const entry = { record, original, kind: edition.kind, route };
211
+ byRecord.set(record, entry);
212
+ byOriginal.set(original, [...(byOriginal.get(original) ?? []), entry]);
213
+ });
214
+
215
+ if (diagnostics.length > 0) throw new BuildInputValidationError(diagnostics);
216
+
217
+ for (const [original, entries] of byOriginal) {
218
+ byOriginal.set(
219
+ original,
220
+ entries.sort(
221
+ (a, b) => EDITION_KINDS.indexOf(a.kind) - EDITION_KINDS.indexOf(b.kind),
222
+ ),
223
+ );
224
+ }
225
+ return { byRecord, byOriginal };
226
+ }
227
+
228
+ /**
229
+ * The editions a reader is offered next to an article: every published one,
230
+ * plus the one being read. An `unlisted` edition stays reachable by its own
231
+ * address but is not advertised.
232
+ *
233
+ * @param {readonly EditionEntry[] | undefined} entries every edition of the
234
+ * article, in selector order
235
+ * @param {EditionEntry | undefined} current the edition being read, if any
236
+ * @returns {EditionEntry[]} the editions to list
237
+ */
238
+ export function listedEditions(entries, current) {
239
+ return (entries ?? []).filter(
240
+ (entry) =>
241
+ entry === current || entry.record.frontmatter.status === 'published',
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Render the `edition-selector` slot for an article or one of its editions.
247
+ *
248
+ * @param {object} options rendering options
249
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
250
+ * the resolved message catalog
251
+ * @param {string} options.originalHref the article's joined route
252
+ * @param {readonly EditionEntry[]} options.entries the editions to list, in
253
+ * selector order ({@link listedEditions})
254
+ * @param {EditionEntry | undefined} options.current the edition being read;
255
+ * `undefined` on the article itself
256
+ * @param {(route: string) => string} options.site joins a route with the
257
+ * public base path
258
+ * @returns {string} the slot, or an empty string when there is no edition to
259
+ * offer (a slot with nothing to say is not rendered)
260
+ */
261
+ export function renderEditionSelector({
262
+ messages,
263
+ originalHref,
264
+ entries,
265
+ current,
266
+ site,
267
+ }) {
268
+ if (entries.length === 0) return '';
269
+ const text = (/** @type {string} */ key) =>
270
+ escapeHtml(/** @type {string} */ (messages[key]));
271
+ const item = (
272
+ /** @type {string} */ href,
273
+ /** @type {string} */ label,
274
+ /** @type {string} */ iconName,
275
+ /** @type {boolean} */ isCurrent,
276
+ ) =>
277
+ `<li><a class="g-pill" href="${escapeHtml(href)}"${isCurrent ? ' aria-current="page"' : ''}>` +
278
+ `${icon(iconName)}<span>${label}</span></a></li>`;
279
+ const links = [
280
+ item(originalHref, text('editionOriginalLabel'), 'pen', !current),
281
+ ...entries.map((entry) =>
282
+ item(
283
+ site(entry.route),
284
+ escapeHtml(editionKindLabel(entry.kind, messages)),
285
+ 'sparkle',
286
+ entry === current,
287
+ ),
288
+ ),
289
+ ].join('');
290
+ return renderSlot('edition-selector', {
291
+ html:
292
+ `<nav aria-label="${text('editionsNavigationLabel')}"><ul class="g-pills">${links}</ul></nav>` +
293
+ (current
294
+ ? `<p class="g-label">${icon('sparkle')}${text('editionNoticeLabel')}</p>`
295
+ : ''),
296
+ });
297
+ }
@@ -18,7 +18,7 @@
18
18
  * media pipeline scope decisions in the package README).
19
19
  */
20
20
 
21
- import { escapeHtml } from './skeleton.js';
21
+ import { escapeHtml, unescapeHtml } from './skeleton.js';
22
22
  import { derivePublicBasePath, joinPublicRoute } from './route.js';
23
23
  import { deriveAuthorSlugs } from './route-labels.js';
24
24
  import {
@@ -48,19 +48,6 @@ function toRfc822(rfc3339) {
48
48
  return new Date(rfc3339).toUTCString();
49
49
  }
50
50
 
51
- /**
52
- * @param {string} value an attribute value as it appears in HTML source
53
- * @returns {string} the value with the five predefined entities decoded
54
- */
55
- function decodeAttribute(value) {
56
- return value
57
- .replace(/&quot;/g, '"')
58
- .replace(/&#39;/g, "'")
59
- .replace(/&lt;/g, '<')
60
- .replace(/&gt;/g, '>')
61
- .replace(/&amp;/g, '&');
62
- }
63
-
64
51
  /**
65
52
  * Make every `href`/`src` in an article body absolute against the article's
66
53
  * own URL: a feed reader renders the body away from the site, so a relative
@@ -75,7 +62,7 @@ export function absolutizeBodyLinks(bodyHtml, pageUrl) {
75
62
  /\b(href|src)="([^"]*)"/g,
76
63
  (match, attribute, value) => {
77
64
  try {
78
- return `${attribute}="${escapeHtml(new URL(decodeAttribute(value), pageUrl).toString())}"`;
65
+ return `${attribute}="${escapeHtml(new URL(unescapeHtml(value), pageUrl).toString())}"`;
79
66
  } catch {
80
67
  return match;
81
68
  }
@@ -99,11 +86,14 @@ function cdata(text) {
99
86
  * validated `build-input:2.0.0` instance.
100
87
  *
101
88
  * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
89
+ * @param {object} options feed options
90
+ * @param {(body: string) => string} options.feedBody turns an article body
91
+ * into its feed form (every image naming its media derivative)
102
92
  * @returns {{atomXml: string, rssXml: string, atomSelfUrl: string, rssSelfUrl: string}}
103
93
  * both feed documents' exact UTF-8 text, plus each feed's own absolute
104
94
  * self URL (for the shared layout's feed-discovery `<link>` tags)
105
95
  */
106
- export function buildFeeds(validatedInput) {
96
+ export function buildFeeds(validatedInput, { feedBody }) {
107
97
  const { publication, authors, content, basePath, baseUrl } = validatedInput;
108
98
  const authorsById = new Map(authors.map((author) => [author.id, author]));
109
99
  const authorSlugs = deriveAuthorSlugs(authors);
@@ -144,7 +134,7 @@ export function buildFeeds(validatedInput) {
144
134
  const categoriesXml = frontmatter.tags
145
135
  .map((tag) => `<category term="${escapeHtml(tag)}"/>`)
146
136
  .join('');
147
- const contentXml = `<content type="html">${escapeHtml(absolutizeBodyLinks(record.body, entryUrl))}</content>`;
137
+ const contentXml = `<content type="html">${escapeHtml(absolutizeBodyLinks(feedBody(record.body), entryUrl))}</content>`;
148
138
  const summaryXml = frontmatter.description
149
139
  ? `<summary>${escapeHtml(frontmatter.description)}</summary>`
150
140
  : '';
@@ -200,7 +190,7 @@ export function buildFeeds(validatedInput) {
200
190
  creatorsXml +
201
191
  categoriesXml +
202
192
  descriptionXml +
203
- `<content:encoded>${cdata(absolutizeBodyLinks(record.body, itemUrl))}</content:encoded>` +
193
+ `<content:encoded>${cdata(absolutizeBodyLinks(feedBody(record.body), itemUrl))}</content:encoded>` +
204
194
  `</item>`
205
195
  );
206
196
  })
@@ -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
+ }