@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala2/template",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Deterministic Eleventy-backed static publication renderer for Galascribe portable repositories.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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-paint-panel); color: var(--gala-color-panel-text); }
463
- .g-card-placeholder .g-icon { width: 2.25rem; height: 2.25rem; opacity: .85; }
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 hero = mediaImage(fm.hero?.file);
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: fm.hero?.role === 'decorative' ? '' : (fm.hero?.alt ?? '') })}</figure>`
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
+ * (`![alt](assets/x.png)`), `./`-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').ContentFrontmatterNormalized} fm
503
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
503
504
  * @returns {{socialImageRef?: {path: string, sourceDigest: string}, socialImageAlt?: string}}
504
505
  */
505
- const socialOfContent = (fm) =>
506
- fm.socialImage
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
- : fm.hero
511
+ : media
509
512
  ? {
510
- socialImageRef: fm.hero.file,
513
+ socialImageRef: media.file,
511
514
  socialImageAlt:
512
- fm.hero.role === 'informative' && fm.hero.alt
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.frontmatter
1008
- : frontmatter,
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(frontmatter).socialImageRef),
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 hero = mediaImage(fm.hero?.file);
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: fm.hero?.role === 'decorative' ? '' : (fm.hero?.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
- if (contentRecord.frontmatter.hero) {
81
- recordResolvedFile(
82
- record,
83
- contentRecord.frontmatter.hero.file,
84
- contentRecord.sourceRevision,
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,