1// TODO: consider making this a bit more flexible with breakpoints and sizes 2// For now all randomly hardcoded based on "common" breakpoints/sizes 3 4// TODO: convert into a more flexible mapping object sizes/breakpoint structure. Idea: 5// { 6// sm: { breakpoint: 671, sizes: [320, 480, 640] }, 7// md: { breakpoint: 1055, sizes: [768, 1024] }, 8// lg: { breakpoint: 1599, sizes: [1280, 1440] }, 9// max: { breakpoint: 1600, sizes: [1600, 1920] }, 10// } 11// Should always be aligned to scripts/breakpoints.js 12const defaultMediaQueryBreakpoints = [671, 1055, 1599, 1600]; 13const defaultMediaQueryBreakpointsMapping = { 14 671: 'sm', 15 1055: 'md', 16 1599: 'lg', 17 1600: 'max', 18}; 19const defaultSrcSizes = [320, 480, 640, 768, 1024, 1280, 1440, 1600, 1920]; 20const defaultResize = 320; 21 22/** 23 * Generates the sizes attribute for responsive images based on columns map per breakpoint 24 * @param {Object} columnsMap - Number of columns per breakpoint (e.g., { sm: 1, md: 2, lg: 2, max: 2 }) 25 * @returns {string} The sizes attribute value 26 */ 27export function generateSizesFromColumnsMap(columnsMap) { 28 // Grid system constants - matches styles/base/grid.css and styles/ui.css 29 const MAX_CONTENT_WIDTH = 1200; // --page-max-content-width in styles/ui.css 30 const SIDE_MARGIN = 24; // --side-margin in styles/base/grid.css 31 const MARGIN_TOTAL = SIDE_MARGIN * 2; // Total horizontal margins 32 33 // Extract breakpoint pixel values from mapping 34 // Converts { 671: 'sm', 1055: 'md', ... } to { sm: 671, md: 1055, ... } 35 const breakpointValues = {}; 36 Object.entries(defaultMediaQueryBreakpointsMapping).forEach(([px, name]) => { 37 breakpointValues[name] = parseInt(px, 10); 38 }); 39 // Result: { sm: 671, md: 1055, lg: 1599, max: 1600 } 40 41 // Resolve column count for a breakpoint with fallback chain 42 // Fallback order: max â lg â md â sm â 1 43 const getColumns = (breakpoint) => { 44 // If explicitly set, use it 45 if (columnsMap[breakpoint]) { 46 return columnsMap[breakpoint]; 47 } 48 49 // Fallback chain 50 const fallbacks = { max: 'lg', lg: 'md', md: 'sm', sm: null }; 51 const fallbackBreakpoint = fallbacks[breakpoint]; 52 53 // Recursively check fallback, or default to 1 column 54 return fallbackBreakpoint ? getColumns(fallbackBreakpoint) : 1; 55 }; 56 57 // Pre-resolve all column values 58 const cols = { 59 max: getColumns('max'), 60 lg: getColumns('lg'), 61 md: getColumns('md'), 62 sm: getColumns('sm'), 63 }; 64 65 // Build sizes array - only add when columns change from previous breakpoint 66 const sizes = []; 67 68 if (cols.max) { 69 sizes.push(`(min-width: ${MAX_CONTENT_WIDTH}px) ${MAX_CONTENT_WIDTH / cols.max}px`); 70 } 71 72 if (cols.lg !== cols.max) { 73 sizes.push(`(min-width: ${breakpointValues.lg + 1}px) calc(${100 / cols.lg}vw - ${MARGIN_TOTAL}px)`); 74 } 75 76 // Add md breakpoint when columns differ from sm 77 // Use the actual md min breakpoint (672) not md+1 to cover the tablet range 78 if (cols.md !== cols.sm) { 79 sizes.push(`(min-width: ${breakpointValues.sm + 1}px) calc(${100 / cols.md}vw - ${MARGIN_TOTAL}px)`); 80 } 81 82 // Mobile fallback 83 sizes.push(`calc(${100 / cols.sm}vw - ${MARGIN_TOTAL}px)`); 84 85 return sizes.join(', '); 86} 87 88/** 89 * Frontify class to handle image rendering with responsive breakpoints. 90 * It constructs the image source URL based on the provided options and Frontify JSON data. 91 * It supports responsive images with srcset and picture elements. 92 * 93 * Options: 94 * - simpleImg: boolean - If true, renders a simple <img> tag instead of <picture> element 95 * - fixedWidth: number - Fixed width for simple image (defaults to defaultResize if not specified) 96 * - imageAlt: string - Alt text for the image 97 * - classes: string - CSS classes to add to the image 98 * - loading: string - Loading strategy ('lazy' or 'eager') 99 * - breakpoints: array - Custom breakpoints for responsive images 100 * - srcSizes: array - Available image sizes for srcset 101 * - defaultResize: number - Default resize value 102 * - cover: object - Cover parameters per breakpoint (e.g., { sm: '5:4', md: '5:4', lg: '5:4', max: '13:8' }) 103 * - focus: string - Focus point for image cropping 104 * - columns: object - Number of columns per breakpoint (e.g., { sm: 1, md: 2, lg: 2, max: 2 }) 105 * - sizes: string - Explicit sizes attribute (overrides columns-based calculation) 106 */ 107export class Frontify { 108 constructor(data) { 109 this.data = data || {}; 110 this.frontifyJson = this.data.frontifyJson || null; 111 this.isPDF = Boolean(this.frontifyJson?.extension?.toLowerCase() === 'pdf'); 112 this.previewUrl = this.frontifyJson?.previewUrl || ''; 113 this.options = this.data.options || {}; 114 115 this.breakpoints = this.options.breakpoints || defaultMediaQueryBreakpoints; 116 this.srcSizes = this.options.srcSizes || defaultSrcSizes; 117 this.defaultResize = this.options.defaultResize || defaultResize; 118 119 this.imageAlt = this.options.imageAlt || this.frontifyJson?.alternativeText || ''; 120 this.classes = this.options.classes || ''; 121 this.loading = this.options.loading || 'lazy'; 122 this.simpleImg = this.options.simpleImg || false; 123 this.fixedWidth = this.options.fixedWidth || null; 124 this.fixedHeight = this.options.fixedHeight || null; 125 126 // Compute sizes: use explicit sizes if provided, otherwise generate from columns map 127 this.computedSizes = 128 this.options.sizes || (this.options.columns ? generateSizesFromColumnsMap(this.options.columns) : '100vw'); 129 } 130 131 addMediaProcessingAPIParams({ size = this.defaultResize, breakpoint = 'lg' }) { 132 const { cover, focus } = this.options; 133 const focalPoint = Array.isArray(this.data.frontifyJson.focalPoint) 134 ? `${Math.ceil(parseFloat(this.data.frontifyJson.focalPoint[0]) * 100)}px${Math.ceil(parseFloat(this.data.frontifyJson.focalPoint[1]) * 100)}p` 135 : ''; 136 const mod = 'mod=v1'; 137 const focusParam = `focus=${focus || focalPoint || 'auto'}`; 138 const coverParam = cover?.[breakpoint] ? `cover=${cover[breakpoint]}` : ''; 139 const outputParam = 'output=webp'; 140 const resizeParam = `resize=${size}`; 141 const qualityParam = 'quality=75'; 142 143 return `${[mod, outputParam, focusParam, coverParam, resizeParam, qualityParam].filter(
143Boolean).join('/')}`; 144 } 145 146 /** 147 * Renders the default image element with the specified resize value. 148 * @returns {string} The HTML string for the default image element. 149 */ 150 renderDefaultImage() { 151 const fetchPriority = this.loading === 'eager' ? ' fetchpriority="high"' : ''; 152 return `<img src="${this.previewUrl}?${this.addMediaProcessingAPIParams({})}" 153 alt="${this.imageAlt}" 154 class="frontify-img ${this.classes}" 155 loading="${this.loading}"${fetchPriority} 156 sizes="${this.computedSizes}" />`; 157 } 158 159 /** 160 * Renders the srcset attribute for the image based on the provided condition. 161 * @param {function} condition - A function that takes a size and returns true if the size should be included in the srcset. 162 * @param {number} breakpoint - The breakpoint value for mapping to the appropriate breakpoint name. 163 * @return {string} The srcset string for the image. 164 */ 165 renderSrcSet(condition, breakpoint) { 166 let srcSet = ''; 167 this.srcSizes.map((size) => { 168 if (condition(size)) { 169 srcSet += `${this.previewUrl}?${this.addMediaProcessingAPIParams({ size, breakpoint: defaultMediaQueryBreakpointsMapping[breakpoint] })} ${size}w, `; 170 } 171 }); 172 return srcSet.slice(0, -2); // Remove trailing comma and space 173 } 174 175 /** 176 * Renders the source elements for the picture element based on the breakpoints. 177 * @returns {string} The HTML string for the source elements. 178 */ 179 renderSources() { 180 return this.breakpoints 181 ?.map((breakpoint, index) => { 182 const sizesAttr = this.computedSizes; 183 // Include all available image sizes - let the browser pick the optimal one based on sizes attribute 184 const srcset = this.renderSrcSet(() => true, breakpoint); 185 186 if (index === 0) { 187 return `<source media="(max-width: ${breakpoint}px)" srcset="${srcset}" sizes="${sizesAttr}" />`; 188 } else if (index === this.breakpoints.length - 1) { 189 return `<source media="(min-width: ${breakpoint}px)" srcset="${srcset}" sizes="${sizesAttr}" />`; 190 } 191 const prevBreakpoint = this.breakpoints[index - 1]; 192 return `<source media="(min-width: ${prevBreakpoint + 1}px) and (max-width: ${breakpoint}px)" srcset="${srcset}" sizes="${sizesAttr}" />`; 193 }) 194 .join('\n'); 195 } 196 197 /** 198 * Renders a simple image element with a fixed width. 199 * @returns {string} The HTML string for a simple image element. 200 */ 201 renderSimpleImage() { 202 const width = this.fixedWidth || this.defaultResize; 203 const height = this.fixedHeight; 204 const fetchPriority = this.loading === 'eager' ? ' fetchpriority="high"' : ''; 205 return `<img src="${this.previewUrl}?${this.addMediaProcessingAPIParams({ size: width })}" 206 alt="${this.imageAlt}" 207 class="frontify-img ${this.classes}" 208 loading="${this.loading}"${fetchPriority} 209 ${width && height ? `width="${width}" height="${height}"` : ''}/>`; 210 } 211 212 /** 213 * Renders the pdf link. 214 * @returns {string} The HTML string for pdf link. 215 */ 216 renderPDFLink() { 217 return `<div class="pdf-download"> 218 <a href="${this.frontifyJson.previewUrl}" download target="_blank" rel="noopener noreferrer"> 219 <span>${this.frontifyJson.filename}</span> 220 </a> 221 </div>`; 222 } 223 224 render() { 225 if (!this.frontifyJson || !this.previewUrl) { 226 return null; 227 } 228 229 if (this.isPDF) { 230 return this.renderPDFLink(); 231 } 232 233 if (this.simpleImg) { 234 return this.renderSimpleImage(); 235 } 236 237 return `<picture> 238 ${this.renderSources()} 239 ${this.renderDefaultImage()} 240 </picture>`; 241 } 242}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.