UNPKG

@pixel-puppy/javascript

Version:

Official JavaScript/TypeScript library for Pixel Puppy - Transform and optimize images with WebP conversion and smart resizing

347 lines (342 loc) 10.9 kB
import invariant from "tiny-invariant"; //#region src/config.ts let globalConfig = {}; /** * Configure global defaults for the Pixel Puppy library. * Call this once at application startup. * * In browser environments, baseUrl is auto-detected from window.location.origin * so you typically don't need to call this function. * * In SSR/Node environments, you must configure a baseUrl to use relative URLs. * * @example * // In SSR/Node.js app initialization * configure({ baseUrl: 'https://example.com' }) * * // Now relative URLs work everywhere * buildImageUrl('my-project', '/images/hero.webp') * * @example * // In Next.js * configure({ baseUrl: process.env.NEXT_PUBLIC_BASE_URL }) */ function configure(config) { globalConfig = { ...config }; } /** * Get the current configuration */ function getConfig() { return Object.freeze({ ...globalConfig }); } /** * Reset configuration to defaults (useful for testing) */ function resetConfig() { globalConfig = {}; } //#endregion //#region src/url-utils.ts /** * Checks if a URL is relative (needs a base URL to resolve) * * @param url - The URL to check * @returns true if the URL is relative, false if absolute * * @example * isRelativeUrl('/images/hero.webp') // true * isRelativeUrl('images/hero.webp') // true * isRelativeUrl('https://example.com/image.jpg') // false * isRelativeUrl('//cdn.example.com/image.jpg') // false (protocol-relative) */ function isRelativeUrl(url) { if (url.startsWith("//")) return false; if (url.startsWith("http://") || url.startsWith("https://")) return false; if (url.startsWith("data:")) return false; return true; } /** * Gets the base URL from browser environment if available */ function getBrowserBaseUrl() { if (typeof globalThis !== "undefined") { const win = globalThis; if (win.location?.origin) return win.location.origin; } } /** * Resolves a potentially relative URL to an absolute URL. * Returns the original URL unchanged if it's already absolute. * * Resolution priority: * 1. If URL is absolute, return as-is * 2. Use provided baseUrl parameter * 3. Use globally configured baseUrl * 4. Auto-detect from browser (window.location.origin) * 5. Throw error if no baseUrl available * * @param url - The URL to resolve * @param baseUrl - Optional base URL to use (overrides global config) * @returns The resolved absolute URL * @throws {Error} if the URL is relative and no baseUrl is available * * @example * // With explicit baseUrl * resolveUrl('/images/hero.webp', 'https://example.com') * // Returns: 'https://example.com/images/hero.webp' * * @example * // With global config * configure({ baseUrl: 'https://example.com' }) * resolveUrl('/images/hero.webp') * // Returns: 'https://example.com/images/hero.webp' * * @example * // Absolute URLs pass through unchanged * resolveUrl('https://other.com/image.jpg', 'https://example.com') * // Returns: 'https://other.com/image.jpg' */ function resolveUrl(url, baseUrl) { if (!isRelativeUrl(url)) return url; const config = getConfig(); const effectiveBaseUrl = baseUrl ?? config.baseUrl ?? getBrowserBaseUrl(); if (!effectiveBaseUrl) throw new Error(`Cannot resolve relative URL "${url}". Please configure a baseUrl using configure({ baseUrl: '...' }) or pass baseUrl in options. In browser environments, this is auto-detected from window.location.origin.`); return `${effectiveBaseUrl.replace(/\/$/, "")}${url.startsWith("/") ? url : `/${url}`}`; } //#endregion //#region src/urls.ts /** * Builds a URL for the Pixel Puppy image transformation API. * * This function constructs a properly formatted URL that can be used to transform * images through the Pixel Puppy service. The service supports format conversion * (WebP, PNG) and resizing operations. * * @param projectSlug - The project identifier for your Pixel Puppy account * @param originalImageUrl - The URL of the original image to transform * @param options - Optional transformation settings * @param options.baseUrl - Base URL for resolving relative image URLs * @param options.format - The desired output format ('webp' or 'png'). Defaults to 'webp' * @param options.width - The desired width in pixels. Maintains aspect ratio when resizing * * @returns The complete Pixel Puppy transformation URL * * @throws {Error} When projectSlug is not provided * @throws {Error} When originalImageUrl is not provided * @throws {Error} When originalImageUrl is relative and no baseUrl is configured * @throws {Error} When format is not 'webp' or 'png' * @throws {Error} When width is not a valid number * @throws {Error} When width is not a positive number * * @example * Basic usage with absolute URL: * ```ts * const url = buildImageUrl('my-project', 'https://example.com/photo.jpg') * // Returns: https://pixelpuppy.io/api/image?project=my-project&url=https://example.com/photo.jpg&format=webp * ``` * * @example * Relative URL with baseUrl option: * ```ts * const url = buildImageUrl('my-project', '/images/hero.webp', { baseUrl: 'https://example.com' }) * // Resolves to: https://pixelpuppy.io/api/image?project=my-project&url=https://example.com/images/hero.webp&format=webp * ``` * * @example * Relative URL with global config (browser auto-detects from window.location.origin): * ```ts * // In browser: works automatically * const url = buildImageUrl('my-project', '/images/hero.webp') * * // In SSR/Node: configure once at startup * configure({ baseUrl: 'https://example.com' }) * const url = buildImageUrl('my-project', '/images/hero.webp') * ``` * * @example * Resize to specific width: * ```ts * const url = buildImageUrl('my-project', 'https://example.com/photo.jpg', { width: 800 }) * ``` * * @example * Convert to PNG format: * ```ts * const url = buildImageUrl('my-project', 'https://example.com/photo.jpg', { format: 'png' }) * ``` */ function buildImageUrl(projectSlug, originalImageUrl, options = {}) { invariant(projectSlug, "projectSlug is required."); invariant(originalImageUrl, "originalImageUrl is required."); const resolvedUrl = resolveUrl(originalImageUrl, options.baseUrl); const baseUrl = `https://pixelpuppy.io/api/image`; const params = new URLSearchParams(); const format = options.format || "webp"; const width = options.width; if (format !== "webp" && format !== "png") throw new Error("Invalid format. Supported formats are webp and png."); if (Number.isNaN(width)) throw new Error("Width must be a number."); if (width && width <= 0) throw new Error("Width must be a positive number."); params.append("project", projectSlug); params.append("url", resolvedUrl); params.append("format", format.toLowerCase()); if (width) params.append("width", width.toString()); return `${baseUrl}?${params.toString()}`; } //#endregion //#region src/responsive.ts /** * Default device breakpoints covering mobile phones to 4K displays */ const defaultDeviceBreakpoints = [ 480, 640, 750, 828, 1080, 1200, 1920, 2048, 3840 ]; /** * Default image breakpoints for small images and icons */ const defaultImageBreakpoints = [ 16, 32, 48, 64, 96, 128, 256, 384 ]; /** * Parses the smallest vw value from a sizes attribute string * Returns the percentage as a decimal (e.g., "50vw" returns 0.5) */ function parseSmallestVw(sizes) { const vwMatches = sizes.match(/(\d+)vw/g); if (!vwMatches || vwMatches.length === 0) return null; const vwValues = vwMatches.map((match) => { const num = match.match(/(\d+)/); return num?.[1] ? parseInt(num[1], 10) : 100; }); return Math.min(...vwValues) / 100; } /** * Generates responsive image attributes for use in img tags * * @param project - The Pixel Puppy project identifier * @param src - The original image URL * @param options - Responsive image options * @returns Object with src, srcSet, and optionally sizes and width attributes * * @example * // Non-responsive (single URL) * const attrs = getResponsiveImageAttributes('my-project', 'https://example.com/image.jpg', { * width: 800, * responsive: false * }) * // Returns: { src: '...' } * * @example * // Width-based strategy (width provided, no sizes) * const attrs = getResponsiveImageAttributes('my-project', 'https://example.com/image.jpg', { * width: 800 * }) * // Returns: { src: '...', srcSet: '... 480w, 640w, 750w, 800w, ..., 1600w, ...', sizes: '(min-width: 1024px) 1024px, 100vw', width: 800 } * * @example * // Sizes-based strategy (with sizes) * const attrs = getResponsiveImageAttributes('my-project', 'https://example.com/image.jpg', { * width: 800, * sizes: '(min-width: 768px) 50vw, 100vw' * }) * // Returns: { src: '...', srcSet: '... 640w, ... 750w, ...', sizes: '(min-width: 768px) 50vw, 100vw' } * * @example * // Default strategy (no width or sizes) * const attrs = getResponsiveImageAttributes('my-project', 'https://example.com/image.jpg') * // Returns: { src: '...', srcSet: '... 480w, 640w, 750w, ...', sizes: '100vw' } */ function getResponsiveImageAttributes(project, src, options = {}) { const { baseUrl, width, sizes, format, responsive = true, deviceBreakpoints = defaultDeviceBreakpoints, imageBreakpoints = defaultImageBreakpoints } = options; if (responsive === false) return { src: buildImageUrl(project, src, { baseUrl, width, format }), srcSet: "" }; const allBreakpoints = [ ...deviceBreakpoints, ...imageBreakpoints, ...width ? [width, width * 2] : [] ]; const uniqueBreakpoints = Array.from(new Set(allBreakpoints)).sort((a, b) => a - b); if (sizes) { const smallestVw = parseSmallestVw(sizes); let filteredBreakpoints = uniqueBreakpoints; if (smallestVw !== null) { const minImageWidth = 480 * smallestVw; filteredBreakpoints = uniqueBreakpoints.filter((bp) => bp >= minImageWidth); } const srcSetEntries$1 = filteredBreakpoints.map((w) => { return `${buildImageUrl(project, src, { baseUrl, width: w, format })} ${w}w`; }); return { src: buildImageUrl(project, src, { baseUrl, width: filteredBreakpoints[0] || uniqueBreakpoints[0], format }), srcSet: srcSetEntries$1.join(", "), sizes }; } if (width) { const srcSetEntries$1 = uniqueBreakpoints.map((w) => { return `${buildImageUrl(project, src, { baseUrl, width: w, format })} ${w}w`; }); return { src: buildImageUrl(project, src, { baseUrl, width, format }), srcSet: srcSetEntries$1.join(", "), sizes: "(min-width: 1024px) 1024px, 100vw", width }; } const sortedBreakpoints = [...deviceBreakpoints].sort((a, b) => a - b); const srcSetEntries = sortedBreakpoints.map((w) => { return `${buildImageUrl(project, src, { baseUrl, width: w, format })} ${w}w`; }); return { src: buildImageUrl(project, src, { baseUrl, width: sortedBreakpoints[0], format }), srcSet: srcSetEntries.join(", "), sizes: "100vw" }; } //#endregion export { buildImageUrl, configure, getConfig, getResponsiveImageAttributes, isRelativeUrl, resetConfig, resolveUrl };