@stacksjs/image 0.70.331 → 0.70.333

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.
Files changed (2) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import{createHash,createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{mkdir,readFile,rename,stat,writeFile}from"node:fs/promises";import{basename,extname,isAbsolute,relative,resolve}from"node:path";import{decode,encode,imageToSplatHash,resize}from"ts-images";export*from"./app-icons";export*from"./app-store";export*from"./fonts";export*from"./generate";export*from"./social";export*from"./theme";const mime={avif:"image/avif",webp:"image/webp",jpeg:"image/jpeg",png:"image/png"};function integer(name,value,min,max){if(!Number.isInteger(value)||value<min||value>max)throw TypeError(`${name} must be between ${min} and ${max}`)}export function resolveImageSource(source,root=process.cwd()){const allowed=resolve(root),candidate=isAbsolute(source)?resolve(source):resolve(allowed,source),relation=relative(allowed,candidate);if(source.includes("\x00")||relation===".."||relation.startsWith("../")||isAbsolute(relation))throw Error("Image source must stay inside the configured root");return candidate}export class ImageBuilder{source;targetWidths=[480,768,1280,1920];targetFormats=["avif","webp","jpeg"];targetFit="inside";targetHeight;targetAspectRatio;targetPosition="center";includeOriginal=!0;targetStorage;targetQuality=82;options;constructor(source,options={}){this.source=source;this.options={...options,root:options.root??process.cwd(),outputDir:options.outputDir??resolve("public/media/images"),publicPath:options.publicPath??"/media/images",concurrency:options.concurrency??4,upscale:options.upscale??!1}}widths(widths){if(!widths.length)throw TypeError("Image widths are required");widths.forEach((value)=>integer("Image width",value,1,16384));this.targetWidths=[...new Set(widths)].sort((a,b)=>a-b);return this}formats(formats){if(!formats.length)throw TypeError("Image formats are required");this.targetFormats=[...new Set(formats)];return this}fit(fit){this.targetFit=fit;return this}height(value){integer("Image height",value,1,16384);this.targetHeight=value;this.targetAspectRatio=void 0;return this}aspectRatio(value){if(!Number.isFinite(value)||value<=0||value>100)throw TypeError("Image aspect ratio must be between 0 and 100");this.targetAspectRatio=value;this.targetHeight=void 0;return this}position(value){this.targetPosition=value;return this}preset(value){const preset={avatar:{widths:[64,128,256,512],ratio:1,fit:"cover",original:!1},content:{widths:[320,640,960,1280,1920],fit:"inside",original:!0},hero:{widths:[640,1280,1920,2560],ratio:1.7777777777777777,fit:"cover",original:!0},thumbnail:{widths:[160,320,640],ratio:1.7777777777777777,fit:"cover",original:!1}}[value];this.targetWidths=[...preset.widths];this.targetFit=preset.fit;this.targetAspectRatio="ratio"in preset?preset.ratio:void 0;this.targetHeight=void 0;this.includeOriginal=preset.original;return this}quality(value){integer("Image quality",value,1,100);this.targetQuality=value;return this}output(dir,publicPath="/media/images"){this.options.outputDir=resolve(dir);this.options.publicPath=`/${publicPath.replace(/^\/+|\/+$/g,"")}`;return this}storage(adapter,prefix="media/images"){this.targetStorage={adapter,prefix:prefix.replace(/^\/+|\/+$/g,"")};return this}async generate(){integer("Image concurrency",this.options.concurrency,1,32);const sourcePath=resolveImageSource(this.source,this.options.root);if(this.options.authorize&&!await this.options.authorize(sourcePath,this.options.authorizationContext))throw Error("Image delivery is not authorized");const bytes=new Uint8Array(await readFile(sourcePath)),hash=createHash("sha256").update(bytes).digest("hex"),decoded=await decode(bytes),widths=this.targetWidths.filter((width)=>this.options.upscale||width<=decoded.width);if(this.includeOriginal&&!widths.includes(decoded.width))widths.push(decoded.width);const tasks=[...new Set(widths)].sort((a,b)=>a-b).flatMap((width)=>this.targetFormats.map((format)=>({width,format}))),variants=[];let cursor=0;await Promise.all(Array.from({length:Math.min(tasks.length,this.options.concurrency)},async()=>{while(cursor<tasks.length){const task=tasks[cursor++];variants.push(await this.variant(decoded,hash,sourcePath,task.width,task.format))}}));variants.sort((a,b)=>a.width-b.width||this.targetFormats.indexOf(a.format)-this.targetFormats.indexOf(b.format));return{source:{width:decoded.width,height:decoded.height,hash},variants,placeholder:Buffer.from(imageToSplatHash(decoded)).toString("base64url")}}async variant(source,hash,sourcePath,width,format){const height=this.targetHeight??(this.targetAspectRatio?Math.max(1,Math.round(width/this.targetAspectRatio)):void 0),output=width===source.width&&height===void 0?source:resize(source,{width,height,fit:this.targetFit,position:this.targetPosition});if(!this.options.upscale&&(output.width>source.width||output.height>source.height))throw TypeError(`Image variant ${output.width}x${output.height} would upscale the source`);const cacheKey=createHash("sha256").update(`${hash}:${width}:${height??"auto"}:${format}:${this.targetFit}:${this.targetPosition}:${this.targetQuality}`).digest("hex"),filename=`${basename(sourcePath,extname(sourcePath)).replace(/[^a-zA-Z0-9_-]/g,"-")||"image"}-${output.width}x${output.height}-${cacheKey.slice(0,16)}.${format==="jpeg"?"jpg":format}`,path=resolve(this.options.outputDir,filename);if(this.targetStorage){const key=this.targetStorage.prefix?`${this.targetStorage.prefix}/${filename}`:filename;if(await this.targetStorage.adapter.fileExists(key)){const existing=await this.targetStorage.adapter.stat(key);return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0}),written=await this.targetStorage.adapter.write(key,encoded);return{width:output.width,height:output.height,bytes:written.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const existing=await stat(path).catch(()=>null);if(existing)return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey};const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0});await mkdir(this.options.outputDir,{recursive:!0});const temporary=`${path}.${process.pid}.${randomUUID()}.tmp`;await writeFile(temporary,encoded);await rename(temporary,path);return{width:output.width,height:output.height,bytes:encoded.byteLength,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey}}}export function image(source,options={}){return new ImageBuilder(source,options)}function accepted(accept,mimeType){const[type,subtype]=mimeType.split("/");let best=0;for(const item of accept.split(",")){const[range="*/*",...params]=item.trim().toLowerCase().split(";").map((value)=>value.trim()),[acceptedType,acceptedSubtype]=range.split("/");if(acceptedType!=="*"&&acceptedType!==type||acceptedSubtype!=="*"&&acceptedSubtype!==subtype)continue;const raw=params.find((param)=>param.startsWith("q="));best=Math.max(best,raw?Number.parseFloat(raw.slice(2))||0:1)}return best}export function negotiateImageVariant(variants,accept="*/*",width){const widths=[...new Set(variants.map((item)=>item.width))].sort((a,b)=>a-b),target=width===void 0?widths.at(-1):widths.find((value)=>value>=width)??widths.at(-1);return variants.filter((item)=>item.width===target).map((variant,index)=>({variant,index,q:accepted(accept||"*/*",variant.mimeType)})).filter((item)=>item.q>0).sort((a,b)=>b.q-a.q||a.index-b.index)[0]?.variant}export function imageResponseHeaders(variant){return{"Content-Type":variant.mimeType,"Content-Length":String(variant.bytes),"Cache-Control":"public, max-age=31536000, immutable",ETag:`"${variant.cacheKey}"`,Vary:"Accept","X-Image-Width":String(variant.width),"X-Image-Height":String(variant.height)}}export function signImageTransform(path,expires,secret){return createHmac("sha256",secret).update(`${path}
1
+ import{createHash,createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{mkdir,readFile,rename,stat,writeFile}from"node:fs/promises";import{basename,extname,isAbsolute,relative,resolve}from"node:path";import{decode,encode,imageToSplatHash,resize}from"ts-images";export*from"./app-icons";export*from"./app-store";export*from"./fonts";export*from"./generate";export*from"./social";export*from"./theme";const mime={avif:"image/avif",webp:"image/webp",jpeg:"image/jpeg",png:"image/png"};function integer(name,value,min,max){if(!Number.isInteger(value)||value<min||value>max)throw TypeError(`${name} must be between ${min} and ${max}`)}export function resolveImageSource(source,root=process.cwd()){const allowed=resolve(root),candidate=isAbsolute(source)?resolve(source):resolve(allowed,source),relation=relative(allowed,candidate);if(source.includes("\x00")||relation===".."||relation.startsWith("../")||isAbsolute(relation))throw Error("Image source must stay inside the configured root");return candidate}export class ImageBuilder{source;targetWidths=[480,768,1280,1920];targetFormats=["avif","webp","jpeg"];targetFit="inside";targetHeight;targetAspectRatio;targetPosition="center";includeOriginal=!0;targetStorage;targetQuality=82;options;constructor(source,options={}){this.source=source;const root=options.root??process.cwd();this.options={...options,root,outputDir:options.outputDir??resolve(root,"public/media/images"),publicPath:options.publicPath??"/media/images",concurrency:options.concurrency??4,upscale:options.upscale??!1}}widths(widths){if(!widths.length)throw TypeError("Image widths are required");widths.forEach((value)=>integer("Image width",value,1,16384));this.targetWidths=[...new Set(widths)].sort((a,b)=>a-b);return this}formats(formats){if(!formats.length)throw TypeError("Image formats are required");this.targetFormats=[...new Set(formats)];return this}fit(fit){this.targetFit=fit;return this}height(value){integer("Image height",value,1,16384);this.targetHeight=value;this.targetAspectRatio=void 0;return this}aspectRatio(value){if(!Number.isFinite(value)||value<=0||value>100)throw TypeError("Image aspect ratio must be between 0 and 100");this.targetAspectRatio=value;this.targetHeight=void 0;return this}position(value){this.targetPosition=value;return this}preset(value){const preset={avatar:{widths:[64,128,256,512],ratio:1,fit:"cover",original:!1},content:{widths:[320,640,960,1280,1920],fit:"inside",original:!0},hero:{widths:[640,1280,1920,2560],ratio:1.7777777777777777,fit:"cover",original:!0},thumbnail:{widths:[160,320,640],ratio:1.7777777777777777,fit:"cover",original:!1}}[value];this.targetWidths=[...preset.widths];this.targetFit=preset.fit;this.targetAspectRatio="ratio"in preset?preset.ratio:void 0;this.targetHeight=void 0;this.includeOriginal=preset.original;return this}quality(value){integer("Image quality",value,1,100);this.targetQuality=value;return this}output(dir,publicPath="/media/images"){this.options.outputDir=resolve(dir);this.options.publicPath=`/${publicPath.replace(/^\/+|\/+$/g,"")}`;return this}storage(adapter,prefix="media/images"){this.targetStorage={adapter,prefix:prefix.replace(/^\/+|\/+$/g,"")};return this}async generate(){integer("Image concurrency",this.options.concurrency,1,32);const sourcePath=resolveImageSource(this.source,this.options.root);if(this.options.authorize&&!await this.options.authorize(sourcePath,this.options.authorizationContext))throw Error("Image delivery is not authorized");const bytes=new Uint8Array(await readFile(sourcePath)),hash=createHash("sha256").update(bytes).digest("hex"),decoded=await decode(bytes),widths=this.targetWidths.filter((width)=>this.options.upscale||width<=decoded.width);if(this.includeOriginal&&!widths.includes(decoded.width))widths.push(decoded.width);const tasks=[...new Set(widths)].sort((a,b)=>a-b).flatMap((width)=>this.targetFormats.map((format)=>({width,format}))),variants=[];let cursor=0;await Promise.all(Array.from({length:Math.min(tasks.length,this.options.concurrency)},async()=>{while(cursor<tasks.length){const task=tasks[cursor++];variants.push(await this.variant(decoded,hash,sourcePath,task.width,task.format))}}));variants.sort((a,b)=>a.width-b.width||this.targetFormats.indexOf(a.format)-this.targetFormats.indexOf(b.format));return{source:{width:decoded.width,height:decoded.height,hash},variants,placeholder:Buffer.from(imageToSplatHash(decoded)).toString("base64url")}}async variant(source,hash,sourcePath,width,format){const height=this.targetHeight??(this.targetAspectRatio?Math.max(1,Math.round(width/this.targetAspectRatio)):void 0),output=width===source.width&&height===void 0?source:resize(source,{width,height,fit:this.targetFit,position:this.targetPosition});if(!this.options.upscale&&(output.width>source.width||output.height>source.height))throw TypeError(`Image variant ${output.width}x${output.height} would upscale the source`);const cacheKey=createHash("sha256").update(`${hash}:${width}:${height??"auto"}:${format}:${this.targetFit}:${this.targetPosition}:${this.targetQuality}`).digest("hex"),filename=`${basename(sourcePath,extname(sourcePath)).replace(/[^a-zA-Z0-9_-]/g,"-")||"image"}-${output.width}x${output.height}-${cacheKey.slice(0,16)}.${format==="jpeg"?"jpg":format}`,path=resolve(this.options.outputDir,filename);if(this.targetStorage){const key=this.targetStorage.prefix?`${this.targetStorage.prefix}/${filename}`:filename;if(await this.targetStorage.adapter.fileExists(key)){const existing=await this.targetStorage.adapter.stat(key);return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0}),written=await this.targetStorage.adapter.write(key,encoded);return{width:output.width,height:output.height,bytes:written.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const existing=await stat(path).catch(()=>null);if(existing)return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey};const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0});await mkdir(this.options.outputDir,{recursive:!0});const temporary=`${path}.${process.pid}.${randomUUID()}.tmp`;await writeFile(temporary,encoded);await rename(temporary,path);return{width:output.width,height:output.height,bytes:encoded.byteLength,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey}}}export function image(source,options={}){return new ImageBuilder(source,options)}function accepted(accept,mimeType){const[type,subtype]=mimeType.split("/");let best=0;for(const item of accept.split(",")){const[range="*/*",...params]=item.trim().toLowerCase().split(";").map((value)=>value.trim()),[acceptedType,acceptedSubtype]=range.split("/");if(acceptedType!=="*"&&acceptedType!==type||acceptedSubtype!=="*"&&acceptedSubtype!==subtype)continue;const raw=params.find((param)=>param.startsWith("q="));best=Math.max(best,raw?Number.parseFloat(raw.slice(2))||0:1)}return best}export function negotiateImageVariant(variants,accept="*/*",width){const widths=[...new Set(variants.map((item)=>item.width))].sort((a,b)=>a-b),target=width===void 0?widths.at(-1):widths.find((value)=>value>=width)??widths.at(-1);return variants.filter((item)=>item.width===target).map((variant,index)=>({variant,index,q:accepted(accept||"*/*",variant.mimeType)})).filter((item)=>item.q>0).sort((a,b)=>b.q-a.q||a.index-b.index)[0]?.variant}export function imageResponseHeaders(variant){return{"Content-Type":variant.mimeType,"Content-Length":String(variant.bytes),"Cache-Control":"public, max-age=31536000, immutable",ETag:`"${variant.cacheKey}"`,Vary:"Accept","X-Image-Width":String(variant.width),"X-Image-Height":String(variant.height)}}export function signImageTransform(path,expires,secret){return createHmac("sha256",secret).update(`${path}
2
2
  ${expires}`).digest("base64url")}export function verifyImageTransform(path,expires,signature,secret,now=Date.now()){if(!Number.isInteger(expires)||expires*1000<=now)return!1;const expected=Buffer.from(signImageTransform(path,expires,secret)),actual=Buffer.from(signature);return expected.length===actual.length&&timingSafeEqual(expected,actual)}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/image",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.331",
5
+ "version": "0.70.333",
6
6
  "description": "Native responsive image delivery for Stacks.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "prepublishOnly": "bun run build"
32
32
  },
33
33
  "dependencies": {
34
- "@stacksjs/types": "0.70.331",
34
+ "@stacksjs/types": "0.70.333",
35
35
  "ts-images": "^0.2.8"
36
36
  },
37
37
  "devDependencies": {