@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.
- package/README.md +136 -43
- package/package.json +2 -2
- package/src/core/index.js +48 -8
- package/src/core/internal/appearance/base-layer.js +31 -2
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/feeds.js +8 -18
- 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 +10 -1
- package/src/core/internal/page-kinds.js +97 -25
- 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 +370 -37
- package/types/index.d.ts +31 -1
|
@@ -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(/"/g, '"')
|
|
58
|
-
.replace(/'/g, "'")
|
|
59
|
-
.replace(/</g, '<')
|
|
60
|
-
.replace(/>/g, '>')
|
|
61
|
-
.replace(/&/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(
|
|
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
|
+
}
|