@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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Site chrome: the one `<header>` and the one `<footer>` landmark every page
3
+ * is composed with (see `internal/skeleton.js`'s landmark contract). Markup
4
+ * mirrors the approved design reference and reads completely with JavaScript
5
+ * off; the optional site script only reveals the search button (rendered
6
+ * `hidden`) and enhances the rest.
7
+ */
8
+
9
+ import { escapeHtml, renderNavItem, renderSlot } from './skeleton.js';
10
+ import { icon } from './icons.js';
11
+ import { monogram, renderImage } from './components.js';
12
+
13
+ /** @type {Readonly<Record<string, string>>} */
14
+ const SOCIAL_ICONS = Object.freeze({
15
+ email: 'mail',
16
+ github: 'code',
17
+ website: 'link',
18
+ });
19
+
20
+ /**
21
+ * @param {string} name publication title
22
+ * @param {{url: string, width?: number, height?: number} | undefined} mark
23
+ * the brand mark image, if any
24
+ * @returns {string} the brand mark element
25
+ */
26
+ function renderMark(name, mark) {
27
+ return (
28
+ `<span class="g-mark" aria-hidden="true">` +
29
+ (mark
30
+ ? renderImage({ image: mark, alt: '', eager: true })
31
+ : escapeHtml(monogram(name))) +
32
+ `</span>`
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Render the one `<header>` landmark.
38
+ *
39
+ * @param {object} options rendering options
40
+ * @param {string} options.homeRoute the publication's own home route
41
+ * @param {string} options.publicationName the validated publication title
42
+ * @param {string} options.tagline the publication description
43
+ * @param {{url: string, width?: number, height?: number} | undefined} options.brandMark
44
+ * the appearance brand mark image; the monogram is used when absent
45
+ * @param {string} options.navHtml the already-rendered primary navigation
46
+ * @param {readonly import('../../../types/index.d.ts').NavigationItem[]} options.navItems
47
+ * the same items, rendered again as the mobile menu's link list
48
+ * @param {string | undefined} options.currentRoute the page's joined route
49
+ * @param {string} options.urlPrefix the `baseUrl` path prefix
50
+ * @param {string} options.searchIndexHref the static search index URL, read
51
+ * by the site script
52
+ * @param {string} [options.appearanceControlHtml] the already-rendered
53
+ * appearance control, inserted into the `header-actions` slot
54
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
55
+ * @returns {string} the rendered header
56
+ */
57
+ export function renderHeader({
58
+ homeRoute,
59
+ publicationName,
60
+ tagline,
61
+ brandMark,
62
+ navHtml,
63
+ navItems,
64
+ currentRoute,
65
+ urlPrefix,
66
+ searchIndexHref,
67
+ appearanceControlHtml,
68
+ messages,
69
+ }) {
70
+ const label = (/** @type {string} */ key) =>
71
+ escapeHtml(/** @type {string} */ (messages[key]));
72
+ const menu =
73
+ navItems.length > 0
74
+ ? `<details class="g-menu"><summary>${icon('menu')}<span class="g-sr">${label('menuSummaryLabel')}</span></summary>` +
75
+ `<ul>${navItems.map((item) => renderNavItem(item, currentRoute, urlPrefix)).join('')}</ul></details>`
76
+ : '';
77
+ return (
78
+ `<header class="g-header"><div class="g-wrap g-header-row">` +
79
+ `<a class="g-brand" href="${escapeHtml(homeRoute)}">${renderMark(publicationName, brandMark)}` +
80
+ `<span class="g-brand-text"><span class="g-brand-name">${escapeHtml(publicationName)}</span>` +
81
+ `<span class="g-brand-tag">${escapeHtml(tagline)}</span></span></a>` +
82
+ navHtml +
83
+ `<div class="g-actions">` +
84
+ `<button class="g-search-btn" type="button" hidden data-action="search" data-search-index="${escapeHtml(searchIndexHref)}" aria-label="${label('searchButtonAriaLabel')}">` +
85
+ `${icon('search')}<span>${label('searchButtonLabel')}</span></button>` +
86
+ renderSlot('header-actions', { html: appearanceControlHtml }) +
87
+ menu +
88
+ `</div></div></header>`
89
+ );
90
+ }
91
+
92
+ /**
93
+ * @param {readonly unknown[]} socialLinks `publication.socialLinks`
94
+ * @param {string} rssHref the RSS feed URL
95
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
96
+ * @returns {string} the `<ul>` of icon links
97
+ */
98
+ function renderSocialLinks(socialLinks, rssHref, messages) {
99
+ const items = socialLinks.flatMap((entry) => {
100
+ const link = /** @type {Record<string, unknown>} */ (entry);
101
+ if (typeof link?.uri !== 'string') return [];
102
+ const type = typeof link.type === 'string' ? link.type : 'other';
103
+ const text = typeof link.label === 'string' ? link.label : type;
104
+ return [
105
+ { href: link.uri, text, iconName: SOCIAL_ICONS[type] ?? 'upright' },
106
+ ];
107
+ });
108
+ items.push({
109
+ href: rssHref,
110
+ text: /** @type {string} */ (messages.rssFeedLabel),
111
+ iconName: 'rss',
112
+ });
113
+ return (
114
+ `<ul class="g-social">` +
115
+ items
116
+ .map(
117
+ (item) =>
118
+ `<li><a class="g-icon-btn" href="${escapeHtml(item.href)}" aria-label="${escapeHtml(item.text)}" title="${escapeHtml(item.text)}">${icon(item.iconName)}</a></li>`,
119
+ )
120
+ .join('') +
121
+ `</ul>`
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Render the one `<footer>` landmark.
127
+ *
128
+ * @param {object} options rendering options
129
+ * @param {string} options.homeRoute the publication's own home route
130
+ * @param {string} options.publicationName the validated publication title
131
+ * @param {string} options.description the publication description
132
+ * @param {{url: string, width?: number, height?: number} | undefined} options.brandMark the brand mark image
133
+ * @param {readonly unknown[]} options.socialLinks `publication.socialLinks`
134
+ * @param {string} options.rssHref the RSS feed URL
135
+ * @param {readonly import('../../../types/index.d.ts').NavigationItem[]} options.primaryItems
136
+ * the primary navigation items, repeated as the "Explore" column
137
+ * @param {readonly import('../../../types/index.d.ts').NavigationItem[]} options.footerItems
138
+ * `navigationNormalized.footerItems`
139
+ * @param {string} options.urlPrefix the `baseUrl` path prefix
140
+ * @param {string} options.authorHtml the already-rendered author mini
141
+ * profile (may be empty)
142
+ * @param {string} options.footerProfileHtml the already-rendered footer card
143
+ * body (may be empty)
144
+ * @param {string | undefined} options.copyrightText the copyright line
145
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
146
+ * @returns {string} the rendered footer
147
+ */
148
+ export function renderFooter({
149
+ homeRoute,
150
+ publicationName,
151
+ description,
152
+ brandMark,
153
+ socialLinks,
154
+ rssHref,
155
+ primaryItems,
156
+ footerItems,
157
+ urlPrefix,
158
+ authorHtml,
159
+ footerProfileHtml,
160
+ copyrightText,
161
+ messages,
162
+ }) {
163
+ const label = (/** @type {string} */ key) =>
164
+ escapeHtml(/** @type {string} */ (messages[key]));
165
+ /**
166
+ * @param {string} labelKey
167
+ * @param {readonly import('../../../types/index.d.ts').NavigationItem[]} items
168
+ * @returns {string}
169
+ */
170
+ const column = (labelKey, items) =>
171
+ items.length === 0
172
+ ? ''
173
+ : `<nav class="g-footer-col" aria-label="${label(labelKey)}"><p class="g-label">${label(labelKey)}</p>` +
174
+ `<ul>${items.map((item) => renderNavItem(item, undefined, urlPrefix)).join('')}</ul></nav>`;
175
+ return (
176
+ `<footer class="g-footer"><div class="g-wrap g-footer-grid">` +
177
+ `<div class="g-footer-brand">` +
178
+ `<a class="g-brand" href="${escapeHtml(homeRoute)}">${renderMark(publicationName, brandMark)}` +
179
+ `<span class="g-brand-name">${escapeHtml(publicationName)}</span></a>` +
180
+ `<p class="g-footer-about">${escapeHtml(description)}</p>` +
181
+ renderSocialLinks(socialLinks, rssHref, messages) +
182
+ `</div>` +
183
+ column('exploreColumnLabel', primaryItems) +
184
+ column('footerNavigationLabel', footerItems) +
185
+ `<div class="g-footer-col">` +
186
+ renderSlot('footer-profile', { html: authorHtml + footerProfileHtml }) +
187
+ `</div>` +
188
+ `</div>` +
189
+ renderSlot('footer-auxiliary') +
190
+ renderSlot('account-intent') +
191
+ renderSlot('conversation') +
192
+ renderSlot('newsletter') +
193
+ renderSlot('edition-selector') +
194
+ `<div class="g-wrap g-footer-base">` +
195
+ (copyrightText ? `<span>${escapeHtml(copyrightText)}</span>` : '') +
196
+ `<span>${label('footerAttributionLabel')}</span>` +
197
+ `</div></footer>`
198
+ );
199
+ }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Shared, template-owned presentational components used by the page kinds
3
+ * (`internal/page-kinds.js`) and the site chrome (`internal/chrome.js`):
4
+ * chips, meta lines, bylines, cards and the optional newsletter panel.
5
+ *
6
+ * Every component is a pure `(data) -> HTML string` function over already
7
+ * validated build-input records. Every link is a real `href` (the pages read
8
+ * completely with JavaScript off); the optional site script only enhances.
9
+ *
10
+ * Hook naming convention: every class this module emits is a `g-*` class.
11
+ * Each distinct class is one published public theme hook, listed in
12
+ * `internal/appearance/styling-contract.js`'s `COMPONENT_CLASSES`; the
13
+ * contract drift test fails if a rendered `g-*` class is unpublished or a
14
+ * published one is never rendered.
15
+ */
16
+
17
+ import { escapeHtml } from './skeleton.js';
18
+ import { icon } from './icons.js';
19
+ import { joinBasePathAndRoute } from './route.js';
20
+ import { resolveOriginalDerivativePath } from './seo.js';
21
+
22
+ /** Reading speed used for every reading-time estimate. */
23
+ export const WORDS_PER_MINUTE = 230;
24
+
25
+ /**
26
+ * @param {string} html sanitized body HTML
27
+ * @returns {string} its text content, whitespace-collapsed
28
+ */
29
+ export function plainText(html) {
30
+ return html
31
+ .replace(/<[^>]*>/g, ' ')
32
+ .replace(/&(?:[a-z]+|#\d+|#x[0-9a-f]+);/gi, ' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim();
35
+ }
36
+
37
+ /**
38
+ * Reading time in whole minutes: body words at 230 words per minute,
39
+ * rounded up, never less than one minute.
40
+ *
41
+ * @param {string} bodyHtml sanitized body HTML
42
+ * @returns {number} minutes (at least 1)
43
+ */
44
+ export function readingMinutes(bodyHtml) {
45
+ const text = plainText(bodyHtml);
46
+ const words = text === '' ? 0 : text.split(' ').length;
47
+ return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
48
+ }
49
+
50
+ /**
51
+ * Extract the `<h2 id="...">` entries of a sanitized body, in order, for the
52
+ * article contents list.
53
+ *
54
+ * @param {string} bodyHtml sanitized body HTML
55
+ * @returns {{id: string, text: string}[]} each h2's id and plain text
56
+ */
57
+ export function extractH2Headings(bodyHtml) {
58
+ /** @type {{id: string, text: string}[]} */
59
+ const headings = [];
60
+ for (const match of bodyHtml.matchAll(
61
+ /<h2\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h2>/g,
62
+ )) {
63
+ headings.push({ id: match[1], text: plainText(match[2]) });
64
+ }
65
+ return headings;
66
+ }
67
+
68
+ /**
69
+ * Up to two initials from a display name, uppercased (the monogram and the
70
+ * image-less avatar fallback).
71
+ *
72
+ * @param {string} name a publication title or author display name
73
+ * @returns {string} one or two characters
74
+ */
75
+ export function monogram(name) {
76
+ const words = name.split(/\s+/).filter(Boolean);
77
+ const picked = words.length > 1 ? [words[0], words[1]] : [words[0] ?? ''];
78
+ const letters = picked.map((word) => Array.from(word)[0] ?? '').join('');
79
+ return (letters || '?').toUpperCase();
80
+ }
81
+
82
+ /**
83
+ * Render the optional newsletter panel: a call-to-action link to the
84
+ * sign-up page the author configured in publication settings, never a form.
85
+ * Reads `publication.newsletter` defensively: absent, or without an
86
+ * `https:` `url`, renders nothing.
87
+ *
88
+ * @param {object} options rendering options
89
+ * @param {unknown} options.newsletter `publication.newsletter` (may be
90
+ * undefined on a build input that predates the field)
91
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
92
+ * the resolved message catalog
93
+ * @returns {string} the panel markup, or an empty string
94
+ */
95
+ export function renderNewsletterPanel({ newsletter, messages }) {
96
+ const value = /** @type {Record<string, unknown> | undefined} */ (
97
+ newsletter && typeof newsletter === 'object' ? newsletter : undefined
98
+ );
99
+ if (!value || typeof value.url !== 'string' || !/^https:\/\//.test(value.url))
100
+ return '';
101
+ const title =
102
+ typeof value.title === 'string' && value.title !== ''
103
+ ? value.title
104
+ : /** @type {string} */ (messages.newsletterLabel);
105
+ const text = typeof value.text === 'string' ? value.text : '';
106
+ return (
107
+ `<section class="g-wrap g-newsletter" aria-labelledby="newsletter-title">` +
108
+ `<div class="g-panel">` +
109
+ `<div class="g-panel-copy">` +
110
+ `<p class="g-label">${icon('mail')}${escapeHtml(/** @type {string} */ (messages.newsletterLabel))}</p>` +
111
+ `<h2 id="newsletter-title">${escapeHtml(title)}</h2>` +
112
+ (text ? `<p>${escapeHtml(text)}</p>` : '') +
113
+ `</div>` +
114
+ `<a class="g-btn" href="${escapeHtml(value.url)}" rel="noopener">${escapeHtml(/** @type {string} */ (messages.newsletterActionLabel))}${icon('arrow')}</a>` +
115
+ `</div></section>`
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Build the resolver every component uses to turn an image reference into
121
+ * its emitted URL (the media pipeline's processed original), or `undefined`
122
+ * when the pipeline produced no derivative for it.
123
+ *
124
+ * @param {object} options
125
+ * @param {readonly {path: string, mediaType: string}[]} options.mediaAssets
126
+ * the media pipeline's finished `assets` list
127
+ * @param {string} options.publicBasePath the public base path
128
+ * @returns {(ref: {path: string, sourceDigest: string} | undefined) => string | undefined}
129
+ */
130
+ export function createMediaUrl({ mediaAssets, publicBasePath }) {
131
+ return (ref) => {
132
+ const derivative = resolveOriginalDerivativePath(mediaAssets, ref);
133
+ return derivative
134
+ ? joinBasePathAndRoute(publicBasePath, `/${derivative}`)
135
+ : undefined;
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Build the resolver for a processed image's URL plus its pixel dimensions
141
+ * (from the media pipeline's own `dimensions` map), so every `<img>` can
142
+ * carry `width`/`height` and reserve its space before the file loads.
143
+ *
144
+ * @param {object} options
145
+ * @param {readonly {path: string, mediaType: string}[]} options.mediaAssets
146
+ * @param {string} options.publicBasePath the public base path
147
+ * @param {Readonly<Record<string, {width: number, height: number}>>} [options.mediaDimensions]
148
+ * the pipeline's dimensions, keyed by derivative path
149
+ * @returns {(ref: {path: string, sourceDigest: string} | undefined) => ({url: string, width?: number, height?: number}) | undefined}
150
+ */
151
+ export function createMediaImage({
152
+ mediaAssets,
153
+ publicBasePath,
154
+ mediaDimensions = {},
155
+ }) {
156
+ return (ref) => {
157
+ const derivative = resolveOriginalDerivativePath(mediaAssets, ref);
158
+ if (!derivative) return undefined;
159
+ const size = mediaDimensions[derivative];
160
+ return {
161
+ url: joinBasePathAndRoute(publicBasePath, `/${derivative}`),
162
+ width: size?.width,
163
+ height: size?.height,
164
+ };
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Render one `<img>` with intrinsic `width`/`height` (when known) and the
170
+ * right loading hints: a priority image (the page's largest above-the-fold
171
+ * image) is `fetchpriority="high"` and never lazy; every other image is
172
+ * `loading="lazy" decoding="async"`.
173
+ *
174
+ * @param {object} options
175
+ * @param {{url: string, width?: number, height?: number}} options.image
176
+ * @param {string} options.alt the alternative text (empty when decorative)
177
+ * @param {string} [options.className]
178
+ * @param {boolean} [options.priority]
179
+ * @param {boolean} [options.eager] above-the-fold but not the page's main
180
+ * image (the header brand mark): no `loading="lazy"`
181
+ * @param {{width: number, height: number}} [options.fallbackSize] used when
182
+ * the pipeline reported no dimensions
183
+ * @returns {string} the `<img>` element
184
+ */
185
+ export function renderImage({
186
+ image,
187
+ alt,
188
+ className,
189
+ priority = false,
190
+ eager = false,
191
+ fallbackSize,
192
+ }) {
193
+ const width = image.width ?? fallbackSize?.width;
194
+ const height = image.height ?? fallbackSize?.height;
195
+ return (
196
+ `<img${className ? ` class="${className}"` : ''} src="${escapeHtml(image.url)}" alt="${escapeHtml(alt)}"` +
197
+ (width && height ? ` width="${width}" height="${height}"` : '') +
198
+ (priority
199
+ ? ' fetchpriority="high" decoding="async"'
200
+ : eager
201
+ ? ' decoding="async"'
202
+ : ' loading="lazy" decoding="async"') +
203
+ `>`
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Add `loading="lazy" decoding="async"` to every `<img>` in an
209
+ * already-sanitized body. Applied at render time only: the body's own
210
+ * digest is over the stored bytes and is never recomputed.
211
+ *
212
+ * @param {string} bodyHtml sanitized body HTML
213
+ * @returns {string} the body with loading hints on its images
214
+ */
215
+ export function lazyBodyImages(bodyHtml) {
216
+ return bodyHtml.replace(
217
+ /<img\b(?![^>]*\bloading=)/g,
218
+ '<img loading="lazy" decoding="async"',
219
+ );
220
+ }
221
+
222
+ /**
223
+ * @typedef {object} ComponentContext
224
+ * @property {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
225
+ * @property {(route: string) => string} site joins a canonical route with
226
+ * the public base path
227
+ * @property {(ref: {path: string, sourceDigest: string} | undefined) => ({url: string, width?: number, height?: number}) | undefined} mediaImage
228
+ * the emitted URL and dimensions of an image reference's processed derivative
229
+ * @property {(author: import('../../../types/index.d.ts').AuthorNormalized) => string} authorHref
230
+ * the author page's joined route
231
+ * @property {ReadonlyMap<string, import('../../../types/index.d.ts').AuthorNormalized>} authorsById
232
+ * @property {(record: import('../../../types/index.d.ts').ContentBuildRecord) => string} recordHref
233
+ * the record's own joined route
234
+ * @property {(tag: string) => string} tagHref
235
+ */
236
+
237
+ /**
238
+ * Build the component renderers bound to one build's context.
239
+ *
240
+ * @param {ComponentContext} context
241
+ * @returns {{text: (key: string) => string, call: (key: string, ...args: string[]) => string, chip: (tag: string) => string, meta: (record: import('../../../types/index.d.ts').ContentBuildRecord, showUpdated?: boolean) => string, avatar: (author: import('../../../types/index.d.ts').AuthorNormalized | undefined) => string, byline: (record: import('../../../types/index.d.ts').ContentBuildRecord, showUpdated?: boolean) => string, card: (record: import('../../../types/index.d.ts').ContentBuildRecord, options?: {partLabel?: string, headingLevel?: number}, variant?: 'grid' | 'row') => string, recordAuthors: (record: import('../../../types/index.d.ts').ContentBuildRecord) => import('../../../types/index.d.ts').AuthorNormalized[]}}
242
+ * the bound renderers
243
+ */
244
+ export function createComponents(context) {
245
+ const { messages, mediaImage, authorsById, recordHref, tagHref } = context;
246
+ /**
247
+ * @param {string} key a plain message key
248
+ * @returns {string} the escaped message text
249
+ */
250
+ const text = (key) => escapeHtml(/** @type {string} */ (messages[key]));
251
+ /**
252
+ * @param {string} key a function message key
253
+ * @param {...string} args the message function's arguments
254
+ * @returns {string} the escaped message text
255
+ */
256
+ const call = (key, ...args) =>
257
+ escapeHtml(
258
+ /** @type {(...a: string[]) => string} */ (messages[key])(...args),
259
+ );
260
+
261
+ /**
262
+ * @param {string} tag
263
+ * @returns {string}
264
+ */
265
+ const chip = (tag) =>
266
+ `<a class="g-chip" href="${escapeHtml(tagHref(tag))}">${escapeHtml(tag)}</a>`;
267
+
268
+ /**
269
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
270
+ * @param {boolean} [showUpdated] append the "Updated" date when the
271
+ * record was modified after publication
272
+ * @returns {string}
273
+ */
274
+ const meta = (record, showUpdated = false) => {
275
+ const { publishedAt, updatedAt } = record.frontmatter;
276
+ const formatDate = /** @type {(iso: string) => string} */ (
277
+ messages.formatDate
278
+ );
279
+ const updated =
280
+ showUpdated && updatedAt && updatedAt !== publishedAt
281
+ ? `<span class="g-dot" aria-hidden="true"></span><time datetime="${escapeHtml(updatedAt)}">` +
282
+ `${call('updatedLabel', formatDate(updatedAt))}</time>`
283
+ : '';
284
+ return (
285
+ `<span class="g-meta"><time datetime="${escapeHtml(publishedAt)}">` +
286
+ `${call('formatDate', publishedAt)}</time>` +
287
+ `<span class="g-dot" aria-hidden="true"></span>` +
288
+ `<span>${icon('clock')}${call('readingTimeLabel', String(readingMinutes(record.body)))}</span>${updated}</span>`
289
+ );
290
+ };
291
+
292
+ /**
293
+ * @param {import('../../../types/index.d.ts').AuthorNormalized | undefined} author
294
+ * @returns {string}
295
+ */
296
+ const avatar = (author) => {
297
+ const image = mediaImage(author?.avatar);
298
+ return image
299
+ ? renderImage({
300
+ image,
301
+ alt: '',
302
+ className: 'g-avatar',
303
+ fallbackSize: { width: 40, height: 40 },
304
+ })
305
+ : `<span class="g-avatar" aria-hidden="true">${escapeHtml(monogram(author?.displayName ?? ''))}</span>`;
306
+ };
307
+
308
+ /**
309
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
310
+ * @returns {import('../../../types/index.d.ts').AuthorNormalized[]}
311
+ */
312
+ const recordAuthors = (record) =>
313
+ record.frontmatter.authorIds.flatMap((id) => {
314
+ const author = authorsById.get(id);
315
+ return author ? [author] : [];
316
+ });
317
+
318
+ /**
319
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
320
+ * @param {boolean} [showUpdated] show the "Updated" date (article head)
321
+ * @returns {string}
322
+ */
323
+ const byline = (record, showUpdated = false) => {
324
+ const authors = recordAuthors(record);
325
+ const names = authors
326
+ .map(
327
+ (author) =>
328
+ `<a href="${escapeHtml(context.authorHref(author))}">${escapeHtml(author.displayName)}</a>`,
329
+ )
330
+ .join(', ');
331
+ return (
332
+ `<div class="g-byline">${avatar(authors[0])}` +
333
+ `<div class="g-byline-text"><span>${names}</span>${meta(record, showUpdated)}</div></div>`
334
+ );
335
+ };
336
+
337
+ /**
338
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
339
+ * @param {{partLabel?: string, headingLevel?: number}} [options] an
340
+ * optional series part label and the title's heading level (default 3)
341
+ * @param {'grid' | 'row'} [variant]
342
+ * @returns {string}
343
+ */
344
+ const card = (record, options = {}, variant = 'grid') => {
345
+ const fm = record.frontmatter;
346
+ const href = escapeHtml(recordHref(record));
347
+ const hero = mediaImage(fm.hero?.file);
348
+ const media = hero
349
+ ? `<figure class="g-card-media">${renderImage({ image: hero, alt: fm.hero?.role === 'decorative' ? '' : (fm.hero?.alt ?? '') })}</figure>`
350
+ : `<div class="g-card-placeholder" aria-hidden="true">${icon('sparkle')}</div>`;
351
+ const seriesTag = options.partLabel
352
+ ? `<span class="g-series-tag">${icon('book')}${escapeHtml(options.partLabel)}</span>`
353
+ : '';
354
+ const firstTag = fm.tags[0];
355
+ return (
356
+ `<article class="g-card${variant === 'row' ? ' g-card-row' : ''}">` +
357
+ media +
358
+ `<div class="g-card-body">` +
359
+ `<div class="g-card-top">${firstTag ? chip(firstTag) : ''}${seriesTag}</div>` +
360
+ `<h${options.headingLevel ?? 3}><a class="g-card-link" href="${href}">${escapeHtml(fm.title)}</a></h${options.headingLevel ?? 3}>` +
361
+ (fm.description
362
+ ? `<p class="g-card-excerpt">${escapeHtml(fm.description)}</p>`
363
+ : '') +
364
+ `<div class="g-card-foot">${meta(record)}<span aria-hidden="true">${icon('arrow')}</span></div>` +
365
+ `</div></article>`
366
+ );
367
+ };
368
+
369
+ return {
370
+ text,
371
+ call,
372
+ chip,
373
+ meta,
374
+ avatar,
375
+ byline,
376
+ card,
377
+ recordAuthors,
378
+ };
379
+ }
@@ -143,13 +143,22 @@ const SKELETON_LAYOUT_SOURCE = [
143
143
  '<meta property="og:site_name" content="{{ siteName }}">',
144
144
  '{% if canonicalUrl %}<meta property="og:url" content="{{ canonicalUrl }}">{% endif %}',
145
145
  '<meta property="og:type" content="{{ ogType }}">',
146
- '<meta property="og:title" content="{{ title }}">',
146
+ '<meta property="og:title" content="{{ ogTitle }}">',
147
147
  '{% if description %}<meta property="og:description" content="{{ description }}">{% endif %}',
148
- '{% if ogImage %}<meta property="og:image" content="{{ ogImage }}">{% endif %}',
148
+ '{% if ogImage %}<meta property="og:image" content="{{ ogImage }}">{% if ogImageWidth %}<meta property="og:image:width" content="{{ ogImageWidth }}">{% endif %}{% if ogImageHeight %}<meta property="og:image:height" content="{{ ogImageHeight }}">{% endif %}{% if ogImageAlt %}<meta property="og:image:alt" content="{{ ogImageAlt }}">{% endif %}{% endif %}',
149
+ '{% if articlePublished %}<meta property="article:published_time" content="{{ articlePublished }}">{% endif %}',
150
+ '{% if articleModified %}<meta property="article:modified_time" content="{{ articleModified }}">{% endif %}',
151
+ '{% for authorUrl in articleAuthors %}<meta property="article:author" content="{{ authorUrl }}">{% endfor %}',
152
+ '{% for articleTag in articleTags %}<meta property="article:tag" content="{{ articleTag }}">{% endfor %}',
149
153
  '<meta name="twitter:card" content="{{ twitterCard }}">',
150
- '<meta name="twitter:title" content="{{ title }}">',
154
+ '<meta name="twitter:title" content="{{ ogTitle }}">',
151
155
  '{% if description %}<meta name="twitter:description" content="{{ description }}">{% endif %}',
152
- '{% if ogImage %}<meta name="twitter:image" content="{{ ogImage }}">{% endif %}',
156
+ '{% if ogImage %}<meta name="twitter:image" content="{{ ogImage }}">{% if ogImageAlt %}<meta name="twitter:image:alt" content="{{ ogImageAlt }}">{% endif %}{% endif %}',
157
+ // One JSON-LD data block per page (`internal/structured-data.js`). Its
158
+ // `type` is not a JavaScript type, so a browser never executes it and the
159
+ // CSP `script-src` does not govern it; `jsonLd` is already escaped for
160
+ // embedding (`<`, `>`, `&`, U+2028/9 as \uXXXX).
161
+ '{% if jsonLd %}<script type="application/ld+json">{{ jsonLd | safe }}</script>{% endif %}',
153
162
  '<link rel="alternate" type="application/atom+xml" href="{{ atomFeedUrl }}" title="{{ siteName }}">',
154
163
  '<link rel="alternate" type="application/rss+xml" href="{{ rssFeedUrl }}" title="{{ siteName }}">',
155
164
  // TPL-H3/TPL-M7: the template-owned gala-base layer's own `<link>`,