@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.
- package/README.md +88 -59
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +1 -1
- package/src/core/index.js +180 -50
- package/src/core/internal/appearance/base-layer.js +711 -137
- package/src/core/internal/appearance/bootstrap-script.js +570 -83
- package/src/core/internal/appearance/contract.js +6 -5
- package/src/core/internal/appearance/controller-markup.js +46 -55
- package/src/core/internal/appearance/minify-script.js +69 -0
- package/src/core/internal/appearance/styling-contract.js +199 -17
- package/src/core/internal/chrome.js +199 -0
- package/src/core/internal/components.js +379 -0
- package/src/core/internal/eleventy-render.js +13 -4
- package/src/core/internal/feeds.js +74 -6
- package/src/core/internal/icons.js +52 -0
- package/src/core/internal/llms.js +252 -0
- package/src/core/internal/media/pipeline.js +32 -17
- package/src/core/internal/messages.js +104 -0
- package/src/core/internal/page-kinds.js +918 -290
- package/src/core/internal/render-policy-content.js +2 -2
- package/src/core/internal/robots.js +78 -0
- package/src/core/internal/route-labels.js +31 -0
- package/src/core/internal/seo.js +53 -0
- package/src/core/internal/skeleton.js +54 -129
- package/src/core/internal/structured-data.js +247 -0
|
@@ -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="{{
|
|
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="{{
|
|
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>`,
|