@rathnasgala2/template 3.0.0 → 3.1.0

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