@rathnasgala2/template 2.2.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,371 @@
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 configured in `publication.newsletter` (validated by the
85
+ * build-input schema: an `https:` URL, a title and a short text), never a
86
+ * form. Renders nothing when the publication has no newsletter.
87
+ *
88
+ * @param {object} options rendering options
89
+ * @param {{url: string, title: string, text: string} | undefined} options.newsletter
90
+ * `publication.newsletter`
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
+ if (!newsletter) return '';
97
+ const { url, title, text } = newsletter;
98
+ return (
99
+ `<section class="g-wrap g-newsletter" aria-labelledby="newsletter-title">` +
100
+ `<div class="g-panel">` +
101
+ `<div class="g-panel-copy">` +
102
+ `<p class="g-label">${icon('mail')}${escapeHtml(/** @type {string} */ (messages.newsletterLabel))}</p>` +
103
+ `<h2 id="newsletter-title">${escapeHtml(title)}</h2>` +
104
+ (text ? `<p>${escapeHtml(text)}</p>` : '') +
105
+ `</div>` +
106
+ `<a class="g-btn" href="${escapeHtml(url)}" rel="noopener">${escapeHtml(/** @type {string} */ (messages.newsletterActionLabel))}${icon('arrow')}</a>` +
107
+ `</div></section>`
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Build the resolver every component uses to turn an image reference into
113
+ * its emitted URL (the media pipeline's processed original), or `undefined`
114
+ * when the pipeline produced no derivative for it.
115
+ *
116
+ * @param {object} options
117
+ * @param {readonly {path: string, mediaType: string}[]} options.mediaAssets
118
+ * the media pipeline's finished `assets` list
119
+ * @param {string} options.publicBasePath the public base path
120
+ * @returns {(ref: {path: string, sourceDigest: string} | undefined) => string | undefined}
121
+ */
122
+ export function createMediaUrl({ mediaAssets, publicBasePath }) {
123
+ return (ref) => {
124
+ const derivative = resolveOriginalDerivativePath(mediaAssets, ref);
125
+ return derivative
126
+ ? joinBasePathAndRoute(publicBasePath, `/${derivative}`)
127
+ : undefined;
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Build the resolver for a processed image's URL plus its pixel dimensions
133
+ * (from the media pipeline's own `dimensions` map), so every `<img>` can
134
+ * carry `width`/`height` and reserve its space before the file loads.
135
+ *
136
+ * @param {object} options
137
+ * @param {readonly {path: string, mediaType: string}[]} options.mediaAssets
138
+ * @param {string} options.publicBasePath the public base path
139
+ * @param {Readonly<Record<string, {width: number, height: number}>>} [options.mediaDimensions]
140
+ * the pipeline's dimensions, keyed by derivative path
141
+ * @returns {(ref: {path: string, sourceDigest: string} | undefined) => ({url: string, width?: number, height?: number}) | undefined}
142
+ */
143
+ export function createMediaImage({
144
+ mediaAssets,
145
+ publicBasePath,
146
+ mediaDimensions = {},
147
+ }) {
148
+ return (ref) => {
149
+ const derivative = resolveOriginalDerivativePath(mediaAssets, ref);
150
+ if (!derivative) return undefined;
151
+ const size = mediaDimensions[derivative];
152
+ return {
153
+ url: joinBasePathAndRoute(publicBasePath, `/${derivative}`),
154
+ width: size?.width,
155
+ height: size?.height,
156
+ };
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Render one `<img>` with intrinsic `width`/`height` (when known) and the
162
+ * right loading hints: a priority image (the page's largest above-the-fold
163
+ * image) is `fetchpriority="high"` and never lazy; every other image is
164
+ * `loading="lazy" decoding="async"`.
165
+ *
166
+ * @param {object} options
167
+ * @param {{url: string, width?: number, height?: number}} options.image
168
+ * @param {string} options.alt the alternative text (empty when decorative)
169
+ * @param {string} [options.className]
170
+ * @param {boolean} [options.priority]
171
+ * @param {boolean} [options.eager] above-the-fold but not the page's main
172
+ * image (the header brand mark): no `loading="lazy"`
173
+ * @param {{width: number, height: number}} [options.fallbackSize] used when
174
+ * the pipeline reported no dimensions
175
+ * @returns {string} the `<img>` element
176
+ */
177
+ export function renderImage({
178
+ image,
179
+ alt,
180
+ className,
181
+ priority = false,
182
+ eager = false,
183
+ fallbackSize,
184
+ }) {
185
+ const width = image.width ?? fallbackSize?.width;
186
+ const height = image.height ?? fallbackSize?.height;
187
+ return (
188
+ `<img${className ? ` class="${className}"` : ''} src="${escapeHtml(image.url)}" alt="${escapeHtml(alt)}"` +
189
+ (width && height ? ` width="${width}" height="${height}"` : '') +
190
+ (priority
191
+ ? ' fetchpriority="high" decoding="async"'
192
+ : eager
193
+ ? ' decoding="async"'
194
+ : ' loading="lazy" decoding="async"') +
195
+ `>`
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Add `loading="lazy" decoding="async"` to every `<img>` in an
201
+ * already-sanitized body. Applied at render time only: the body's own
202
+ * digest is over the stored bytes and is never recomputed.
203
+ *
204
+ * @param {string} bodyHtml sanitized body HTML
205
+ * @returns {string} the body with loading hints on its images
206
+ */
207
+ export function lazyBodyImages(bodyHtml) {
208
+ return bodyHtml.replace(
209
+ /<img\b(?![^>]*\bloading=)/g,
210
+ '<img loading="lazy" decoding="async"',
211
+ );
212
+ }
213
+
214
+ /**
215
+ * @typedef {object} ComponentContext
216
+ * @property {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
217
+ * @property {(route: string) => string} site joins a canonical route with
218
+ * the public base path
219
+ * @property {(ref: {path: string, sourceDigest: string} | undefined) => ({url: string, width?: number, height?: number}) | undefined} mediaImage
220
+ * the emitted URL and dimensions of an image reference's processed derivative
221
+ * @property {(author: import('../../../types/index.d.ts').AuthorNormalized) => string} authorHref
222
+ * the author page's joined route
223
+ * @property {ReadonlyMap<string, import('../../../types/index.d.ts').AuthorNormalized>} authorsById
224
+ * @property {(record: import('../../../types/index.d.ts').ContentBuildRecord) => string} recordHref
225
+ * the record's own joined route
226
+ * @property {(tag: string) => string} tagHref
227
+ */
228
+
229
+ /**
230
+ * Build the component renderers bound to one build's context.
231
+ *
232
+ * @param {ComponentContext} context
233
+ * @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[]}}
234
+ * the bound renderers
235
+ */
236
+ export function createComponents(context) {
237
+ const { messages, mediaImage, authorsById, recordHref, tagHref } = context;
238
+ /**
239
+ * @param {string} key a plain message key
240
+ * @returns {string} the escaped message text
241
+ */
242
+ const text = (key) => escapeHtml(/** @type {string} */ (messages[key]));
243
+ /**
244
+ * @param {string} key a function message key
245
+ * @param {...string} args the message function's arguments
246
+ * @returns {string} the escaped message text
247
+ */
248
+ const call = (key, ...args) =>
249
+ escapeHtml(
250
+ /** @type {(...a: string[]) => string} */ (messages[key])(...args),
251
+ );
252
+
253
+ /**
254
+ * @param {string} tag
255
+ * @returns {string}
256
+ */
257
+ const chip = (tag) =>
258
+ `<a class="g-chip" href="${escapeHtml(tagHref(tag))}">${escapeHtml(tag)}</a>`;
259
+
260
+ /**
261
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
262
+ * @param {boolean} [showUpdated] append the "Updated" date when the
263
+ * record was modified after publication
264
+ * @returns {string}
265
+ */
266
+ const meta = (record, showUpdated = false) => {
267
+ const { publishedAt, updatedAt } = record.frontmatter;
268
+ const formatDate = /** @type {(iso: string) => string} */ (
269
+ messages.formatDate
270
+ );
271
+ const updated =
272
+ showUpdated && updatedAt && updatedAt !== publishedAt
273
+ ? `<span class="g-dot" aria-hidden="true"></span><time datetime="${escapeHtml(updatedAt)}">` +
274
+ `${call('updatedLabel', formatDate(updatedAt))}</time>`
275
+ : '';
276
+ return (
277
+ `<span class="g-meta"><time datetime="${escapeHtml(publishedAt)}">` +
278
+ `${call('formatDate', publishedAt)}</time>` +
279
+ `<span class="g-dot" aria-hidden="true"></span>` +
280
+ `<span>${icon('clock')}${call('readingTimeLabel', String(readingMinutes(record.body)))}</span>${updated}</span>`
281
+ );
282
+ };
283
+
284
+ /**
285
+ * @param {import('../../../types/index.d.ts').AuthorNormalized | undefined} author
286
+ * @returns {string}
287
+ */
288
+ const avatar = (author) => {
289
+ const image = mediaImage(author?.avatar);
290
+ return image
291
+ ? renderImage({
292
+ image,
293
+ alt: '',
294
+ className: 'g-avatar',
295
+ fallbackSize: { width: 40, height: 40 },
296
+ })
297
+ : `<span class="g-avatar" aria-hidden="true">${escapeHtml(monogram(author?.displayName ?? ''))}</span>`;
298
+ };
299
+
300
+ /**
301
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
302
+ * @returns {import('../../../types/index.d.ts').AuthorNormalized[]}
303
+ */
304
+ const recordAuthors = (record) =>
305
+ record.frontmatter.authorIds.flatMap((id) => {
306
+ const author = authorsById.get(id);
307
+ return author ? [author] : [];
308
+ });
309
+
310
+ /**
311
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
312
+ * @param {boolean} [showUpdated] show the "Updated" date (article head)
313
+ * @returns {string}
314
+ */
315
+ const byline = (record, showUpdated = false) => {
316
+ const authors = recordAuthors(record);
317
+ const names = authors
318
+ .map(
319
+ (author) =>
320
+ `<a href="${escapeHtml(context.authorHref(author))}">${escapeHtml(author.displayName)}</a>`,
321
+ )
322
+ .join(', ');
323
+ return (
324
+ `<div class="g-byline">${avatar(authors[0])}` +
325
+ `<div class="g-byline-text"><span>${names}</span>${meta(record, showUpdated)}</div></div>`
326
+ );
327
+ };
328
+
329
+ /**
330
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
331
+ * @param {{partLabel?: string, headingLevel?: number}} [options] an
332
+ * optional series part label and the title's heading level (default 3)
333
+ * @param {'grid' | 'row'} [variant]
334
+ * @returns {string}
335
+ */
336
+ const card = (record, options = {}, variant = 'grid') => {
337
+ const fm = record.frontmatter;
338
+ const href = escapeHtml(recordHref(record));
339
+ const hero = mediaImage(fm.hero?.file);
340
+ const media = hero
341
+ ? `<figure class="g-card-media">${renderImage({ image: hero, alt: fm.hero?.role === 'decorative' ? '' : (fm.hero?.alt ?? '') })}</figure>`
342
+ : `<div class="g-card-placeholder" aria-hidden="true">${icon('sparkle')}</div>`;
343
+ const seriesTag = options.partLabel
344
+ ? `<span class="g-series-tag">${icon('book')}${escapeHtml(options.partLabel)}</span>`
345
+ : '';
346
+ const firstTag = fm.tags[0];
347
+ return (
348
+ `<article class="g-card${variant === 'row' ? ' g-card-row' : ''}">` +
349
+ media +
350
+ `<div class="g-card-body">` +
351
+ `<div class="g-card-top">${firstTag ? chip(firstTag) : ''}${seriesTag}</div>` +
352
+ `<h${options.headingLevel ?? 3}><a class="g-card-link" href="${href}">${escapeHtml(fm.title)}</a></h${options.headingLevel ?? 3}>` +
353
+ (fm.description
354
+ ? `<p class="g-card-excerpt">${escapeHtml(fm.description)}</p>`
355
+ : '') +
356
+ `<div class="g-card-foot">${meta(record)}<span aria-hidden="true">${icon('arrow')}</span></div>` +
357
+ `</div></article>`
358
+ );
359
+ };
360
+
361
+ return {
362
+ text,
363
+ call,
364
+ chip,
365
+ meta,
366
+ avatar,
367
+ byline,
368
+ card,
369
+ recordAuthors,
370
+ };
371
+ }
@@ -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>`,