@apleasantview/eleventy-plugin-baseline 0.1.0-next.29 → 0.1.0-next.33

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 CHANGED
@@ -1,6 +1,14 @@
1
- # Eleventy Plugin Baseline
1
+ # Eleventy Baseline
2
2
 
3
- An experimental Swiss army knife toolkit for Eleventy. Bundles handy helpers for assets, head/meta, navigation, sitemaps, debugging, and more — without turning into a full theme.
3
+ _An experimental Swiss army knife for Eleventy._
4
+
5
+ Eleventy Baseline is a lightweight toolkit built around a simple question:
6
+
7
+ > What if Eleventy had a minimal, optional layer of conventions — just enough to eliminate repetition, but not enough to feel restrictive?
8
+
9
+ It explores what a "core" for Eleventy could look like without becoming a framework or theme — small, practical tools rather than sweeping abstractions. If you've ever started a new Eleventy project and found yourself copy-pasting the same asset pipeline, the same head template, the same image shortcode for the third time, this is for you.
10
+
11
+ This is a practical, evolving baseline. Things might shift, break, or get renamed as the project evolves.
4
12
 
5
13
  ## Install
6
14
 
@@ -16,6 +24,8 @@ For a fresh project (install Eleventy and eleventy-img too):
16
24
  npm install @11ty/eleventy @11ty/eleventy-img @apleasantview/eleventy-plugin-baseline
17
25
  ```
18
26
 
27
+ Requires Eleventy 3.x and Node >=20.
28
+
19
29
  ## Usage
20
30
 
21
31
  In your Eleventy config (ESM):
@@ -34,13 +44,32 @@ export default function (eleventyConfig) {
34
44
  export const config = baselineConfig;
35
45
  ```
36
46
 
37
- Requires Eleventy 3.x.
47
+ ## What's included
48
+
49
+ When the plugin loads, you get core filters, Nunjucks globals, debugging utilities, and an image shortcode (via eleventy-img) out of the box. On top of that, the plugin is organized into opt-in modules — take what you need:
50
+
51
+ | Module | What it does |
52
+ |---|---|
53
+ | `assets-core` | Shared foundation for the asset pipeline |
54
+ | `assets-esbuild` | JS bundling via esbuild, with an inline injection filter for critical scripts |
55
+ | `assets-postcss` | CSS processing via PostCSS + cssnano, with an inline injection filter for critical styles |
56
+ | `head-core` | Drop `<baseline-head>` into your template and get sensible meta, canonical, og:image, and basic SEO defaults — processed by PostHTML at build time |
57
+ | `multilang-core` | Directory-based multilingual support: per-language collections, hreflang, sitemaps, and language normalization |
58
+ | `navigator-core` | Navigation tree helpers and a `_navigator` Nunjucks global |
59
+ | `sitemap-core` | XML sitemap generation with draft-page support |
38
60
 
39
61
  ## Docs
40
62
 
41
- Documentation tracks latest builds:
63
+ Full documentation tutorials, how-to guides, and reference — lives at:
42
64
  [https://eleventy-plugin-baseline.netlify.app/](https://eleventy-plugin-baseline.netlify.app/)
43
65
 
66
+ ## Contributing
67
+
68
+ Opinions, issues, and pull requests are welcome. If something doesn't work as documented, or
69
+ you've found a pattern that fits the spirit of the project,
70
+ [open an issue](https://github.com/apleasantview/eleventy-plugin-baseline/issues) and let's talk.
71
+ You can also find me on [Mastodon](https://mastodon.social/@crisverstraeten).
72
+
44
73
  ## License
45
74
 
46
75
  MIT. See `LICENSE`.
package/core/debug.js CHANGED
@@ -1,18 +1,33 @@
1
1
  import { inspect as utilInspect } from 'node:util';
2
2
 
3
3
  // Adapted from pdehaan - https://github.com/pdehaan/eleventy-plugin-debug
4
- const debugOptions = Object.assign({
5
- space: 0
6
- });
4
+ const debugOptions = { space: 0 };
7
5
 
6
+ /**
7
+ * Pretty-print an object using Node's util.inspect.
8
+ * @param {*} obj - Value to inspect.
9
+ * @param {Object} [options={}] - Options forwarded to util.inspect.
10
+ * @returns {string}
11
+ */
8
12
  function inspect(obj, options = {}) {
9
13
  return utilInspect(obj, options);
10
14
  }
11
15
 
16
+ /**
17
+ * Serialize an object to JSON.
18
+ * @param {*} obj - Value to serialize.
19
+ * @param {number} [space] - Indentation level (default 0, compact).
20
+ * @returns {string}
21
+ */
12
22
  function json(obj, space = debugOptions.space) {
13
23
  return JSON.stringify(obj, null, space);
14
24
  }
15
25
 
26
+ /**
27
+ * Return an object's own keys, sorted alphabetically.
28
+ * @param {Object} obj
29
+ * @returns {string[]}
30
+ */
16
31
  function keys(obj) {
17
32
  return Object.keys(obj).sort();
18
33
  }
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Test whether a value is a string.
3
+ * @param {*} object - Value to test.
4
+ * @returns {boolean}
5
+ */
1
6
  export default function isStringFilter(object) {
2
7
  return typeof object === 'string';
3
8
  }
@@ -3,6 +3,12 @@ import markdownit from 'markdown-it';
3
3
 
4
4
  const md = markdownit();
5
5
 
6
+ /**
7
+ * Render a string as inline Markdown (no wrapping <p> tag).
8
+ * @param {string} string - Markdown source.
9
+ * @returns {string} HTML output.
10
+ */
6
11
  export const markdownFilter = (string) => {
12
+ if (!string) return '';
7
13
  return md.renderInline(string);
8
14
  };
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Filter the current page out of a collection.
3
+ * Uses `this.ctx.page` from the Nunjucks runtime to identify the current page.
4
+ * @param {Array<Object>} [collection=[]] - Collection to filter.
5
+ * @returns {Array<Object>} Collection without the current page.
6
+ */
1
7
  export default function relatedPostsFilter(collection = []) {
2
8
  const page = this?.ctx?.page;
3
9
  if (!page?.url) return collection;
package/core/helpers.js CHANGED
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import { TemplatePath } from '@11ty/eleventy-utils';
3
2
 
4
3
  /**
@@ -14,11 +13,12 @@ export function addTrailingSlash(path) {
14
13
  }
15
14
 
16
15
  /**
17
- * Resolves the assets directory path from config
18
- * Follows Eleventy's pattern: join inputDir + rawDir, then normalize and add trailing slash
19
- * @param {string} inputDir - The input directory (e.g., "./src/")
20
- * @param {string} rawDir - Raw directory value from config (e.g., "assets")
21
- * @returns {{assetsDir: string, assetsDirRelative: string}}
16
+ * Resolves the assets directory paths from config.
17
+ * Joins inputDir/outputDir with rawDir, normalizes, and adds trailing slashes.
18
+ * @param {string} inputDir - The input directory (e.g., "./src/").
19
+ * @param {string} outputDir - The output directory (e.g., "./dist/").
20
+ * @param {string} rawDir - Raw directory value from config (e.g., "assets").
21
+ * @returns {{assetsDir: string, assetsOutputDir: string}}
22
22
  */
23
23
  export function resolveAssetsDir(inputDir, outputDir, rawDir) {
24
24
  // Join input/output with assets subdir and normalize
@@ -34,94 +34,3 @@ export function resolveAssetsDir(inputDir, outputDir, rawDir) {
34
34
  };
35
35
  }
36
36
 
37
- /**
38
- * Builds glob patterns for fast-glob (absolute paths)
39
- * @param {string[]} patterns - User-provided patterns
40
- * @param {string} assetsDir - Assets directory (relative, e.g., "./src/assets/")
41
- * @returns {string[]} Absolute glob patterns
42
- */
43
- export function buildGlobPatterns(patterns, assetsDir) {
44
- const assetsDirAbsolute = TemplatePath.absolutePath(TemplatePath.stripLeadingDotSlash(assetsDir));
45
-
46
- return patterns.map((pattern) => {
47
- const normalized = TemplatePath.standardizeFilePath(pattern);
48
- return normalized.startsWith('/') || path.isAbsolute(normalized)
49
- ? normalized
50
- : TemplatePath.join(assetsDirAbsolute, normalized);
51
- });
52
- }
53
-
54
- /**
55
- * Extracts file metadata from a file path
56
- * @param {string} filePath - Normalized file path
57
- * @returns {{basename: string, fileSlug: string, inputFileExtension: string}}
58
- */
59
- export function extractFileMetadata(filePath) {
60
- const ext = path.extname(filePath); // Returns extension with dot (e.g., ".css") or ""
61
- const inputFileExtension = ext && ext.length > 0 ? ext.slice(1) : '';
62
- const basename = TemplatePath.getLastPathSegment(filePath, false);
63
- const fileSlug = ext ? basename.slice(0, -ext.length) : basename;
64
-
65
- return { basename, fileSlug, inputFileExtension };
66
- }
67
-
68
- /**
69
- * Creates a collection item from a relative file path
70
- * @param {string} inputPath - Relative path from project root (e.g., "./src/assets/css/index.css")
71
- * @param {string} inputDir - Input directory (e.g., "./src/")
72
- * @param {string} outputDir - Output directory (e.g., "./dist/")
73
- * @param {string} assetsDirRelative - Assets directory relative to input (e.g., "assets")
74
- * @param {string} passthroughOutput - Output path for passthrough
75
- * @param {boolean} passthrough - Whether passthrough is enabled
76
- * @returns {object} Collection item
77
- */
78
- export function createCollectionItem(
79
- inputPath,
80
- inputDir,
81
- outputDir,
82
- assetsDirRelative,
83
- passthroughOutput,
84
- passthrough
85
- ) {
86
- const { basename, fileSlug, inputFileExtension } = extractFileMetadata(inputPath);
87
-
88
- // Get path relative to input directory
89
- // e.g., inputPath = "./src/assets/css/index.css", inputDir = "./src/"
90
- // relToInput = "assets/css/index.css"
91
- const relToInput = TemplatePath.stripLeadingSubPath(inputPath, TemplatePath.addLeadingDotSlash(inputDir));
92
-
93
- // outputPath: prepend output directory (with leading ./)
94
- // e.g., relToInput = "assets/css/index.css", outputDir = "./dist/"
95
- // outputPath = "./dist/assets/css/index.css"
96
- const outputPath = TemplatePath.addLeadingDotSlash(
97
- TemplatePath.normalize(TemplatePath.join(TemplatePath.addLeadingDotSlash(outputDir), relToInput))
98
- );
99
-
100
- // relToAssets: path relative to assets directory for URL generation
101
- // e.g., inputPath = "./src/assets/css/index.css", assetsDirRelative = "assets"
102
- // relToAssets = "css/index.css"
103
- const assetsDirPath = TemplatePath.addLeadingDotSlash(TemplatePath.join(inputDir, assetsDirRelative));
104
- const relToAssets = TemplatePath.stripLeadingSubPath(inputPath, assetsDirPath);
105
-
106
- const url = passthrough ? TemplatePath.join(passthroughOutput, relToAssets).replace(/\/$/, '') : undefined;
107
-
108
- // filePathStem: path relative to input without extension, with leading slash
109
- // e.g., relToInput = "assets/css/index.css"
110
- // filePathStem = "/assets/css/index"
111
- const filePathStem =
112
- '/' +
113
- (inputFileExtension
114
- ? relToInput.slice(0, -inputFileExtension.length - 1) // Remove extension and dot
115
- : relToInput);
116
-
117
- return {
118
- inputPath,
119
- outputPath,
120
- basename,
121
- fileSlug,
122
- inputFileExtension,
123
- filePathStem,
124
- dir: TemplatePath.getDirFromFilePath(inputPath),
125
- url
126
- };
127
- }
package/core/logging.js CHANGED
@@ -25,8 +25,8 @@ export function logIfVerbose(verbose, message, ...args) {
25
25
  * @param {boolean} verbose - Whether verbose logging is enabled
26
26
  * @param {string} message - Warning message
27
27
  */
28
- export function warnIfVerbose(verbose, message) {
28
+ export function warnIfVerbose(verbose, message, ...args) {
29
29
  if (verbose) {
30
- console.warn(`[eleventy-plugin-baseline] WARN ${message}`);
30
+ console.warn(`[eleventy-plugin-baseline] WARN ${message}`, ...args);
31
31
  }
32
32
  }
package/core/modules.js CHANGED
@@ -5,8 +5,6 @@ import { EleventyHtmlBasePlugin } from '@11ty/eleventy';
5
5
  import multilangCore from '../modules/multilang-core/plugins/multilang-core.js';
6
6
  import navigatorCore from '../modules/navigator-core/plugins/navigator-core.js';
7
7
  import assetsCore from '../modules/assets-core/plugins/assets-core.js';
8
- import assetsPostCSS from '../modules/assets-postcss/plugins/assets-postcss.js';
9
- import assetsESBuild from '../modules/assets-esbuild/plugins/assets-esbuild.js';
10
8
  import headCore from '../modules/head-core/plugins/head-core.js';
11
9
  import sitemapCore from '../modules/sitemap-core/plugins/sitemap-core.js';
12
10
 
@@ -15,8 +13,6 @@ export default {
15
13
  multilangCore,
16
14
  navigatorCore,
17
15
  assetsCore,
18
- assetsPostCSS,
19
- assetsESBuild,
20
16
  headCore,
21
17
  sitemapCore
22
18
  };
@@ -1,128 +1,162 @@
1
- import path from 'node:path';
2
- import Image from '@11ty/eleventy-img';
3
-
4
- const DEFAULT_WIDTHS = [320, 640, 960, 1280];
5
- const DEFAULT_FORMATS = ['avif', 'webp', 'jpeg'];
6
- const DEFAULT_SIZES = '(max-width: 768px) 100vw, 768px';
7
-
8
- function pickRenditions(metadata) {
9
- // Use the first available format; first entry is smallest, last is largest.
10
- const firstFormat = Object.values(metadata)[0];
11
- const lowsrc = firstFormat?.[0];
12
- const highsrc = firstFormat?.[firstFormat.length - 1];
13
- return { lowsrc, highsrc };
14
- }
15
-
16
- /**
17
- * Responsive image shortcode using @11ty/eleventy-img.
18
- *
19
- * @param {Object} options
20
- * @param {string} options.src Required image source (local or remote).
21
- * @param {string} options.alt Required alt text (empty string allowed for decorative).
22
- * @param {string} [options.caption=""] Optional caption; enables figure wrapper when non-empty.
23
- * @param {("lazy"|"eager")} [options.loading="lazy"] Loading behavior.
24
- * @param {string} [options.containerClass=""] Class applied to <picture>.
25
- * @param {string} [options.imageClass=""] Class applied to <img>.
26
- * @param {Array<number|string>} [options.widths=DEFAULT_WIDTHS] Widths passed to eleventy-img.
27
- * @param {string} [options.sizes=DEFAULT_SIZES] Sizes attribute used on sources.
28
- * @param {string[]} [options.formats=DEFAULT_FORMATS] Output formats (order matters).
29
- * @param {string} [options.outputDir] Output directory for generated assets (defaults to `./dist/media/` or `./<dir.output>/media/` when set).
30
- * @param {string} [options.urlPath="/media/"] Public URL base for generated assets.
31
- * @param {Object} [options.attrs={}] Extra attributes applied to <img>; `class` merges with imageClass.
32
- * @param {string} [options.style] Inline style applied to <img> (alias for attrs.style).
33
- * @param {boolean} [options.figure=true] Wrap in <figure> when caption is provided.
34
- * @param {boolean} [options.setDimensions=true] When false, omit width/height on <img>.
35
- */
36
- export async function imageShortcode(options = {}) {
37
- const outputBase = this?.eleventy?.directories?.output || 'dist';
38
- const {
39
- src,
40
- alt,
41
- caption = '',
42
- loading = 'lazy',
43
- containerClass = '',
44
- imageClass = '',
45
- style,
46
- widths = DEFAULT_WIDTHS,
47
- sizes = DEFAULT_SIZES,
48
- formats = DEFAULT_FORMATS,
49
- outputDir = path.join('.', outputBase, 'media'),
50
- urlPath = '/media/',
51
- attrs = {},
52
- figure = true,
53
- setDimensions = true
54
- } = options;
55
- const hasImageTransformPlugin = this.ctx._baseline.hasImageTransformPlugin;
56
-
57
- if (!src) throw new Error('imageShortcode: src is required');
58
- if (alt === undefined) {
59
- throw new Error('imageShortcode: alt is required (use empty string for decorative images)');
60
- }
61
-
62
- const normalizedCaption = caption == null ? '' : String(caption);
63
- const normalizedAlt = alt == null ? '' : String(alt);
64
-
65
- const inputDir = this?.eleventy?.directories?.input;
66
- const isRemote = /^https?:\/\//i.test(src);
67
- const resolvedSrc = !isRemote && inputDir ? path.join(inputDir, src.replace(/^\//, '')) : src;
68
-
69
- const metadata = await Image(resolvedSrc, {
70
- transformOnRequest: process.env.ELEVENTY_RUN_MODE === 'serve',
71
- widths: [...widths],
72
- formats: [...formats],
73
- outputDir,
74
- urlPath,
75
- filenameFormat(id, srcPath, width, format) {
76
- const extension = path.extname(srcPath);
77
- const name = path.basename(srcPath, extension);
78
- return `${name}-${width}w.${format}`;
79
- }
80
- });
81
-
82
- const { lowsrc, highsrc } = pickRenditions(metadata);
83
- if (!lowsrc || !highsrc) {
84
- throw new Error(`imageShortcode: no renditions produced for ${src}`);
85
- }
86
-
87
- const sourceTags = Object.values(metadata)
88
- .map((formatEntries) => {
89
- const type = formatEntries[0].sourceType;
90
- const srcset = formatEntries.map((entry) => entry.srcset).join(', ');
91
- return `<source type="${type}" srcset="${srcset}" sizes="${sizes}">`;
92
- })
93
- .join('\n');
94
-
95
- const { class: attrClass, ...restAttrs } = attrs;
96
- const combinedClass = [imageClass, attrClass].filter(Boolean).join(' ').trim() || undefined;
97
-
98
- const imageAttributes = {
99
- src: lowsrc.url,
100
- alt: normalizedAlt,
101
- loading,
102
- decoding: loading === 'eager' ? 'sync' : 'async',
103
- class: combinedClass,
104
- style,
105
- ...(setDimensions ? { width: highsrc.width, height: highsrc.height } : {}),
106
- ...restAttrs,
107
- ...(hasImageTransformPlugin ? { 'eleventy:ignore': true } : {})
108
- };
109
-
110
- const imgAttrString = Object.entries(imageAttributes)
111
- .filter(([, value]) => value !== undefined && value !== null && value !== '')
112
- .map(([key, value]) => (value === true ? key : `${key}="${value}"`))
113
- .join(' ');
114
-
115
- const pictureClass = containerClass && containerClass.trim() ? ` class="${containerClass.trim()}"` : '';
116
-
117
- const picture = `<picture${pictureClass}>
118
- ${sourceTags}
119
- <img ${imgAttrString}>
120
- </picture>`;
121
-
122
- if (!figure || !normalizedCaption) return picture;
123
-
124
- return `<figure>
125
- ${picture}
126
- <figcaption>${normalizedCaption}</figcaption>
127
- </figure>`;
128
- }
1
+ import path from 'node:path';
2
+ import Image from '@11ty/eleventy-img';
3
+
4
+ const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920, 'auto'];
5
+ const DEFAULT_FORMATS = ['avif', 'webp'];
6
+ const DEFAULT_SIZES = '(max-width: 768px) 100vw, 768px';
7
+
8
+ /**
9
+ * Pick the smallest and largest rendition from eleventy-img metadata.
10
+ * Uses the first available format; entries are ordered smallest → largest.
11
+ * @param {Object} metadata - eleventy-img metadata keyed by format.
12
+ * @returns {{lowsrc: Object, highsrc: Object}} Smallest and largest rendition.
13
+ */
14
+ function pickRenditions(metadata) {
15
+ const firstFormat = Object.values(metadata)[0];
16
+ const lowsrc = firstFormat?.[0];
17
+ const highsrc = firstFormat?.[firstFormat.length - 1];
18
+ return { lowsrc, highsrc };
19
+ }
20
+
21
+ /**
22
+ * Responsive image shortcode using @11ty/eleventy-img.
23
+ *
24
+ * @param {Object} options
25
+ * @param {string} options.src Required image source (local or remote).
26
+ * @param {string} options.alt Required alt text (empty string allowed for decorative).
27
+ * @param {string} [options.caption=""] Optional caption; enables figure wrapper when non-empty.
28
+ * @param {("lazy"|"eager")} [options.loading="lazy"] Loading behavior.
29
+ * @param {string} [options.containerClass=""] Class applied to <picture>.
30
+ * @param {string} [options.imageClass=""] Class applied to <img>.
31
+ * @param {Array<number|string>} [options.widths=DEFAULT_WIDTHS] Widths passed to eleventy-img.
32
+ * @param {string} [options.sizes=DEFAULT_SIZES] Sizes attribute used on sources.
33
+ * @param {string[]} [options.formats=DEFAULT_FORMATS] Output formats (order matters).
34
+ * @param {string} [options.outputDir] Output directory for generated assets.
35
+ * @param {string} [options.urlPath="/media/"] Public URL base for generated assets.
36
+ * @param {Object} [options.attrs={}] Extra attributes applied to <img>; `class` merges with imageClass.
37
+ * @param {string} [options.style] Inline style applied to <img>. Separate from attrs.style — if both are passed, attrs.style takes precedence via restAttrs spread.
38
+ * @param {boolean} [options.figure=true] Wrap in <figure> when caption is provided.
39
+ * @param {boolean} [options.setDimensions=true] When false, omit width/height on <img>.
40
+ */
41
+ export async function imageShortcode(options = {}) {
42
+ const outputBase = this?.eleventy?.directories?.output || 'dist';
43
+ const {
44
+ src,
45
+ alt,
46
+ caption = '',
47
+ loading = 'lazy',
48
+ containerClass = '',
49
+ imageClass = '',
50
+ style,
51
+ widths = DEFAULT_WIDTHS,
52
+ sizes = DEFAULT_SIZES,
53
+ formats = DEFAULT_FORMATS,
54
+ outputDir = path.join('.', outputBase, 'media'),
55
+ urlPath = '/media/',
56
+ attrs = {},
57
+ figure = true,
58
+ setDimensions = true
59
+ } = options;
60
+ // Read from global data set during plugin init. When true, `eleventy:ignore`
61
+ // is added to the <img> (line 140) to prevent double-processing.
62
+ const hasImageTransformPlugin = this.ctx._baseline.hasImageTransformPlugin;
63
+
64
+ // --- Validation and normalization ---
65
+
66
+ if (!src) throw new Error(`imageShortcode: src is required (received ${JSON.stringify(src)})`);
67
+ if (alt == null) {
68
+ console.warn('imageShortcode: alt is required (use empty string for decorative images)');
69
+ }
70
+
71
+ const normalizedCaption = String(caption);
72
+ const normalizedAlt = alt == null ? '' : String(alt);
73
+
74
+ const inputDir = this?.eleventy?.directories?.input;
75
+ const isRemote = /^https?:\/\//i.test(src);
76
+ // Note: remote URLs rely on eleventy-img's built-in fetch — no timeout/retry control at shortcode level.
77
+ const resolvedSrc = !isRemote && inputDir ? path.join(inputDir, src.replace(/^\//, '')) : src;
78
+
79
+ const imageOptions = {
80
+ widths: [...widths],
81
+ formats: [...formats],
82
+ outputDir,
83
+ urlPath,
84
+ filenameFormat(id, srcPath, width, format) {
85
+ const extension = path.extname(srcPath);
86
+ const name = path.basename(srcPath, extension);
87
+ return `${name}-${id.slice(0, 6)}-${width}w.${format}`;
88
+ }
89
+ };
90
+
91
+ // --- Image processing ---
92
+ // In serve mode, `transformOnRequest` defers processing to first browser request
93
+ // for faster dev startup. If it fails, retry without it — this is an edge case
94
+ // but one that has bitten in practice. In build mode, errors surface immediately.
95
+
96
+ let metadata;
97
+ try {
98
+ metadata = await Image(resolvedSrc, {
99
+ transformOnRequest: process.env.ELEVENTY_RUN_MODE === 'serve',
100
+ ...imageOptions
101
+ });
102
+ } catch (error) {
103
+ if (process.env.ELEVENTY_RUN_MODE === 'serve') {
104
+ console.warn(`imageShortcode: transformOnRequest failed for ${src}, retrying.\n > ${error?.message || error}`);
105
+ metadata = await Image(resolvedSrc, imageOptions);
106
+ } else {
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ const { lowsrc, highsrc } = pickRenditions(metadata);
112
+ if (!lowsrc || !highsrc) {
113
+ throw new Error(`imageShortcode: no renditions produced for ${src}`);
114
+ }
115
+
116
+ // --- HTML assembly ---
117
+ // One <source> per format, each carrying the full srcset for that format.
118
+ const sourceTags = Object.values(metadata)
119
+ .map((formatEntries) => {
120
+ const type = formatEntries[0].sourceType;
121
+ const srcset = formatEntries.map((entry) => entry.srcset).join(', ');
122
+ return `<source type="${type}" srcset="${srcset}" sizes="${sizes}">`;
123
+ })
124
+ .join('\n');
125
+
126
+ // Pull `class` out of attrs so it can merge with imageClass. Remaining attrs
127
+ // and `eleventy:ignore` (if needed) are spread onto imageAttributes below.
128
+ const { class: attrClass, ...restAttrs } = attrs;
129
+ const combinedClass = [imageClass, attrClass].filter(Boolean).join(' ').trim() || undefined;
130
+
131
+ const imageAttributes = {
132
+ src: lowsrc.url,
133
+ alt: normalizedAlt,
134
+ loading,
135
+ decoding: loading === 'eager' ? 'sync' : 'async',
136
+ class: combinedClass,
137
+ style,
138
+ ...(setDimensions ? { width: highsrc.width, height: highsrc.height } : {}),
139
+ ...restAttrs,
140
+ ...(hasImageTransformPlugin ? { 'eleventy:ignore': true } : {})
141
+ };
142
+
143
+ // Build the attribute string, dropping any empty/null values to keep output clean.
144
+ const imgAttrString = Object.entries(imageAttributes)
145
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
146
+ .map(([key, value]) => (value === true ? key : `${key}="${value}"`))
147
+ .join(' ');
148
+
149
+ const pictureClass = containerClass && containerClass.trim() ? ` class="${containerClass.trim()}"` : '';
150
+
151
+ const picture = `<picture${pictureClass}>
152
+ ${sourceTags}
153
+ <img ${imgAttrString}>
154
+ </picture>`;
155
+
156
+ if (!figure || !normalizedCaption) return picture;
157
+
158
+ return `<figure>
159
+ ${picture}
160
+ <figcaption>${normalizedCaption}</figcaption>
161
+ </figure>`;
162
+ }