@rathnasgala2/template 4.1.0 → 4.2.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/package.json
CHANGED
|
@@ -459,8 +459,8 @@ export const GALA_BASE_COMPONENT_CSS = `
|
|
|
459
459
|
.g-card-media { margin: 0; aspect-ratio: 16 / 10; overflow: hidden; border-radius: var(--gala-radius-media); background: var(--gala-color-surface-raised); border-bottom: var(--gala-border-media-divider); max-width: 100%; }
|
|
460
460
|
.g-card-media img { width: 100%; height: 100%; object-fit: cover; filter: var(--gala-media-filter); transition: transform 1s var(--gala-ease-standard), filter var(--gala-duration-slow) var(--gala-ease-standard); }
|
|
461
461
|
.g-card:hover .g-card-media img { transform: scale(var(--gala-media-zoom)); filter: var(--gala-media-filter-hover); }
|
|
462
|
-
.g-card-placeholder { display: grid; place-items: center; aspect-ratio: 16 / 10; width: 100%; overflow: hidden; border-radius: var(--gala-radius-media); background: var(--gala-
|
|
463
|
-
.g-card-placeholder .g-icon { width: 2.25rem; height: 2.25rem; opacity: .
|
|
462
|
+
.g-card-placeholder { display: grid; place-items: center; aspect-ratio: 16 / 10; width: 100%; overflow: hidden; border-radius: var(--gala-radius-media); background: var(--gala-color-surface-raised); color: var(--gala-color-text-muted); }
|
|
463
|
+
.g-card-placeholder .g-icon { width: 2.25rem; height: 2.25rem; opacity: .7; }
|
|
464
464
|
.g-hero-media .g-card-placeholder { height: 100%; aspect-ratio: auto; border-radius: 0; }
|
|
465
465
|
.g-card-body { display: flex; flex-direction: column; gap: .6rem; padding: var(--gala-card-pad); flex: 1; min-width: 0; }
|
|
466
466
|
.g-card-top { display: flex; align-items: center; justify-content: space-between; gap: var(--gala-space-2); min-height: 1.6rem; }
|
|
@@ -19,6 +19,7 @@ import { escapeHtml } from './skeleton.js';
|
|
|
19
19
|
import { icon } from './icons.js';
|
|
20
20
|
import { joinBasePathAndRoute } from './route.js';
|
|
21
21
|
import { resolveOriginalDerivativePath } from './seo.js';
|
|
22
|
+
import { cardMediaOf } from './first-body-image.js';
|
|
22
23
|
|
|
23
24
|
/** Reading speed used for every reading-time estimate. */
|
|
24
25
|
export const WORDS_PER_MINUTE = 230;
|
|
@@ -395,9 +396,10 @@ export function createComponents(context) {
|
|
|
395
396
|
const card = (record, options = {}, variant = 'grid') => {
|
|
396
397
|
const fm = record.frontmatter;
|
|
397
398
|
const href = escapeHtml(recordHref(record));
|
|
398
|
-
const
|
|
399
|
+
const cardMedia = cardMediaOf(record);
|
|
400
|
+
const hero = mediaImage(cardMedia?.file);
|
|
399
401
|
const media = hero
|
|
400
|
-
? `<figure class="g-card-media">${renderImage({ image: hero, alt:
|
|
402
|
+
? `<figure class="g-card-media">${renderImage({ image: hero, alt: cardMedia?.role === 'decorative' ? '' : (cardMedia?.alt ?? '') })}</figure>`
|
|
401
403
|
: `<div class="g-card-placeholder" aria-hidden="true">${icon('sparkle')}</div>`;
|
|
402
404
|
const seriesTag = options.partLabel
|
|
403
405
|
? `<span class="g-series-tag">${icon('book')}${escapeHtml(options.partLabel)}</span>`
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The picture a post shows where the post is summarised (cards, the home
|
|
3
|
+
* hero, `og:image`) when its frontmatter carries no `hero`.
|
|
4
|
+
*
|
|
5
|
+
* A content record's `body` is already render-policy-conformant HTML (the
|
|
6
|
+
* authored Markdown after `normalizeAuthoredMarkdown`), so inline images
|
|
7
|
+
* (``), `./`-prefixed paths and reference-style images
|
|
8
|
+
* (`![alt][ref]` with a `[ref]: assets/x.png` definition) have all become
|
|
9
|
+
* `<img src alt>` elements by the time this module sees them; code samples
|
|
10
|
+
* are `<pre>`/`<code>` text whose `<` is entity-encoded. The scan is
|
|
11
|
+
* therefore over `<img>` elements, with the same path rule the prose
|
|
12
|
+
* renderer resolves them by (`media/content-images.js`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { unescapeHtml } from './skeleton.js';
|
|
16
|
+
import { repositoryPathOfImageSource } from './media/content-images.js';
|
|
17
|
+
|
|
18
|
+
/** Code regions, removed before the scan so an image shown as code is skipped. */
|
|
19
|
+
const CODE_REGION = /<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>/g;
|
|
20
|
+
|
|
21
|
+
const IMG_ELEMENT = /<img\b[^>]*>/g;
|
|
22
|
+
const ATTRIBUTE = /\s([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
|
|
23
|
+
|
|
24
|
+
/** The media types a card picture may be (same set as body images). */
|
|
25
|
+
const IMAGE_MEDIA_TYPES = new Set([
|
|
26
|
+
'image/png',
|
|
27
|
+
'image/jpeg',
|
|
28
|
+
'image/webp',
|
|
29
|
+
'image/avif',
|
|
30
|
+
'image/gif',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} body a content record's policy-conformant HTML body
|
|
35
|
+
* @returns {{path: string, alt: string} | undefined} the first image whose
|
|
36
|
+
* source is a repository-relative `assets/` path (remote, `data:` and
|
|
37
|
+
* code-region images are skipped), with its decoded alt text
|
|
38
|
+
*/
|
|
39
|
+
export function firstBodyImage(body) {
|
|
40
|
+
for (const element of body.replace(CODE_REGION, '').match(IMG_ELEMENT) ??
|
|
41
|
+
[]) {
|
|
42
|
+
/** @type {{src?: string, alt?: string}} */
|
|
43
|
+
const attributes = {};
|
|
44
|
+
for (const [, name, value] of element.matchAll(ATTRIBUTE)) {
|
|
45
|
+
if (name === 'src' || name === 'alt') attributes[name] = value;
|
|
46
|
+
}
|
|
47
|
+
const path = repositoryPathOfImageSource(attributes.src);
|
|
48
|
+
if (path?.startsWith('assets/')) {
|
|
49
|
+
return { path, alt: unescapeHtml(attributes.alt ?? '') };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @type {WeakMap<object, unknown>} */
|
|
56
|
+
const cache = new WeakMap();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The picture a record shows on cards, the home hero and as its social
|
|
60
|
+
* image: `frontmatter.hero` when present, else the first body image that the
|
|
61
|
+
* record's own `media[]` inventories (so it carries the `sourceDigest` the
|
|
62
|
+
* media pipeline processed it by). Computed once per record.
|
|
63
|
+
*
|
|
64
|
+
* @param {import('../../../types/index.d.ts').ContentBuildRecord} record
|
|
65
|
+
* @returns {{file: {path: string, sourceDigest: string}, alt?: string, role: 'informative' | 'decorative'} | undefined}
|
|
66
|
+
*/
|
|
67
|
+
export function cardMediaOf(record) {
|
|
68
|
+
if (cache.has(record)) {
|
|
69
|
+
return /** @type {ReturnType<typeof cardMediaOf>} */ (cache.get(record));
|
|
70
|
+
}
|
|
71
|
+
/** @type {ReturnType<typeof cardMediaOf>} */
|
|
72
|
+
let media = /** @type {any} */ (record.frontmatter.hero);
|
|
73
|
+
if (!media) {
|
|
74
|
+
const image = firstBodyImage(record.body);
|
|
75
|
+
const entry = image
|
|
76
|
+
? record.media?.find(
|
|
77
|
+
(file) =>
|
|
78
|
+
file.path === image.path && IMAGE_MEDIA_TYPES.has(file.mediaType),
|
|
79
|
+
)
|
|
80
|
+
: undefined;
|
|
81
|
+
if (image && entry) {
|
|
82
|
+
media = {
|
|
83
|
+
file: { path: entry.path, sourceDigest: entry.sourceDigest },
|
|
84
|
+
alt: image.alt,
|
|
85
|
+
role: 'informative',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
cache.set(record, media);
|
|
90
|
+
return media;
|
|
91
|
+
}
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
resolveEditions,
|
|
79
79
|
} from './editions.js';
|
|
80
80
|
import { truncateAtWord } from './seo.js';
|
|
81
|
+
import { cardMediaOf } from './first-body-image.js';
|
|
81
82
|
import { createStructuredData } from './structured-data.js';
|
|
82
83
|
import { icon } from './icons.js';
|
|
83
84
|
import {
|
|
@@ -499,21 +500,22 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
499
500
|
}
|
|
500
501
|
: {};
|
|
501
502
|
/**
|
|
502
|
-
* @param {import('../../../types/index.d.ts').
|
|
503
|
+
* @param {import('../../../types/index.d.ts').ContentBuildRecord} record
|
|
503
504
|
* @returns {{socialImageRef?: {path: string, sourceDigest: string}, socialImageAlt?: string}}
|
|
504
505
|
*/
|
|
505
|
-
const socialOfContent = (
|
|
506
|
-
fm.
|
|
506
|
+
const socialOfContent = (record) => {
|
|
507
|
+
const fm = record.frontmatter;
|
|
508
|
+
const media = cardMediaOf(record);
|
|
509
|
+
return fm.socialImage
|
|
507
510
|
? { socialImageRef: fm.socialImage, socialImageAlt: fm.title }
|
|
508
|
-
:
|
|
511
|
+
: media
|
|
509
512
|
? {
|
|
510
|
-
socialImageRef:
|
|
513
|
+
socialImageRef: media.file,
|
|
511
514
|
socialImageAlt:
|
|
512
|
-
|
|
513
|
-
? fm.hero.alt
|
|
514
|
-
: fm.title,
|
|
515
|
+
media.role === 'informative' && media.alt ? media.alt : fm.title,
|
|
515
516
|
}
|
|
516
517
|
: defaultSocial;
|
|
518
|
+
};
|
|
517
519
|
/**
|
|
518
520
|
* @param {import('../../../types/index.d.ts').ContentBuildRecord} record
|
|
519
521
|
* @returns {string} the page description: authored, else the body's
|
|
@@ -1004,15 +1006,15 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
1004
1006
|
// An edition with no social image of its own shares its article's.
|
|
1005
1007
|
const social = socialOfContent(
|
|
1006
1008
|
isEdition && !frontmatter.socialImage && !frontmatter.hero
|
|
1007
|
-
? original
|
|
1008
|
-
:
|
|
1009
|
+
? original
|
|
1010
|
+
: record,
|
|
1009
1011
|
);
|
|
1010
1012
|
const articleGraph = isArticle
|
|
1011
1013
|
? sd.article({
|
|
1012
1014
|
url: ownUrl,
|
|
1013
1015
|
headline: frontmatter.title,
|
|
1014
1016
|
description: contentDescription(record),
|
|
1015
|
-
image: absoluteImage(socialOfContent(
|
|
1017
|
+
image: absoluteImage(socialOfContent(record).socialImageRef),
|
|
1016
1018
|
datePublished: frontmatter.publishedAt,
|
|
1017
1019
|
dateModified: contentLastModified(frontmatter),
|
|
1018
1020
|
language: frontmatter.language,
|
|
@@ -1095,7 +1097,8 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
1095
1097
|
const homeFeatured = featured
|
|
1096
1098
|
? (() => {
|
|
1097
1099
|
const fm = featured.frontmatter;
|
|
1098
|
-
const
|
|
1100
|
+
const cardMedia = cardMediaOf(featured);
|
|
1101
|
+
const hero = mediaImage(cardMedia?.file);
|
|
1099
1102
|
const href = escapeHtml(recordHref(featured));
|
|
1100
1103
|
return (
|
|
1101
1104
|
`<section class="g-wrap g-hero" aria-labelledby="hero-title">` +
|
|
@@ -1103,7 +1106,10 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
1103
1106
|
(hero
|
|
1104
1107
|
? renderImage({
|
|
1105
1108
|
image: hero,
|
|
1106
|
-
alt:
|
|
1109
|
+
alt:
|
|
1110
|
+
cardMedia?.role === 'decorative'
|
|
1111
|
+
? ''
|
|
1112
|
+
: (cardMedia?.alt ?? ''),
|
|
1107
1113
|
priority: true,
|
|
1108
1114
|
})
|
|
1109
1115
|
: `<span class="g-card-placeholder">${icon('sparkle')}</span>`) +
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* appears once, and its role is the earliest value in the enum order.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { cardMediaOf } from './first-body-image.js';
|
|
10
|
+
|
|
9
11
|
/** @type {readonly string[]} */
|
|
10
12
|
const ROLE_PRIORITY = Object.freeze([
|
|
11
13
|
'publication',
|
|
@@ -77,13 +79,12 @@ export function collectIncludedSources(buildInput) {
|
|
|
77
79
|
'content',
|
|
78
80
|
contentRecord.sourceRevision,
|
|
79
81
|
);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
82
|
+
// The hero, else the first body image a card shows in its place.
|
|
83
|
+
recordResolvedFile(
|
|
84
|
+
record,
|
|
85
|
+
cardMediaOf(contentRecord)?.file,
|
|
86
|
+
contentRecord.sourceRevision,
|
|
87
|
+
);
|
|
87
88
|
recordResolvedFile(
|
|
88
89
|
record,
|
|
89
90
|
contentRecord.frontmatter.socialImage,
|