@rathnasgala2/template 2.2.0 → 3.0.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.
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { escapeHtml } from './skeleton.js';
22
22
  import { derivePublicBasePath, joinPublicRoute } from './route.js';
23
+ import { deriveAuthorSlugs } from './route-labels.js';
23
24
  import {
24
25
  contentLastModified,
25
26
  contentRoute,
@@ -47,6 +48,52 @@ function toRfc822(rfc3339) {
47
48
  return new Date(rfc3339).toUTCString();
48
49
  }
49
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(/&lt;/g, '<')
60
+ .replace(/&gt;/g, '>')
61
+ .replace(/&amp;/g, '&');
62
+ }
63
+
64
+ /**
65
+ * Make every `href`/`src` in an article body absolute against the article's
66
+ * own URL: a feed reader renders the body away from the site, so a relative
67
+ * link or image would otherwise point nowhere.
68
+ *
69
+ * @param {string} bodyHtml sanitized body HTML
70
+ * @param {string} pageUrl the article's absolute URL
71
+ * @returns {string} the body with absolute links and image sources
72
+ */
73
+ export function absolutizeBodyLinks(bodyHtml, pageUrl) {
74
+ return bodyHtml.replace(
75
+ /\b(href|src)="([^"]*)"/g,
76
+ (match, attribute, value) => {
77
+ try {
78
+ return `${attribute}="${escapeHtml(new URL(decodeAttribute(value), pageUrl).toString())}"`;
79
+ } catch {
80
+ return match;
81
+ }
82
+ },
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Wrap text in a CDATA section, splitting any `]]>` it contains so the
88
+ * section can never be closed early.
89
+ *
90
+ * @param {string} text
91
+ * @returns {string}
92
+ */
93
+ function cdata(text) {
94
+ return `<![CDATA[${text.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`;
95
+ }
96
+
50
97
  /**
51
98
  * Build the deterministic Atom 1.0 and RSS 2.0 feed documents for one
52
99
  * validated `build-input:2.0.0` instance.
@@ -59,6 +106,7 @@ function toRfc822(rfc3339) {
59
106
  export function buildFeeds(validatedInput) {
60
107
  const { publication, authors, content, basePath, baseUrl } = validatedInput;
61
108
  const authorsById = new Map(authors.map((author) => [author.id, author]));
109
+ const authorSlugs = deriveAuthorSlugs(authors);
62
110
  const articles = selectPublishedArticles(content).slice(0, FEED_ITEM_LIMIT);
63
111
 
64
112
  /**
@@ -84,12 +132,19 @@ export function buildFeeds(validatedInput) {
84
132
  .map((record) => {
85
133
  const { frontmatter } = record;
86
134
  const entryUrl = absoluteUrl(contentRoute(frontmatter));
87
- const authorNames = frontmatter.authorIds.map(
88
- (id) => authorsById.get(id)?.displayName ?? '',
89
- );
90
- const authorsXml = authorNames
91
- .map((name) => `<author><name>${escapeHtml(name)}</name></author>`)
135
+ const authorsXml = frontmatter.authorIds
136
+ .map((id) => {
137
+ const author = authorsById.get(id);
138
+ const uri = author
139
+ ? `<uri>${escapeHtml(absoluteUrl(`/authors/${authorSlugs.get(id) ?? id}`))}</uri>`
140
+ : '';
141
+ return `<author><name>${escapeHtml(author?.displayName ?? '')}</name>${uri}</author>`;
142
+ })
143
+ .join('');
144
+ const categoriesXml = frontmatter.tags
145
+ .map((tag) => `<category term="${escapeHtml(tag)}"/>`)
92
146
  .join('');
147
+ const contentXml = `<content type="html">${escapeHtml(absolutizeBodyLinks(record.body, entryUrl))}</content>`;
93
148
  const summaryXml = frontmatter.description
94
149
  ? `<summary>${escapeHtml(frontmatter.description)}</summary>`
95
150
  : '';
@@ -101,7 +156,9 @@ export function buildFeeds(validatedInput) {
101
156
  `<published>${escapeHtml(frontmatter.publishedAt)}</published>` +
102
157
  `<link href="${escapeHtml(entryUrl)}" rel="alternate"/>` +
103
158
  authorsXml +
159
+ categoriesXml +
104
160
  summaryXml +
161
+ contentXml +
105
162
  `</entry>`
106
163
  );
107
164
  })
@@ -126,13 +183,24 @@ export function buildFeeds(validatedInput) {
126
183
  const descriptionXml = frontmatter.description
127
184
  ? `<description>${escapeHtml(frontmatter.description)}</description>`
128
185
  : '';
186
+ const creatorsXml = frontmatter.authorIds
187
+ .map((id) => authorsById.get(id)?.displayName)
188
+ .filter(Boolean)
189
+ .map((name) => `<dc:creator>${escapeHtml(String(name))}</dc:creator>`)
190
+ .join('');
191
+ const categoriesXml = frontmatter.tags
192
+ .map((tag) => `<category>${escapeHtml(tag)}</category>`)
193
+ .join('');
129
194
  return (
130
195
  `<item>` +
131
196
  `<title>${escapeHtml(frontmatter.title)}</title>` +
132
197
  `<link>${escapeHtml(itemUrl)}</link>` +
133
198
  `<guid isPermaLink="true">${escapeHtml(itemUrl)}</guid>` +
134
199
  `<pubDate>${escapeHtml(toRfc822(frontmatter.publishedAt))}</pubDate>` +
200
+ creatorsXml +
201
+ categoriesXml +
135
202
  descriptionXml +
203
+ `<content:encoded>${cdata(absolutizeBodyLinks(record.body, itemUrl))}</content:encoded>` +
136
204
  `</item>`
137
205
  );
138
206
  })
@@ -146,7 +214,7 @@ export function buildFeeds(validatedInput) {
146
214
  // review request).
147
215
  const rssXml =
148
216
  '<?xml version="1.0" encoding="UTF-8"?>\n' +
149
- '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' +
217
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
150
218
  '<channel>' +
151
219
  `<title>${escapeHtml(publication.title)}</title>` +
152
220
  `<link>${escapeHtml(siteUrl)}</link>` +
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The template's own fixed inline icon set. Chrome (header, footer, cards,
3
+ * article furniture) is template-owned markup, never author content, so
4
+ * these inline `<svg>` elements are emitted straight into the page and never
5
+ * pass through the author-content sanitizer in `content-security.js`. Every
6
+ * icon is decorative (`aria-hidden`, not focusable); a control that carries
7
+ * only an icon always has its own accessible name from the message catalog.
8
+ * No external icon file, font or network request is involved.
9
+ *
10
+ * Path data is the approved design reference's icon set (24x24 grid,
11
+ * stroke-based; fill/stroke colours come from the base layer, never here).
12
+ */
13
+
14
+ /** @type {Readonly<Record<string, string>>} */
15
+ export const ICONS = Object.freeze({
16
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
17
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
18
+ monitor:
19
+ '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
20
+ moon: '<path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11z"/>',
21
+ arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
22
+ back: '<path d="M19 12H5M11 6l-6 6 6 6"/>',
23
+ upright: '<path d="M7 17 17 7M8 7h9v9"/>',
24
+ chevron: '<path d="m9 6 6 6-6 6"/>',
25
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
26
+ rss: '<path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.2"/>',
27
+ code: '<path d="m8 8-5 4 5 4M16 8l5 4-5 4"/>',
28
+ mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
29
+ link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
30
+ bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
31
+ menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
32
+ hash: '<path d="M5 9h14M5 15h14M10 4 8 20M16 4l-2 16"/>',
33
+ book: '<path d="M3 5h6a3 3 0 0 1 3 3v12a2 2 0 0 0-2-2H3zM21 5h-6a3 3 0 0 0-3 3v12a2 2 0 0 1 2-2h7z"/>',
34
+ sparkle:
35
+ '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 16l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/>',
36
+ list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
37
+ layers: '<path d="m12 3 9 5-9 5-9-5z"/><path d="m3 13 9 5 9-5"/>',
38
+ pen: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/>',
39
+ });
40
+
41
+ /**
42
+ * Render one inline icon.
43
+ *
44
+ * @param {string} name a key of {@link ICONS}
45
+ * @returns {string} the `<svg>` markup, or an empty string for an unknown
46
+ * name (a defect the tests catch, never an author-reachable path)
47
+ */
48
+ export function icon(name) {
49
+ const paths = ICONS[name];
50
+ if (paths === undefined) return '';
51
+ return `<svg class="g-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`;
52
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * `llms.txt` and `llms-full.txt` generation (the llmstxt.org format): a
3
+ * Markdown index of the publication for language-model assistants, and one
4
+ * plain-text file carrying every article in full.
5
+ *
6
+ * Only `status: "published"` content is listed. Unlisted content is
7
+ * reachable by direct link and marked `noindex`; it is never advertised
8
+ * here either.
9
+ */
10
+
11
+ import { derivePublicBasePath, joinPublicRoute } from './route.js';
12
+ import { routeSegmentForLabel } from './route-labels.js';
13
+ import { compareUtf8Bytes } from './source-inventory.js';
14
+ import {
15
+ contentLastModified,
16
+ contentRoute,
17
+ selectPublishedArticles,
18
+ } from './page-kinds.js';
19
+ import { getMessages } from './messages.js';
20
+
21
+ /** @type {Readonly<Record<string, string>>} */
22
+ const NAMED_ENTITIES = Object.freeze({
23
+ amp: '&',
24
+ lt: '<',
25
+ gt: '>',
26
+ quot: '"',
27
+ apos: "'",
28
+ nbsp: ' ',
29
+ hellip: '…',
30
+ mdash: '—',
31
+ ndash: '–',
32
+ lsquo: '‘',
33
+ rsquo: '’',
34
+ ldquo: '“',
35
+ rdquo: '”',
36
+ });
37
+
38
+ /**
39
+ * Convert a sanitized HTML fragment to readable plain text. Tags are removed
40
+ * first and entities decoded afterwards, so escaped markup in the source
41
+ * stays literal text and can never be turned back into a tag.
42
+ *
43
+ * @param {string} html a render-policy-conformant HTML fragment
44
+ * @returns {string} plain text with paragraph breaks, `#` headings and `-`
45
+ * list items
46
+ */
47
+ export function htmlToText(html) {
48
+ const stripped = html
49
+ .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '')
50
+ .replace(/<br\s*\/?>/gi, '\n')
51
+ .replace(
52
+ /<h([1-6])\b[^>]*>/gi,
53
+ (_match, level) => `\n\n${'#'.repeat(Number(level))} `,
54
+ )
55
+ .replace(/<li\b[^>]*>/gi, '\n- ')
56
+ .replace(
57
+ /<\/(?:p|div|h[1-6]|pre|blockquote|ul|ol|table|tr|figure|section)\s*>/gi,
58
+ '\n\n',
59
+ )
60
+ .replace(/<[^>]*>/g, '');
61
+ return stripped
62
+ .replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body) => {
63
+ if (body[0] === '#') {
64
+ const code =
65
+ body[1] === 'x' || body[1] === 'X'
66
+ ? Number.parseInt(body.slice(2), 16)
67
+ : Number.parseInt(body.slice(1), 10);
68
+ return Number.isInteger(code) && code > 0 && code <= 0x10ffff
69
+ ? String.fromCodePoint(code)
70
+ : '';
71
+ }
72
+ return NAMED_ENTITIES[body.toLowerCase()] ?? match;
73
+ })
74
+ .replace(/[ \t]+\n/g, '\n')
75
+ .replace(/\n{3,}/g, '\n\n')
76
+ .trim();
77
+ }
78
+
79
+ /**
80
+ * Build both documents.
81
+ *
82
+ * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
83
+ * @param {object} urls absolute URLs of the sibling documents
84
+ * @param {string} urls.rss
85
+ * @param {string} urls.atom
86
+ * @param {string} urls.sitemap
87
+ * @param {string} urls.searchIndex
88
+ * @param {string} urls.full this file's `llms-full.txt` sibling
89
+ * @returns {{llmsTxt: string, llmsFullTxt: string}} the exact UTF-8 texts
90
+ */
91
+ export function buildLlmsDocuments(validatedInput, urls) {
92
+ const { publication, authors, content, basePath, baseUrl } = validatedInput;
93
+ const messages = getMessages(publication.defaultLanguage);
94
+ const authorsById = new Map(authors.map((author) => [author.id, author]));
95
+ /**
96
+ * @param {string} route an un-joined route
97
+ * @returns {string} the absolute URL
98
+ */
99
+ const absoluteUrl = (route) =>
100
+ new URL(
101
+ joinPublicRoute(derivePublicBasePath(baseUrl, basePath), route),
102
+ baseUrl,
103
+ ).toString();
104
+ const oneLine = (/** @type {string} */ value) =>
105
+ value.replace(/\s+/g, ' ').trim();
106
+
107
+ const articles = selectPublishedArticles(content);
108
+ const pages = content
109
+ .filter(
110
+ (record) =>
111
+ record.frontmatter.kind === 'page' &&
112
+ record.frontmatter.status === 'published',
113
+ )
114
+ .sort((a, b) =>
115
+ compareUtf8Bytes(
116
+ contentRoute(a.frontmatter),
117
+ contentRoute(b.frontmatter),
118
+ ),
119
+ );
120
+
121
+ /**
122
+ * @param {{title: string, url: string, description?: string}} item
123
+ * @returns {string} one list line
124
+ */
125
+ const link = ({ title, url, description }) =>
126
+ `- [${oneLine(title).replace(/[[\]]/g, '')}](${url})` +
127
+ (description ? `: ${oneLine(description)}` : '');
128
+
129
+ /** @type {Map<string, number>} */
130
+ const seriesCounts = new Map();
131
+ for (const record of [...articles, ...pages]) {
132
+ const series = record.frontmatter.series;
133
+ if (series) seriesCounts.set(series, (seriesCounts.get(series) ?? 0) + 1);
134
+ }
135
+
136
+ const authorNames = authors.map((author) => author.displayName);
137
+ const intro =
138
+ `${articles.length === 1 ? '1 article' : `${articles.length} articles`}` +
139
+ (authorNames.length > 0 ? ` by ${authorNames.join(', ')}` : '') +
140
+ `. Each article is a standalone page; the full text of every article is in llms-full.txt.`;
141
+
142
+ /** @type {string[]} */
143
+ const lines = [
144
+ `# ${oneLine(publication.title)}`,
145
+ '',
146
+ `> ${oneLine(publication.description)}`,
147
+ '',
148
+ intro,
149
+ '',
150
+ '## Articles',
151
+ '',
152
+ ...articles.map((record) =>
153
+ link({
154
+ title: record.frontmatter.title,
155
+ url: absoluteUrl(contentRoute(record.frontmatter)),
156
+ description: record.frontmatter.description,
157
+ }),
158
+ ),
159
+ ];
160
+ if (pages.length > 0) {
161
+ lines.push(
162
+ '',
163
+ '## Pages',
164
+ '',
165
+ ...pages.map((record) =>
166
+ link({
167
+ title: record.frontmatter.title,
168
+ url: absoluteUrl(contentRoute(record.frontmatter)),
169
+ description: record.frontmatter.description,
170
+ }),
171
+ ),
172
+ );
173
+ }
174
+ if (seriesCounts.size > 0) {
175
+ lines.push(
176
+ '',
177
+ '## Series',
178
+ '',
179
+ ...[...seriesCounts.keys()].sort().map((name) =>
180
+ link({
181
+ title: name,
182
+ url: absoluteUrl(`/series/${routeSegmentForLabel(name)}`),
183
+ description:
184
+ /** @type {(c: string, n: string, p: string) => string} */ (
185
+ messages.seriesPageDescription
186
+ )(String(seriesCounts.get(name)), name, publication.title),
187
+ }),
188
+ ),
189
+ );
190
+ }
191
+ lines.push(
192
+ '',
193
+ '## Optional',
194
+ '',
195
+ ...(articles.length > 0
196
+ ? [
197
+ link({
198
+ title: 'Archive',
199
+ url: absoluteUrl('/archive'),
200
+ description: 'Every article by year',
201
+ }),
202
+ ]
203
+ : []),
204
+ link({
205
+ title: 'Full text of every article',
206
+ url: urls.full,
207
+ description: 'Plain text',
208
+ }),
209
+ link({ title: 'RSS feed', url: urls.rss }),
210
+ link({ title: 'Atom feed', url: urls.atom }),
211
+ link({ title: 'Sitemap', url: urls.sitemap }),
212
+ link({
213
+ title: 'Search index',
214
+ url: urls.searchIndex,
215
+ description: 'JSON',
216
+ }),
217
+ );
218
+
219
+ const fullSections = articles.map((record) => {
220
+ const { frontmatter } = record;
221
+ const names = frontmatter.authorIds
222
+ .map((id) => authorsById.get(id)?.displayName)
223
+ .filter(Boolean);
224
+ return [
225
+ `# ${oneLine(frontmatter.title)}`,
226
+ '',
227
+ `URL: ${absoluteUrl(contentRoute(frontmatter))}`,
228
+ `Published: ${frontmatter.publishedAt}`,
229
+ ...(frontmatter.updatedAt
230
+ ? [`Updated: ${contentLastModified(frontmatter)}`]
231
+ : []),
232
+ ...(names.length > 0 ? [`Author: ${names.join(', ')}`] : []),
233
+ ...(frontmatter.tags.length > 0
234
+ ? [`Tags: ${frontmatter.tags.join(', ')}`]
235
+ : []),
236
+ ...(frontmatter.description
237
+ ? ['', `> ${oneLine(frontmatter.description)}`]
238
+ : []),
239
+ '',
240
+ htmlToText(record.body),
241
+ ].join('\n');
242
+ });
243
+ const llmsFullTxt =
244
+ [
245
+ `# ${oneLine(publication.title)}`,
246
+ '',
247
+ `> ${oneLine(publication.description)}`,
248
+ ...fullSections.flatMap((section) => ['', '---', '', section]),
249
+ ].join('\n') + '\n';
250
+
251
+ return { llmsTxt: `${lines.join('\n')}\n`, llmsFullTxt };
252
+ }
@@ -192,7 +192,9 @@ function derivativePath(sourceDigest, variant, extension) {
192
192
  *
193
193
  * @param {Buffer} bytes verified source bytes
194
194
  * @param {ImageReference} reference the reference these bytes back
195
- * @returns {PipelineOutputFile[]} the output files this reference produces
195
+ * @returns {{files: PipelineOutputFile[], width: number, height: number}}
196
+ * the output files this reference produces and the canonical original's
197
+ * pixel dimensions
196
198
  */
197
199
  function processImage(bytes, reference) {
198
200
  const format = sniffMediaFormat(bytes);
@@ -214,18 +216,21 @@ function processImage(bytes, reference) {
214
216
  if (format === 'webp' || format === 'avif') {
215
217
  // Bounded container/dimension validation only; see webp-probe.js and
216
218
  // avif-probe.js module documentation for the documented scope decision.
217
- if (format === 'webp') {
218
- probeWebp(bytes, reference.path);
219
- } else {
220
- probeAvif(bytes, reference.path);
221
- }
219
+ const size =
220
+ format === 'webp'
221
+ ? probeWebp(bytes, reference.path)
222
+ : probeAvif(bytes, reference.path);
222
223
  const extension = EXTENSION_BY_MEDIA_TYPE[MEDIA_TYPE_BY_FORMAT[format]];
223
- return [
224
- {
225
- path: derivativePath(reference.sourceDigest, 'original', extension),
226
- bytes,
227
- },
228
- ];
224
+ return {
225
+ files: [
226
+ {
227
+ path: derivativePath(reference.sourceDigest, 'original', extension),
228
+ bytes,
229
+ },
230
+ ],
231
+ width: size.width,
232
+ height: size.height,
233
+ };
229
234
  }
230
235
 
231
236
  /** @type {{width: number, height: number, rgba: Buffer}} */
@@ -261,7 +266,7 @@ function processImage(bytes, reference) {
261
266
  bytes: encode(resized),
262
267
  });
263
268
  }
264
- return outputs;
269
+ return { files: outputs, width: oriented.width, height: oriented.height };
265
270
  }
266
271
 
267
272
  /**
@@ -272,8 +277,11 @@ function processImage(bytes, reference) {
272
277
  * the validated build input
273
278
  * @param {{sourceDirectory: string}} options the media pipeline's own
274
279
  * options
275
- * @returns {Promise<{assets: import('../../../../types/index.d.ts').ManifestAssetEntry[], files: PipelineOutputFile[]}>}
276
- * the asset manifest rows and the output files backing them
280
+ * @returns {Promise<{assets: import('../../../../types/index.d.ts').ManifestAssetEntry[], files: PipelineOutputFile[], dimensions: Record<string, {width: number, height: number}>}>}
281
+ * the asset manifest rows, the output files backing them, and the pixel
282
+ * dimensions of each image's canonical original keyed by its output path
283
+ * (`manifestAsset` has no width/height field, so this is the one place the
284
+ * renderer learns them, for `<img width height>` and structured data)
277
285
  */
278
286
  export async function processMedia(buildInput, { sourceDirectory }) {
279
287
  const imageReferences = collectImageReferences(buildInput);
@@ -295,14 +303,21 @@ export async function processMedia(buildInput, { sourceDirectory }) {
295
303
 
296
304
  /** @type {PipelineOutputFile[]} */
297
305
  const files = [];
306
+ /** @type {Record<string, {width: number, height: number}>} */
307
+ const dimensions = {};
298
308
  let totalBytes = 0;
299
309
 
300
310
  for (const reference of imageReferences) {
301
311
  const bytes = await readAndVerify(sourceDirectory, reference);
302
- for (const output of processImage(bytes, reference)) {
312
+ const processed = processImage(bytes, reference);
313
+ for (const output of processed.files) {
303
314
  totalBytes += output.bytes.byteLength;
304
315
  files.push(output);
305
316
  }
317
+ dimensions[processed.files[0].path] = {
318
+ width: processed.width,
319
+ height: processed.height,
320
+ };
306
321
  }
307
322
 
308
323
  for (const reference of fontReferences) {
@@ -349,5 +364,5 @@ export async function processMedia(buildInput, { sourceDirectory }) {
349
364
  });
350
365
  });
351
366
 
352
- return { assets, files };
367
+ return { assets, files, dimensions };
353
368
  }
@@ -56,6 +56,110 @@ const CATALOGS = Object.freeze({
56
56
  appearanceModeLightLabel: 'Light',
57
57
  appearanceModeDarkLabel: 'Dark',
58
58
  appearanceModeSystemLabel: 'System',
59
+ searchButtonLabel: 'Search',
60
+ searchButtonAriaLabel: 'Search articles',
61
+ searchPlaceholderLabel: 'Search the publication',
62
+ searchEmptyLabel: 'No articles match your search.',
63
+ searchUnavailableLabel: 'Search is unavailable right now.',
64
+ copyCodeLabel: 'Copy',
65
+ copiedLabel: 'Copied',
66
+ linkCopiedLabel: 'Link copied.',
67
+ copyBlockedLabel:
68
+ 'Copying is blocked here. Copy the address from your browser.',
69
+ codeSelectedLabel: 'Code selected. Press Ctrl C or Command C to copy.',
70
+ savedLabel: 'Saved for later.',
71
+ unsavedLabel: 'Removed from saved articles.',
72
+ menuSummaryLabel: 'Menu',
73
+ featuredLabel: 'Featured',
74
+ readEssayLabel: 'Read the essay',
75
+ latestHeading: 'Latest',
76
+ moreArticlesHeading: 'More articles',
77
+ browseByTagLabel: 'Browse by tag',
78
+ seriesCardLabel: 'Series',
79
+ /** @type {(count: string) => string} */
80
+ partsCountLabel: (count) => `${count} parts`,
81
+ startSeriesLabel: 'Start with part 1',
82
+ newsletterLabel: 'Newsletter',
83
+ newsletterActionLabel: 'Subscribe',
84
+ /** @type {(minutes: string) => string} */
85
+ readingTimeLabel: (minutes) => `${minutes} min read`,
86
+ /** @type {(part: string, total: string) => string} */
87
+ seriesPartLabel: (part, total) => `Part ${part} of ${total}`,
88
+ onThisPageLabel: 'On this page',
89
+ writtenByLabel: 'Written by',
90
+ aboutTheAuthorLabel: 'About the author',
91
+ youAreHereLabel: 'You are here',
92
+ newerArticleLabel: 'Newer',
93
+ olderArticleLabel: 'Older',
94
+ moreArticlesNavigationLabel: 'More articles',
95
+ keepReadingHeading: 'Keep reading',
96
+ /** @type {(tag: string) => string} */
97
+ moreInTagLabel: (tag) => `More in ${tag}`,
98
+ shareLabel: 'Share',
99
+ copyLinkLabel: 'Copy link to this article',
100
+ saveForLaterLabel: 'Save for later',
101
+ tagListLabel: 'Tags',
102
+ tagKickerLabel: 'Tag',
103
+ seriesKickerLabel: 'Series',
104
+ archiveKickerLabel: 'Archive',
105
+ authorKickerLabel: 'Author',
106
+ /** @type {(count: string) => string} */
107
+ articleCountLabel: (count) =>
108
+ count === '1' ? '1 article' : `${count} articles`,
109
+ emptyListingLabel: 'No articles yet.',
110
+ aboutPageTitle: 'About',
111
+ /** @type {(date: string) => string} */
112
+ updatedLabel: (date) => `Updated ${date}`,
113
+ /** @type {(tag: string) => string} */
114
+ tagPageTitle: (tag) => `Posts tagged \u201c${tag}\u201d`,
115
+ /** @type {(count: string, tag: string, publication: string) => string} */
116
+ tagPageDescription: (count, tag, publication) =>
117
+ `${count === '1' ? '1 article' : `${count} articles`} tagged ${tag} on ${publication}.`,
118
+ /** @type {(count: string, name: string, publication: string) => string} */
119
+ seriesPageDescription: (count, name, publication) =>
120
+ `${name}, a series of ${count === '1' ? '1 part' : `${count} parts`} on ${publication}.`,
121
+ /** @type {(year: string) => string} */
122
+ archivePageTitle: (year) => `Archive ${year}`,
123
+ /** @type {(count: string, year: string, publication: string) => string} */
124
+ archivePageDescription: (count, year, publication) =>
125
+ `${count === '1' ? '1 article' : `${count} articles`} published in ${year} on ${publication}.`,
126
+ /** @type {(name: string, publication: string) => string} */
127
+ authorPageDescription: (name, publication) =>
128
+ `Articles written by ${name} on ${publication}.`,
129
+ /** @type {(count: string, publication: string) => string} */
130
+ tagsRootDescription: (count, publication) =>
131
+ `Browse the ${count} topics covered on ${publication}.`,
132
+ /** @type {(count: string, publication: string) => string} */
133
+ seriesRootDescription: (count, publication) =>
134
+ `Browse the ${count} series published on ${publication}.`,
135
+ /** @type {(count: string, publication: string) => string} */
136
+ archiveRootDescription: (count, publication) =>
137
+ `Every article on ${publication}, ${count} in all, by year.`,
138
+ /** @type {(publication: string) => string} */
139
+ indexPageDescription: (publication) =>
140
+ `All articles on ${publication}, newest first.`,
141
+ exploreColumnLabel: 'Explore',
142
+ rssFeedLabel: 'RSS feed',
143
+ /** @type {(iso: string) => string} */
144
+ formatDate: (iso) => {
145
+ const months = [
146
+ 'Jan',
147
+ 'Feb',
148
+ 'Mar',
149
+ 'Apr',
150
+ 'May',
151
+ 'Jun',
152
+ 'Jul',
153
+ 'Aug',
154
+ 'Sep',
155
+ 'Oct',
156
+ 'Nov',
157
+ 'Dec',
158
+ ];
159
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
160
+ if (!match) return iso;
161
+ return `${months[Number(match[2]) - 1]} ${Number(match[3])}, ${match[1]}`;
162
+ },
59
163
  }),
60
164
  });
61
165