@pixel-puppy/javascript
Version:
Official JavaScript/TypeScript library for Pixel Puppy - Transform and optimize images with WebP conversion and smart resizing
381 lines (375 loc) • 12.1 kB
JavaScript
//#region rolldown:runtime
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
key = keys[i];
if (!__hasOwnProp.call(to, key) && key !== except) {
__defProp(to, key, {
get: ((k) => from[k]).bind(null, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
}
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
value: mod,
enumerable: true
}) : target, mod));
//#endregion
let tiny_invariant = require("tiny-invariant");
tiny_invariant = __toESM(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 = {}) {
(0, tiny_invariant.default)(projectSlug, "projectSlug is required.");
(0, tiny_invariant.default)(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
exports.buildImageUrl = buildImageUrl;
exports.configure = configure;
exports.getConfig = getConfig;
exports.getResponsiveImageAttributes = getResponsiveImageAttributes;
exports.isRelativeUrl = isRelativeUrl;
exports.resetConfig = resetConfig;
exports.resolveUrl = resolveUrl;