@pro-laico/payload-images 0.0.0

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 (107) hide show
  1. package/LICENSE.md +22 -0
  2. package/README.md +11 -0
  3. package/dist/access.d.ts +4 -0
  4. package/dist/access.d.ts.map +1 -0
  5. package/dist/access.js +4 -0
  6. package/dist/access.js.map +1 -0
  7. package/dist/collections/generatedImages.d.ts +26 -0
  8. package/dist/collections/generatedImages.d.ts.map +1 -0
  9. package/dist/collections/generatedImages.js +91 -0
  10. package/dist/collections/generatedImages.js.map +1 -0
  11. package/dist/collections/images.d.ts +60 -0
  12. package/dist/collections/images.d.ts.map +1 -0
  13. package/dist/collections/images.js +223 -0
  14. package/dist/collections/images.js.map +1 -0
  15. package/dist/components/admin/focalPreview.d.ts +8 -0
  16. package/dist/components/admin/focalPreview.d.ts.map +1 -0
  17. package/dist/components/admin/focalPreview.js +234 -0
  18. package/dist/components/admin/focalPreview.js.map +1 -0
  19. package/dist/components/admin/purgeVariants.d.ts +16 -0
  20. package/dist/components/admin/purgeVariants.d.ts.map +1 -0
  21. package/dist/components/admin/purgeVariants.js +66 -0
  22. package/dist/components/admin/purgeVariants.js.map +1 -0
  23. package/dist/components/image.d.ts +90 -0
  24. package/dist/components/image.d.ts.map +1 -0
  25. package/dist/components/image.js +162 -0
  26. package/dist/components/image.js.map +1 -0
  27. package/dist/components/inlineLqip.d.ts +36 -0
  28. package/dist/components/inlineLqip.d.ts.map +1 -0
  29. package/dist/components/inlineLqip.js +54 -0
  30. package/dist/components/inlineLqip.js.map +1 -0
  31. package/dist/endpoints/transform.d.ts +40 -0
  32. package/dist/endpoints/transform.d.ts.map +1 -0
  33. package/dist/endpoints/transform.js +181 -0
  34. package/dist/endpoints/transform.js.map +1 -0
  35. package/dist/fields/virtualUrls.d.ts +26 -0
  36. package/dist/fields/virtualUrls.d.ts.map +1 -0
  37. package/dist/fields/virtualUrls.js +185 -0
  38. package/dist/fields/virtualUrls.js.map +1 -0
  39. package/dist/hooks/purge.d.ts +33 -0
  40. package/dist/hooks/purge.d.ts.map +1 -0
  41. package/dist/hooks/purge.js +63 -0
  42. package/dist/hooks/purge.js.map +1 -0
  43. package/dist/index.d.ts +4 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +9 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/lib/configStash.d.ts +6 -0
  48. package/dist/lib/configStash.d.ts.map +1 -0
  49. package/dist/lib/configStash.js +10 -0
  50. package/dist/lib/configStash.js.map +1 -0
  51. package/dist/lib/getServerSideURL.d.ts +8 -0
  52. package/dist/lib/getServerSideURL.d.ts.map +1 -0
  53. package/dist/lib/getServerSideURL.js +12 -0
  54. package/dist/lib/getServerSideURL.js.map +1 -0
  55. package/dist/lib/mergeCollection.d.ts +17 -0
  56. package/dist/lib/mergeCollection.d.ts.map +1 -0
  57. package/dist/lib/mergeCollection.js +55 -0
  58. package/dist/lib/mergeCollection.js.map +1 -0
  59. package/dist/plugin.d.ts +128 -0
  60. package/dist/plugin.d.ts.map +1 -0
  61. package/dist/plugin.js +184 -0
  62. package/dist/plugin.js.map +1 -0
  63. package/dist/transform/coalesce.d.ts +16 -0
  64. package/dist/transform/coalesce.d.ts.map +1 -0
  65. package/dist/transform/coalesce.js +25 -0
  66. package/dist/transform/coalesce.js.map +1 -0
  67. package/dist/transform/geometry.d.ts +46 -0
  68. package/dist/transform/geometry.d.ts.map +1 -0
  69. package/dist/transform/geometry.js +64 -0
  70. package/dist/transform/geometry.js.map +1 -0
  71. package/dist/transform/getVariantBytes.d.ts +60 -0
  72. package/dist/transform/getVariantBytes.d.ts.map +1 -0
  73. package/dist/transform/getVariantBytes.js +163 -0
  74. package/dist/transform/getVariantBytes.js.map +1 -0
  75. package/dist/transform/limit.d.ts +5 -0
  76. package/dist/transform/limit.d.ts.map +1 -0
  77. package/dist/transform/limit.js +41 -0
  78. package/dist/transform/limit.js.map +1 -0
  79. package/dist/transform/lqip.d.ts +21 -0
  80. package/dist/transform/lqip.d.ts.map +1 -0
  81. package/dist/transform/lqip.js +32 -0
  82. package/dist/transform/lqip.js.map +1 -0
  83. package/dist/transform/params.d.ts +100 -0
  84. package/dist/transform/params.d.ts.map +1 -0
  85. package/dist/transform/params.js +144 -0
  86. package/dist/transform/params.js.map +1 -0
  87. package/dist/transform/sharp.d.ts +28 -0
  88. package/dist/transform/sharp.d.ts.map +1 -0
  89. package/dist/transform/sharp.js +85 -0
  90. package/dist/transform/sharp.js.map +1 -0
  91. package/dist/transform/sharpInstance.d.ts +21 -0
  92. package/dist/transform/sharpInstance.d.ts.map +1 -0
  93. package/dist/transform/sharpInstance.js +38 -0
  94. package/dist/transform/sharpInstance.js.map +1 -0
  95. package/dist/transform/source.d.ts +32 -0
  96. package/dist/transform/source.d.ts.map +1 -0
  97. package/dist/transform/source.js +121 -0
  98. package/dist/transform/source.js.map +1 -0
  99. package/dist/utils/urls.d.ts +108 -0
  100. package/dist/utils/urls.d.ts.map +1 -0
  101. package/dist/utils/urls.js +113 -0
  102. package/dist/utils/urls.js.map +1 -0
  103. package/dist/variants/key.d.ts +9 -0
  104. package/dist/variants/key.d.ts.map +1 -0
  105. package/dist/variants/key.js +29 -0
  106. package/dist/variants/key.js.map +1 -0
  107. package/package.json +98 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/image.tsx"],"sourcesContent":["/**\n * `<ResponsiveImage>` — a single plain `<img>` carrying a `srcset` of on-demand transform URLs,\n * with a faithful **inline LQIP** painted as its own `background-image`: a tiny base64 data-URI\n * (generated server-side at the requested aspect-ratio + focal point, zero network) shows\n * instantly, and the real image paints over it on load — the swap is native, no JS, no `<head>`\n * script. An async server component: it resolves the project config (pixel step + placeholder\n * settings), generates the LQIP via the shared variant cache, and renders one `<img>`. Not\n * `next/image`.\n *\n * `className` / `style` / `dataAttributes` all go on the `<img>` itself (size / space / round it\n * there). The LQIP is on by default; pass `blur={false}` (or the plugin's `placeholder: false`)\n * to skip it, and it's skipped automatically when there's no populated doc to generate from.\n */\nimport type { SanitizedConfig } from 'payload'\nimport type { CSSProperties, ImgHTMLAttributes, ReactElement } from 'react'\n\nimport { stashedConfig } from '../lib/configStash'\nimport type { VariantSourceDoc } from '../transform/getVariantBytes'\nimport { type Fit, type Format, parseAspectRatio } from '../transform/params'\nimport {\n type BuildSrcsetOptions,\n buildSrcset,\n type BuildUrlOptions,\n buildVariantUrl,\n deriveVersion,\n type GetImageUrlOptions,\n getImageUrl,\n type ImageResource,\n stepWidths,\n} from '../utils/urls'\n\n// Re-export the isomorphic URL builders so this single subpath covers the whole frontend API —\n// `import { ResponsiveImage, getImageUrl } from '@pro-laico/payload-images/components/image'`.\n// (The `utils/urls` subpath stays available for importing the builders without the component.)\nexport { buildSrcset, buildVariantUrl, deriveVersion, getImageUrl, stepWidths }\nexport type { BuildSrcsetOptions, BuildUrlOptions, Fit, Format, GetImageUrlOptions, ImageResource }\n\n/** A bare id, or a populated image doc (for natural dims, alt, the version token, and inline-LQIP generation). */\nexport type ResponsiveImageInput =\n | string\n | number\n | {\n id: string | number\n width?: number | null\n height?: number | null\n alt?: string | null\n filename?: string | null\n url?: string | null\n focalX?: number | null\n focalY?: number | null\n }\n\ntype Awaitable<T> = T | Promise<T>\n\n/** Placeholder override: `false` off · `true`/unset on (defaults) · a number = LQIP width (px) · `{ width?, quality? }` = tuned. */\nexport type PlaceholderProp = boolean | number | { width?: number; quality?: number }\n\n/** Resolve the `placeholder` prop (falling back to the deprecated `blur`) to on/off + optional width/quality. */\nconst resolvePlaceholderProp = (placeholder: PlaceholderProp | undefined, blur: boolean): { on: boolean; width?: number; quality?: number } => {\n if (placeholder === false) return { on: false }\n if (placeholder === true) return { on: true }\n if (typeof placeholder === 'number') return { on: true, width: placeholder }\n if (placeholder && typeof placeholder === 'object') return { on: true, width: placeholder.width, quality: placeholder.quality }\n return { on: blur } // `placeholder` unset → legacy `blur` (default true)\n}\n\nexport interface ResponsiveImageProps {\n image: ResponsiveImageInput\n alt?: string\n /** The `sizes` attribute. Default `100vw`. */\n sizes?: string\n /** Render aspect ratio (`16/9` | `\"16:9\"`); falls back to the doc's natural ratio. Ignored when `fill` is set. */\n aspectRatio?: number | string\n /**\n * Cover-fill a height-driven parent instead of acting as an aspect-ratio box. The `<img>`\n * becomes `position:absolute; inset:0; size:100%` with `object-fit:<fit>` and NO aspect-ratio\n * — so it fills a parent that sets its own height (full-bleed hero, carousel slide, map panel).\n * The parent must be positioned. The placeholder still applies. Default false.\n */\n fill?: boolean\n quality?: number\n fit?: Fit\n format?: Format\n /** Override the source intrinsic width used to cap the srcset (else read from a populated doc). */\n sourceWidth?: number\n /** Native `<img>` `loading`. Default `lazy`; set `eager` for an above-the-fold hero. */\n loading?: 'lazy' | 'eager'\n /** Native `<img>` `fetchpriority`. Default `auto`; set `high` for the LCP image. */\n fetchPriority?: 'high' | 'low' | 'auto'\n /** Native `<img>` `decoding` hint. Default `async`. */\n decoding?: 'async' | 'auto' | 'sync'\n /** Applied to the `<img>` (size / space / round it here). */\n className?: string\n /** Merged onto the `<img>`'s style. */\n style?: CSSProperties\n /** Absolute base for the generated URLs (default same-origin). */\n baseUrl?: string\n /** Transform endpoint base. Default `/api/img`; set it only if your Payload API route or Next.js basePath differs. */\n path?: string\n /** Payload config, used to resolve the project `pixelStep`. Rarely needed: without it the\n * component uses the config the plugin stashed at init, falling back to the `@payload-config`\n * alias `withPayload` sets up. Pass it explicitly only for a non-standard setup. */\n config?: Awaitable<SanitizedConfig>\n /** Explicit cache-busting version token (`v=`); overrides the one derived from the doc's filename + focal. */\n version?: string\n /**\n * Inline LQIP placeholder. `false` disables it; a **number** sets the LQIP width in px; an object\n * tunes `{ width, quality }`. Keep the width small — the LQIP is base64-inlined in **every**\n * response (~0.6 KB at 24px, ~2 KB at 48, ~3–4 KB at 64), so **24–64 is the sweet spot**; larger\n * just bloats the HTML. Defaults to the project `placeholder` config (24px). Supersedes `blur`.\n */\n placeholder?: PlaceholderProp\n /** @deprecated Use `placeholder` instead (`placeholder={false}` to disable). */\n blur?: boolean\n /** Extra attributes (e.g. `data-*`) spread onto the `<img>`. */\n dataAttributes?: Record<string, string>\n}\n\nconst CSS_OBJECT_FIT: Record<Fit, NonNullable<CSSProperties['objectFit']>> = {\n cover: 'cover',\n contain: 'contain',\n fill: 'fill',\n inside: 'contain',\n outside: 'cover',\n}\n\nconst idOf = (image: ResponsiveImageInput): string => (typeof image === 'object' ? String(image.id ?? '') : String(image ?? ''))\n\n// Once-per-process dev warnings — these failures repeat on every render, so warning each time would flood the console.\nlet warnedNoConfig = false\nlet warnedLqipFailed = false\n\nexport const ResponsiveImage = async (props: ResponsiveImageProps): Promise<ReactElement | null> => {\n const {\n image,\n alt,\n sizes = '100vw',\n aspectRatio,\n fill = false,\n quality = 75,\n fit = 'cover',\n format = 'auto',\n sourceWidth,\n loading = 'lazy',\n fetchPriority = 'auto',\n decoding = 'async',\n className,\n style,\n baseUrl,\n path,\n version,\n placeholder,\n blur = true,\n dataAttributes,\n config,\n } = props\n\n const id = idOf(image)\n if (!id) return null\n\n // Resolve the Payload config once (the srcset's `pixelStep` and the placeholder settings both\n // come off it): the explicit prop, else the globalThis stash the plugin's `onInit` filled (set\n // the moment Payload boots — no bundler involvement), else the `@payload-config` alias — which\n // only resolves from a published package when the consumer transpiles it (`transpilePackages`).\n // `as string` keeps TS from resolving the alias at this package's build. Stays undefined if\n // unreadable (defaults apply).\n let cfg: SanitizedConfig | undefined\n try {\n cfg = await (config ?? stashedConfig() ?? ((await import('@payload-config' as string)).default as Awaitable<SanitizedConfig>))\n } catch {\n cfg = undefined\n }\n if (!cfg && process.env.NODE_ENV !== 'production' && !warnedNoConfig) {\n warnedNoConfig = true\n console.warn(\n \"[payload-images] <ResponsiveImage> could not resolve the Payload config — project pixelStep/placeholder settings are skipped and the inline LQIP is disabled. Pass the `config` prop, or add `transpilePackages: ['@pro-laico/payload-images']` to next.config so the `@payload-config` alias resolves.\",\n )\n }\n const pixelStep = (cfg as { custom?: { payloadImages?: { pixelStep?: number | number[] } } } | undefined)?.custom?.payloadImages?.pixelStep\n\n const doc = typeof image === 'object' ? image : undefined\n const altText = alt ?? doc?.alt ?? ''\n const naturalW = doc?.width ?? undefined\n const naturalH = doc?.height ?? undefined\n // Warn (dev only) when an aspectRatio was passed but doesn't parse — we silently fall back to the\n // natural ratio, which is easy to miss; mirrors how Sanity's reference component surfaces this.\n if (!fill && aspectRatio != null && parseAspectRatio(aspectRatio) === undefined && process.env.NODE_ENV !== 'production') {\n console.warn(\n `[payload-images] Invalid aspectRatio ${JSON.stringify(aspectRatio)} — expected \"w/h\", \"w:h\", or a positive number. Falling back to the image's natural ratio.`,\n )\n }\n const ar = fill ? undefined : (parseAspectRatio(aspectRatio) ?? (naturalW && naturalH ? naturalW / naturalH : undefined))\n\n const opts: BuildSrcsetOptions = {\n fit,\n quality,\n format,\n aspectRatio: ar,\n baseUrl,\n path,\n pixelStep,\n sourceWidth: sourceWidth ?? naturalW,\n version: version ?? deriveVersion(doc),\n }\n const { srcset, src } = buildSrcset(id, opts)\n\n // The faithful inline LQIP: a tiny base64 variant at this exact ratio/focal, generated\n // server-side (shared variant cache) and painted as the <img>'s own background — instant,\n // zero network, covered when the real image loads. Needs a populated doc + the config;\n // the engine is dynamic-imported so the non-placeholder path never bundles Sharp/getPayload.\n const ph = resolvePlaceholderProp(placeholder, blur)\n let lqip: string | undefined\n if (ph.on && cfg && doc?.filename) {\n try {\n const { generateInlineLqip } = await import('./inlineLqip')\n // Trusted (component) call: the requested width is honored (no `untrusted` clamp).\n lqip = await generateInlineLqip({ config: cfg, source: doc as VariantSourceDoc, ar, fit, width: ph.width, quality: ph.quality })\n } catch (err) {\n lqip = undefined\n if (process.env.NODE_ENV !== 'production' && !warnedLqipFailed) {\n warnedLqipFailed = true\n console.warn(`[payload-images] <ResponsiveImage> inline LQIP generation failed — rendering without a placeholder. ${String(err)}`)\n }\n }\n }\n\n const intrinsicW = naturalW ?? (ar ? 1280 : undefined)\n const intrinsicH = naturalH ?? (ar && intrinsicW ? Math.round(intrinsicW / ar) : undefined)\n const bgSize = fit === 'contain' || fit === 'inside' ? 'contain' : 'cover'\n\n return (\n // biome-ignore lint/performance/noImgElement: intentional plain <img> — a hand-built srcset + inline LQIP that next/image would defeat\n <img\n src={src}\n srcSet={srcset}\n sizes={sizes}\n alt={altText}\n width={intrinsicW}\n height={intrinsicH}\n loading={loading}\n fetchPriority={fetchPriority}\n decoding={decoding}\n className={className}\n style={{\n display: 'block',\n width: '100%',\n height: fill ? '100%' : 'auto',\n ...(ar ? { aspectRatio: String(ar) } : null),\n objectFit: CSS_OBJECT_FIT[fit],\n ...(fill ? { position: 'absolute', inset: 0 } : null),\n ...(lqip\n ? { backgroundImage: `url(${lqip})`, backgroundSize: bgSize, backgroundPosition: 'center', backgroundRepeat: 'no-repeat' }\n : null),\n ...style,\n }}\n {...(dataAttributes as ImgHTMLAttributes<HTMLImageElement>)}\n />\n )\n}\n\nexport default ResponsiveImage\n"],"names":["stashedConfig","parseAspectRatio","buildSrcset","buildVariantUrl","deriveVersion","getImageUrl","stepWidths","resolvePlaceholderProp","placeholder","blur","on","width","quality","CSS_OBJECT_FIT","cover","contain","fill","inside","outside","idOf","image","String","id","warnedNoConfig","warnedLqipFailed","ResponsiveImage","props","alt","sizes","aspectRatio","fit","format","sourceWidth","loading","fetchPriority","decoding","className","style","baseUrl","path","version","dataAttributes","config","cfg","default","undefined","process","env","NODE_ENV","console","warn","pixelStep","custom","payloadImages","doc","altText","naturalW","naturalH","height","JSON","stringify","ar","opts","srcset","src","ph","lqip","filename","generateInlineLqip","source","err","intrinsicW","intrinsicH","Math","round","bgSize","img","srcSet","display","objectFit","position","inset","backgroundImage","backgroundSize","backgroundPosition","backgroundRepeat"],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Generate the inline LQIP for `<ResponsiveImage>`: a tiny, faithful (per aspect-ratio +
3
+ * focal point) variant resolved to a base64 `data:` URI, server-side. Shares the one
4
+ * `generated-images` cache via {@link getOrCreateVariantBytes}, so the LQIP for a given
5
+ * (id, ar, focal) is built once ever — by whichever door (this, or the `/api/img` endpoint)
6
+ * asks first.
7
+ *
8
+ * Server-only (pulls in `getPayload` + Sharp). `<ResponsiveImage>` imports it **dynamically**,
9
+ * only when a placeholder is actually needed, so the lightweight render path never bundles it.
10
+ */
11
+ import { type Payload, type SanitizedConfig } from 'payload';
12
+ import { type VariantSourceDoc } from '../transform/getVariantBytes';
13
+ import type { Fit } from '../transform/params';
14
+ export interface InlineLqipArgs {
15
+ /** The resolved Payload config (already in hand from `<ResponsiveImage>` / `req.payload.config`). */
16
+ config: SanitizedConfig;
17
+ /** Populated source doc — needs `filename`/`url` + focal point. */
18
+ source: VariantSourceDoc;
19
+ /** Render aspect ratio; omitted = the source's natural ratio. */
20
+ ar?: number;
21
+ fit: Fit;
22
+ /** Per-read width override (else the project default). Clamped per `untrusted`. */
23
+ width?: number;
24
+ /** Per-read quality override (else the project default). Clamped to 20–70. */
25
+ quality?: number;
26
+ /**
27
+ * Untrusted caller (the external `context.lqip` / `X-LQIP` door): clamp width to `maxWidth` and
28
+ * snap to /8. The trusted component path leaves this false and its width is honored up to a guard.
29
+ */
30
+ untrusted?: boolean;
31
+ /** Reuse an existing Payload instance (e.g. `req.payload` in a hook); otherwise `getPayload(config)`. */
32
+ payload?: Payload;
33
+ }
34
+ /** Returns a `data:` URI for the LQIP, or `undefined` when disabled / not generatable (caller renders no placeholder). */
35
+ export declare const generateInlineLqip: ({ config, source, ar, fit, width, quality, untrusted, payload: existing, }: InlineLqipArgs) => Promise<string | undefined>;
36
+ //# sourceMappingURL=inlineLqip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inlineLqip.d.ts","sourceRoot":"","sources":["../../src/components/inlineLqip.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAc,KAAK,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,SAAS,CAAA;AAGxE,OAAO,EAA2B,KAAK,gBAAgB,EAAE,MAAM,8BAA8B,CAAA;AAE7F,OAAO,KAAK,EAAE,GAAG,EAAgB,MAAM,qBAAqB,CAAA;AAS5D,MAAM,WAAW,cAAc;IAC7B,qGAAqG;IACrG,MAAM,EAAE,eAAe,CAAA;IACvB,mEAAmE;IACnE,MAAM,EAAE,gBAAgB,CAAA;IACxB,iEAAiE;IACjE,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,GAAG,CAAA;IACR,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,yGAAyG;IACzG,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,0HAA0H;AAC1H,eAAO,MAAM,kBAAkB,GAAU,4EAStC,cAAc,KAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAuB7C,CAAA"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Generate the inline LQIP for `<ResponsiveImage>`: a tiny, faithful (per aspect-ratio +
3
+ * focal point) variant resolved to a base64 `data:` URI, server-side. Shares the one
4
+ * `generated-images` cache via {@link getOrCreateVariantBytes}, so the LQIP for a given
5
+ * (id, ar, focal) is built once ever — by whichever door (this, or the `/api/img` endpoint)
6
+ * asks first.
7
+ *
8
+ * Server-only (pulls in `getPayload` + Sharp). `<ResponsiveImage>` imports it **dynamically**,
9
+ * only when a placeholder is actually needed, so the lightweight render path never bundles it.
10
+ */ import { getPayload } from "payload";
11
+ import { getServerSideURL } from "../lib/getServerSideURL.js";
12
+ import { getOrCreateVariantBytes } from "../transform/getVariantBytes.js";
13
+ import { clampLqipQuality, resolveLqipWidth } from "../transform/lqip.js";
14
+ /** Returns a `data:` URI for the LQIP, or `undefined` when disabled / not generatable (caller renders no placeholder). */ export const generateInlineLqip = async ({ config, source, ar, fit, width, quality, untrusted = false, payload: existing })=>{
15
+ try {
16
+ const stash = config.custom?.payloadImages;
17
+ const ph = stash?.placeholder;
18
+ if (!ph) return undefined // disabled project-wide
19
+ ;
20
+ const payload = existing ?? await getPayload({
21
+ config
22
+ });
23
+ const sourceSlug = stash?.sourceSlug ?? 'images';
24
+ const variantSlug = stash?.variantSlug ?? 'generated-images';
25
+ const maxInputPixels = stash?.maxInputPixels ?? 100_000_000;
26
+ const base = config.serverURL || getServerSideURL() || '';
27
+ const w = resolveLqipWidth(width, ph.width, ph.maxWidth, untrusted);
28
+ const q = clampLqipQuality(quality, ph.quality);
29
+ const h = ar ? Math.max(1, Math.round(w / ar)) : undefined;
30
+ const params = {
31
+ w,
32
+ h,
33
+ fit,
34
+ q,
35
+ fmt: ph.format
36
+ };
37
+ const res = await getOrCreateVariantBytes({
38
+ payload,
39
+ source,
40
+ params,
41
+ format: ph.format,
42
+ sourceSlug,
43
+ variantSlug,
44
+ base,
45
+ maxInputPixels
46
+ });
47
+ if (!res.ok) return undefined;
48
+ return `data:image/${ph.format};base64,${res.data.toString('base64')}`;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ };
53
+
54
+ //# sourceMappingURL=inlineLqip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/inlineLqip.ts"],"sourcesContent":["/**\n * Generate the inline LQIP for `<ResponsiveImage>`: a tiny, faithful (per aspect-ratio +\n * focal point) variant resolved to a base64 `data:` URI, server-side. Shares the one\n * `generated-images` cache via {@link getOrCreateVariantBytes}, so the LQIP for a given\n * (id, ar, focal) is built once ever — by whichever door (this, or the `/api/img` endpoint)\n * asks first.\n *\n * Server-only (pulls in `getPayload` + Sharp). `<ResponsiveImage>` imports it **dynamically**,\n * only when a placeholder is actually needed, so the lightweight render path never bundles it.\n */\nimport { getPayload, type Payload, type SanitizedConfig } from 'payload'\n\nimport { getServerSideURL } from '../lib/getServerSideURL'\nimport { getOrCreateVariantBytes, type VariantSourceDoc } from '../transform/getVariantBytes'\nimport { clampLqipQuality, resolveLqipWidth } from '../transform/lqip'\nimport type { Fit, ParsedParams } from '../transform/params'\n\ninterface PayloadImagesStash {\n sourceSlug?: string\n variantSlug?: string\n maxInputPixels?: number\n placeholder?: false | { width: number; quality: number; format: 'webp' | 'jpeg'; maxWidth: number }\n}\n\nexport interface InlineLqipArgs {\n /** The resolved Payload config (already in hand from `<ResponsiveImage>` / `req.payload.config`). */\n config: SanitizedConfig\n /** Populated source doc — needs `filename`/`url` + focal point. */\n source: VariantSourceDoc\n /** Render aspect ratio; omitted = the source's natural ratio. */\n ar?: number\n fit: Fit\n /** Per-read width override (else the project default). Clamped per `untrusted`. */\n width?: number\n /** Per-read quality override (else the project default). Clamped to 20–70. */\n quality?: number\n /**\n * Untrusted caller (the external `context.lqip` / `X-LQIP` door): clamp width to `maxWidth` and\n * snap to /8. The trusted component path leaves this false and its width is honored up to a guard.\n */\n untrusted?: boolean\n /** Reuse an existing Payload instance (e.g. `req.payload` in a hook); otherwise `getPayload(config)`. */\n payload?: Payload\n}\n\n/** Returns a `data:` URI for the LQIP, or `undefined` when disabled / not generatable (caller renders no placeholder). */\nexport const generateInlineLqip = async ({\n config,\n source,\n ar,\n fit,\n width,\n quality,\n untrusted = false,\n payload: existing,\n}: InlineLqipArgs): Promise<string | undefined> => {\n try {\n const stash = (config as { custom?: { payloadImages?: PayloadImagesStash } }).custom?.payloadImages\n const ph = stash?.placeholder\n if (!ph) return undefined // disabled project-wide\n\n const payload = existing ?? (await getPayload({ config }))\n const sourceSlug = stash?.sourceSlug ?? 'images'\n const variantSlug = stash?.variantSlug ?? 'generated-images'\n const maxInputPixels = stash?.maxInputPixels ?? 100_000_000\n const base = config.serverURL || getServerSideURL() || ''\n\n const w = resolveLqipWidth(width, ph.width, ph.maxWidth, untrusted)\n const q = clampLqipQuality(quality, ph.quality)\n const h = ar ? Math.max(1, Math.round(w / ar)) : undefined\n const params: ParsedParams = { w, h, fit, q, fmt: ph.format }\n\n const res = await getOrCreateVariantBytes({ payload, source, params, format: ph.format, sourceSlug, variantSlug, base, maxInputPixels })\n if (!res.ok) return undefined\n return `data:image/${ph.format};base64,${res.data.toString('base64')}`\n } catch {\n return undefined\n }\n}\n"],"names":["getPayload","getServerSideURL","getOrCreateVariantBytes","clampLqipQuality","resolveLqipWidth","generateInlineLqip","config","source","ar","fit","width","quality","untrusted","payload","existing","stash","custom","payloadImages","ph","placeholder","undefined","sourceSlug","variantSlug","maxInputPixels","base","serverURL","w","maxWidth","q","h","Math","max","round","params","fmt","format","res","ok","data","toString"],"mappings":"AAAA;;;;;;;;;CASC,GACD,SAASA,UAAU,QAA4C,UAAS;AAExE,SAASC,gBAAgB,QAAQ,6BAAyB;AAC1D,SAASC,uBAAuB,QAA+B,kCAA8B;AAC7F,SAASC,gBAAgB,EAAEC,gBAAgB,QAAQ,uBAAmB;AA+BtE,wHAAwH,GACxH,OAAO,MAAMC,qBAAqB,OAAO,EACvCC,MAAM,EACNC,MAAM,EACNC,EAAE,EACFC,GAAG,EACHC,KAAK,EACLC,OAAO,EACPC,YAAY,KAAK,EACjBC,SAASC,QAAQ,EACF;IACf,IAAI;QACF,MAAMC,QAAQ,AAACT,OAA+DU,MAAM,EAAEC;QACtF,MAAMC,KAAKH,OAAOI;QAClB,IAAI,CAACD,IAAI,OAAOE,UAAU,wBAAwB;;QAElD,MAAMP,UAAUC,YAAa,MAAMd,WAAW;YAAEM;QAAO;QACvD,MAAMe,aAAaN,OAAOM,cAAc;QACxC,MAAMC,cAAcP,OAAOO,eAAe;QAC1C,MAAMC,iBAAiBR,OAAOQ,kBAAkB;QAChD,MAAMC,OAAOlB,OAAOmB,SAAS,IAAIxB,sBAAsB;QAEvD,MAAMyB,IAAItB,iBAAiBM,OAAOQ,GAAGR,KAAK,EAAEQ,GAAGS,QAAQ,EAAEf;QACzD,MAAMgB,IAAIzB,iBAAiBQ,SAASO,GAAGP,OAAO;QAC9C,MAAMkB,IAAIrB,KAAKsB,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACN,IAAIlB,OAAOY;QACjD,MAAMa,SAAuB;YAAEP;YAAGG;YAAGpB;YAAKmB;YAAGM,KAAKhB,GAAGiB,MAAM;QAAC;QAE5D,MAAMC,MAAM,MAAMlC,wBAAwB;YAAEW;YAASN;YAAQ0B;YAAQE,QAAQjB,GAAGiB,MAAM;YAAEd;YAAYC;YAAaE;YAAMD;QAAe;QACtI,IAAI,CAACa,IAAIC,EAAE,EAAE,OAAOjB;QACpB,OAAO,CAAC,WAAW,EAAEF,GAAGiB,MAAM,CAAC,QAAQ,EAAEC,IAAIE,IAAI,CAACC,QAAQ,CAAC,WAAW;IACxE,EAAE,OAAM;QACN,OAAOnB;IACT;AACF,EAAC"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The on-demand image transform endpoint (Cloudflare-style settings in the URL),
3
+ * plus an authed purge endpoint. Config-level endpoints — registered by the plugin
4
+ * so they mount at `/api/img/...`. Same-origin: the handler STREAMS bytes (it never
5
+ * redirects to the storage host). On a miss it transforms with Sharp, responds
6
+ * immediately, and persists the variant after the response via Next's `after()`.
7
+ *
8
+ * Routing note: a config-level endpoint is only consulted when the first path
9
+ * segment isn't a collection/global slug — so the default `/img` base is safe as
10
+ * long as no collection is named `img`.
11
+ */
12
+ import type { Endpoint } from 'payload';
13
+ import { type TransformConstraints } from '../transform/params';
14
+ export interface TransformEndpointConfig extends Partial<TransformConstraints> {
15
+ /** Source image collection slug. Default `images`. */
16
+ sourceSlug?: string;
17
+ /** Generated-images collection slug. Default `generated-images`. */
18
+ variantSlug?: string;
19
+ /** Also emit `CDN-Cache-Control` / `Vercel-CDN-Cache-Control` (edge caching). Default true. */
20
+ cdnCacheControl?: boolean;
21
+ /** Max concurrent Sharp transforms in this process (default `cpus - 1`, or `IMAGES_TRANSFORM_CONCURRENCY`). */
22
+ maxConcurrency?: number;
23
+ /** Per-image libvips thread cap (default 1 for serverless safety; `0` = CPU cores, or `IMAGES_SHARP_CONCURRENCY`). */
24
+ sharpConcurrency?: number;
25
+ }
26
+ /** GET `/img/:id?w&h&ar&fit&q&fmt` — on-demand transform with focal-aware crop. */
27
+ export declare const createTransformEndpoint: (cfg?: TransformEndpointConfig) => Endpoint;
28
+ export interface PurgeEndpointConfig {
29
+ /** Generated-images collection slug. Default `generated-images`. */
30
+ variantSlug?: string;
31
+ /** Source image collection slug (purge is authorized against read access to it). Default `images`. */
32
+ sourceSlug?: string;
33
+ }
34
+ /**
35
+ * POST `/img/purge/:id` — delete all generated variants of a source image. Requires a
36
+ * logged-in user who can READ that source (so a user can't purge—and force costly
37
+ * regeneration of—variants for images they can't even see).
38
+ */
39
+ export declare const createPurgeEndpoint: (cfg?: PurgeEndpointConfig) => Endpoint;
40
+ //# sourceMappingURL=transform.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../../src/endpoints/transform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAkB,QAAQ,EAA2B,MAAM,SAAS,CAAA;AAQhF,OAAO,EAML,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAA;AAI5B,MAAM,WAAW,uBAAwB,SAAQ,OAAO,CAAC,oBAAoB,CAAC;IAC5E,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,+FAA+F;IAC/F,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,+GAA+G;IAC/G,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,sHAAsH;IACtH,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAuCD,mFAAmF;AACnF,eAAO,MAAM,uBAAuB,GAAI,MAAK,uBAA4B,KAAG,QAuE3E,CAAA;AAED,MAAM,WAAW,mBAAmB;IAClC,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,MAAK,mBAAwB,KAAG,QA4BnE,CAAA"}
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The on-demand image transform endpoint (Cloudflare-style settings in the URL),
3
+ * plus an authed purge endpoint. Config-level endpoints — registered by the plugin
4
+ * so they mount at `/api/img/...`. Same-origin: the handler STREAMS bytes (it never
5
+ * redirects to the storage host). On a miss it transforms with Sharp, responds
6
+ * immediately, and persists the variant after the response via Next's `after()`.
7
+ *
8
+ * Routing note: a config-level endpoint is only consulted when the first path
9
+ * segment isn't a collection/global slug — so the default `/img` base is safe as
10
+ * long as no collection is named `img`.
11
+ */ import { GENERATED_IMAGES_SLUG } from "../collections/generatedImages.js";
12
+ import { purgeVariantsForSource } from "../hooks/purge.js";
13
+ import { getServerSideURL } from "../lib/getServerSideURL.js";
14
+ import { createSingleFlight } from "../transform/coalesce.js";
15
+ import { getOrCreateVariantBytes } from "../transform/getVariantBytes.js";
16
+ import { setTransformConcurrency } from "../transform/limit.js";
17
+ import { DEFAULT_CONSTRAINTS, negotiateFormat, parseTransformParams } from "../transform/params.js";
18
+ import { setSharpConcurrency } from "../transform/sharpInstance.js";
19
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
20
+ const PRIVATE_IMMUTABLE = 'private, max-age=31536000, immutable';
21
+ const toBody = (buf)=>buf;
22
+ const resolveConstraints = (cfg)=>({
23
+ maxDimension: cfg.maxDimension ?? DEFAULT_CONSTRAINTS.maxDimension,
24
+ qualityRange: cfg.qualityRange ?? DEFAULT_CONSTRAINTS.qualityRange,
25
+ defaultQuality: cfg.defaultQuality ?? DEFAULT_CONSTRAINTS.defaultQuality,
26
+ formats: cfg.formats ?? DEFAULT_CONSTRAINTS.formats,
27
+ defaultFormat: cfg.defaultFormat ?? DEFAULT_CONSTRAINTS.defaultFormat,
28
+ preferAvif: cfg.preferAvif ?? DEFAULT_CONSTRAINTS.preferAvif,
29
+ dimensionStep: cfg.dimensionStep ?? DEFAULT_CONSTRAINTS.dimensionStep,
30
+ maxInputPixels: cfg.maxInputPixels ?? DEFAULT_CONSTRAINTS.maxInputPixels
31
+ });
32
+ const buildHeaders = (mime, key, isAuto, cdn, isPublic)=>{
33
+ const h = {
34
+ 'Content-Type': mime,
35
+ 'Cache-Control': isPublic ? IMMUTABLE : PRIVATE_IMMUTABLE,
36
+ ETag: `"${key}"`
37
+ };
38
+ if (cdn && isPublic) {
39
+ h['CDN-Cache-Control'] = IMMUTABLE;
40
+ h['Vercel-CDN-Cache-Control'] = IMMUTABLE;
41
+ }
42
+ if (isAuto) h.Vary = 'Accept';
43
+ return h;
44
+ };
45
+ const routeId = (req)=>{
46
+ const raw = req.routeParams?.id;
47
+ return raw == null ? '' : String(raw);
48
+ };
49
+ /** GET `/img/:id?w&h&ar&fit&q&fmt` — on-demand transform with focal-aware crop. */ export const createTransformEndpoint = (cfg = {})=>{
50
+ const path = '/img';
51
+ const sourceSlug = cfg.sourceSlug || 'images';
52
+ const variantSlug = cfg.variantSlug || GENERATED_IMAGES_SLUG;
53
+ const cdn = cfg.cdnCacheControl !== false;
54
+ const constraints = resolveConstraints(cfg);
55
+ setTransformConcurrency(cfg.maxConcurrency);
56
+ setSharpConcurrency(cfg.sharpConcurrency);
57
+ // Per-endpoint single-flight maps: dedupe the source read across one <img>'s srcset
58
+ // widths, and coalesce variant generation under a thundering herd. See ./coalesce.
59
+ const sourceFlight = createSingleFlight();
60
+ const genFlight = createSingleFlight();
61
+ return {
62
+ path: `${path}/:id`,
63
+ method: 'get',
64
+ handler: async (req)=>{
65
+ const { payload } = req;
66
+ //NOTE: This origin is ONLY used to self-fetch an original from *relative-URL* storage.
67
+ //NOTE: Absolute-URL adapters (Vercel Blob, S3-public) fetch doc.url directly, and local disk
68
+ //NOTE: reads the filesystem — both paths ignore `base` entirely. The chain self-resolves per
69
+ //NOTE: environment (serverURL -> env -> req.origin -> localhost), so it works zero-config and
70
+ //NOTE: a missing serverURL is NOT a general 502 risk. The fallbacks are intentional — do not
71
+ //NOTE: "harden" this by requiring serverURL; relative-URL storage is the only case that needs it.
72
+ const base = payload.config.serverURL || getServerSideURL() || req.origin || 'http://localhost:3000';
73
+ const id = routeId(req);
74
+ if (!id) return new Response('Missing id', {
75
+ status: 400
76
+ });
77
+ const parsed = parseTransformParams(req.searchParams ?? new URLSearchParams(), constraints);
78
+ if (!parsed.ok) return new Response(parsed.error, {
79
+ status: 400
80
+ });
81
+ const p = parsed.params;
82
+ const readSource = async (user)=>{
83
+ try {
84
+ return await payload.findByID({
85
+ collection: sourceSlug,
86
+ id,
87
+ depth: 0,
88
+ overrideAccess: false,
89
+ user
90
+ });
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+ // The anonymous read is shareable across the concurrent srcset requests for this
96
+ // id, so coalesce it; the per-user fallback (private sources) stays uncoalesced.
97
+ let source = await sourceFlight(id, ()=>readSource(null));
98
+ const isPublic = source != null;
99
+ if (!source && req.user) source = await readSource(req.user);
100
+ if (!source || !source.url && !source.filename) return new Response('Not found', {
101
+ status: 404
102
+ });
103
+ const src = source;
104
+ const isAuto = p.fmt === 'auto';
105
+ const format = isAuto ? negotiateFormat(req.headers.get('accept'), constraints.formats, constraints.preferAvif) : p.fmt;
106
+ const result = await getOrCreateVariantBytes({
107
+ payload,
108
+ source: src,
109
+ params: p,
110
+ format,
111
+ sourceSlug,
112
+ variantSlug,
113
+ base,
114
+ maxInputPixels: constraints.maxInputPixels,
115
+ genFlight
116
+ });
117
+ if (!result.ok) return new Response(result.msg, {
118
+ status: result.status
119
+ });
120
+ return new Response(toBody(result.data), {
121
+ headers: buildHeaders(result.mimeType, result.key, isAuto, cdn, isPublic)
122
+ });
123
+ }
124
+ };
125
+ };
126
+ /**
127
+ * POST `/img/purge/:id` — delete all generated variants of a source image. Requires a
128
+ * logged-in user who can READ that source (so a user can't purge—and force costly
129
+ * regeneration of—variants for images they can't even see).
130
+ */ export const createPurgeEndpoint = (cfg = {})=>{
131
+ const path = '/img/purge';
132
+ const variantSlug = cfg.variantSlug || GENERATED_IMAGES_SLUG;
133
+ const sourceSlug = cfg.sourceSlug || 'images';
134
+ return {
135
+ path: `${path}/:id`,
136
+ method: 'post',
137
+ handler: async (req)=>{
138
+ if (!req.user) return Response.json({
139
+ error: 'Unauthorized'
140
+ }, {
141
+ status: 401
142
+ });
143
+ const id = routeId(req);
144
+ if (!id) return Response.json({
145
+ error: 'Missing id'
146
+ }, {
147
+ status: 400
148
+ });
149
+ try {
150
+ await req.payload.findByID({
151
+ collection: sourceSlug,
152
+ id,
153
+ depth: 0,
154
+ overrideAccess: false,
155
+ user: req.user
156
+ });
157
+ } catch {
158
+ return Response.json({
159
+ error: 'Not found'
160
+ }, {
161
+ status: 404
162
+ });
163
+ }
164
+ try {
165
+ const deleted = await purgeVariantsForSource(req.payload, variantSlug, id, req);
166
+ return Response.json({
167
+ deleted
168
+ });
169
+ } catch (err) {
170
+ req.payload.logger.error(`[payload-images] purge failed for ${id}: ${String(err)}`);
171
+ return Response.json({
172
+ error: 'Purge failed'
173
+ }, {
174
+ status: 500
175
+ });
176
+ }
177
+ }
178
+ };
179
+ };
180
+
181
+ //# sourceMappingURL=transform.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/endpoints/transform.ts"],"sourcesContent":["/**\n * The on-demand image transform endpoint (Cloudflare-style settings in the URL),\n * plus an authed purge endpoint. Config-level endpoints — registered by the plugin\n * so they mount at `/api/img/...`. Same-origin: the handler STREAMS bytes (it never\n * redirects to the storage host). On a miss it transforms with Sharp, responds\n * immediately, and persists the variant after the response via Next's `after()`.\n *\n * Routing note: a config-level endpoint is only consulted when the first path\n * segment isn't a collection/global slug — so the default `/img` base is safe as\n * long as no collection is named `img`.\n */\nimport type { CollectionSlug, Endpoint, Payload, PayloadRequest } from 'payload'\n\nimport { GENERATED_IMAGES_SLUG } from '../collections/generatedImages'\nimport { purgeVariantsForSource } from '../hooks/purge'\nimport { getServerSideURL } from '../lib/getServerSideURL'\nimport { createSingleFlight } from '../transform/coalesce'\nimport { type GenBytes, getOrCreateVariantBytes } from '../transform/getVariantBytes'\nimport { setTransformConcurrency } from '../transform/limit'\nimport {\n DEFAULT_CONSTRAINTS,\n type Format,\n negotiateFormat,\n type OutputFormat,\n parseTransformParams,\n type TransformConstraints,\n} from '../transform/params'\nimport { setSharpConcurrency } from '../transform/sharpInstance'\nimport type { UploadDocLike } from '../transform/source'\n\nexport interface TransformEndpointConfig extends Partial<TransformConstraints> {\n /** Source image collection slug. Default `images`. */\n sourceSlug?: string\n /** Generated-images collection slug. Default `generated-images`. */\n variantSlug?: string\n /** Also emit `CDN-Cache-Control` / `Vercel-CDN-Cache-Control` (edge caching). Default true. */\n cdnCacheControl?: boolean\n /** Max concurrent Sharp transforms in this process (default `cpus - 1`, or `IMAGES_TRANSFORM_CONCURRENCY`). */\n maxConcurrency?: number\n /** Per-image libvips thread cap (default 1 for serverless safety; `0` = CPU cores, or `IMAGES_SHARP_CONCURRENCY`). */\n sharpConcurrency?: number\n}\n\ntype SourceDoc = UploadDocLike & { id: string | number; focalX?: number | null; focalY?: number | null }\n\nconst IMMUTABLE = 'public, max-age=31536000, immutable'\nconst PRIVATE_IMMUTABLE = 'private, max-age=31536000, immutable'\n\nconst toBody = (buf: Buffer): BodyInit => buf as unknown as BodyInit\n\nconst resolveConstraints = (cfg: TransformEndpointConfig): TransformConstraints => ({\n maxDimension: cfg.maxDimension ?? DEFAULT_CONSTRAINTS.maxDimension,\n qualityRange: cfg.qualityRange ?? DEFAULT_CONSTRAINTS.qualityRange,\n defaultQuality: cfg.defaultQuality ?? DEFAULT_CONSTRAINTS.defaultQuality,\n formats: cfg.formats ?? DEFAULT_CONSTRAINTS.formats,\n defaultFormat: cfg.defaultFormat ?? DEFAULT_CONSTRAINTS.defaultFormat,\n preferAvif: cfg.preferAvif ?? DEFAULT_CONSTRAINTS.preferAvif,\n dimensionStep: cfg.dimensionStep ?? DEFAULT_CONSTRAINTS.dimensionStep,\n maxInputPixels: cfg.maxInputPixels ?? DEFAULT_CONSTRAINTS.maxInputPixels,\n})\n\nconst buildHeaders = (mime: string, key: string, isAuto: boolean, cdn: boolean, isPublic: boolean): Record<string, string> => {\n const h: Record<string, string> = {\n 'Content-Type': mime,\n 'Cache-Control': isPublic ? IMMUTABLE : PRIVATE_IMMUTABLE,\n ETag: `\"${key}\"`,\n }\n if (cdn && isPublic) {\n h['CDN-Cache-Control'] = IMMUTABLE\n h['Vercel-CDN-Cache-Control'] = IMMUTABLE\n }\n if (isAuto) h.Vary = 'Accept'\n return h\n}\n\nconst routeId = (req: PayloadRequest): string => {\n const raw = req.routeParams?.id\n return raw == null ? '' : String(raw)\n}\n\n/** GET `/img/:id?w&h&ar&fit&q&fmt` — on-demand transform with focal-aware crop. */\nexport const createTransformEndpoint = (cfg: TransformEndpointConfig = {}): Endpoint => {\n const path = '/img'\n const sourceSlug = (cfg.sourceSlug || 'images') as CollectionSlug\n const variantSlug = (cfg.variantSlug || GENERATED_IMAGES_SLUG) as CollectionSlug\n const cdn = cfg.cdnCacheControl !== false\n const constraints = resolveConstraints(cfg)\n setTransformConcurrency(cfg.maxConcurrency)\n setSharpConcurrency(cfg.sharpConcurrency)\n\n // Per-endpoint single-flight maps: dedupe the source read across one <img>'s srcset\n // widths, and coalesce variant generation under a thundering herd. See ./coalesce.\n const sourceFlight = createSingleFlight<string, SourceDoc | null>()\n const genFlight = createSingleFlight<string, GenBytes>()\n\n return {\n path: `${path}/:id`,\n method: 'get',\n handler: async (req: PayloadRequest): Promise<Response> => {\n const { payload } = req\n\n //NOTE: This origin is ONLY used to self-fetch an original from *relative-URL* storage.\n //NOTE: Absolute-URL adapters (Vercel Blob, S3-public) fetch doc.url directly, and local disk\n //NOTE: reads the filesystem — both paths ignore `base` entirely. The chain self-resolves per\n //NOTE: environment (serverURL -> env -> req.origin -> localhost), so it works zero-config and\n //NOTE: a missing serverURL is NOT a general 502 risk. The fallbacks are intentional — do not\n //NOTE: \"harden\" this by requiring serverURL; relative-URL storage is the only case that needs it.\n const base = payload.config.serverURL || getServerSideURL() || req.origin || 'http://localhost:3000'\n\n const id = routeId(req)\n if (!id) return new Response('Missing id', { status: 400 })\n\n const parsed = parseTransformParams(req.searchParams ?? new URLSearchParams(), constraints)\n if (!parsed.ok) return new Response(parsed.error, { status: 400 })\n const p = parsed.params\n\n const readSource = async (user: PayloadRequest['user']): Promise<SourceDoc | null> => {\n try {\n return (await payload.findByID({ collection: sourceSlug, id, depth: 0, overrideAccess: false, user })) as unknown as SourceDoc\n } catch {\n return null\n }\n }\n // The anonymous read is shareable across the concurrent srcset requests for this\n // id, so coalesce it; the per-user fallback (private sources) stays uncoalesced.\n let source = await sourceFlight(id, () => readSource(null))\n const isPublic = source != null\n if (!source && req.user) source = await readSource(req.user)\n if (!source || (!source.url && !source.filename)) return new Response('Not found', { status: 404 })\n const src = source\n\n const isAuto = p.fmt === 'auto'\n const format: OutputFormat = isAuto\n ? negotiateFormat(req.headers.get('accept'), constraints.formats, constraints.preferAvif)\n : (p.fmt as Exclude<Format, 'auto'>)\n\n const result = await getOrCreateVariantBytes({\n payload,\n source: src,\n params: p,\n format,\n sourceSlug,\n variantSlug,\n base,\n maxInputPixels: constraints.maxInputPixels,\n genFlight,\n })\n\n if (!result.ok) return new Response(result.msg, { status: result.status })\n return new Response(toBody(result.data), { headers: buildHeaders(result.mimeType, result.key, isAuto, cdn, isPublic) })\n },\n }\n}\n\nexport interface PurgeEndpointConfig {\n /** Generated-images collection slug. Default `generated-images`. */\n variantSlug?: string\n /** Source image collection slug (purge is authorized against read access to it). Default `images`. */\n sourceSlug?: string\n}\n\n/**\n * POST `/img/purge/:id` — delete all generated variants of a source image. Requires a\n * logged-in user who can READ that source (so a user can't purge—and force costly\n * regeneration of—variants for images they can't even see).\n */\nexport const createPurgeEndpoint = (cfg: PurgeEndpointConfig = {}): Endpoint => {\n const path = '/img/purge'\n const variantSlug = cfg.variantSlug || GENERATED_IMAGES_SLUG\n const sourceSlug = (cfg.sourceSlug || 'images') as CollectionSlug\n\n return {\n path: `${path}/:id`,\n method: 'post',\n handler: async (req: PayloadRequest): Promise<Response> => {\n if (!req.user) return Response.json({ error: 'Unauthorized' }, { status: 401 })\n const id = routeId(req)\n if (!id) return Response.json({ error: 'Missing id' }, { status: 400 })\n\n try {\n await req.payload.findByID({ collection: sourceSlug, id, depth: 0, overrideAccess: false, user: req.user })\n } catch {\n return Response.json({ error: 'Not found' }, { status: 404 })\n }\n\n try {\n const deleted = await purgeVariantsForSource(req.payload as Payload, variantSlug, id, req)\n return Response.json({ deleted })\n } catch (err) {\n req.payload.logger.error(`[payload-images] purge failed for ${id}: ${String(err)}`)\n return Response.json({ error: 'Purge failed' }, { status: 500 })\n }\n },\n }\n}\n"],"names":["GENERATED_IMAGES_SLUG","purgeVariantsForSource","getServerSideURL","createSingleFlight","getOrCreateVariantBytes","setTransformConcurrency","DEFAULT_CONSTRAINTS","negotiateFormat","parseTransformParams","setSharpConcurrency","IMMUTABLE","PRIVATE_IMMUTABLE","toBody","buf","resolveConstraints","cfg","maxDimension","qualityRange","defaultQuality","formats","defaultFormat","preferAvif","dimensionStep","maxInputPixels","buildHeaders","mime","key","isAuto","cdn","isPublic","h","ETag","Vary","routeId","req","raw","routeParams","id","String","createTransformEndpoint","path","sourceSlug","variantSlug","cdnCacheControl","constraints","maxConcurrency","sharpConcurrency","sourceFlight","genFlight","method","handler","payload","base","config","serverURL","origin","Response","status","parsed","searchParams","URLSearchParams","ok","error","p","params","readSource","user","findByID","collection","depth","overrideAccess","source","url","filename","src","fmt","format","headers","get","result","msg","data","mimeType","createPurgeEndpoint","json","deleted","err","logger"],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Virtual (computed, never stored) URL fields for the image doc. An `afterRead` hook builds
3
+ * each URL from the doc's own id + dimensions + filename + focal point, so the optimized URLs
4
+ * ride along in EVERY read — REST, GraphQL, and the Local API — and through relationship
5
+ * population (a populated `heroImage` carries `srcset` / `placeholderURL` ready to render),
6
+ * with no client code and no knowledge of the `/api/img` endpoint.
7
+ *
8
+ * URLs are absolute when `config.serverURL` is set (handy for mobile / email / OG consumers),
9
+ * relative otherwise. The builders are pure/isomorphic, so importing them here is server-safe.
10
+ */
11
+ import type { Field } from 'payload';
12
+ /** Fields the virtual URLs are computed from — kept selected via the collection's `forceSelect`. */
13
+ export declare const VIRTUAL_URL_INPUTS: readonly ["width", "height", "filename", "focalX", "focalY"];
14
+ /**
15
+ * The virtual URL fields appended to the image collection:
16
+ * - `src` — a single optimized URL (capped at 1280px) for a plain `<img>` / OG tag.
17
+ * - `srcset` — a responsive srcset at the image's natural ratio, stepped up to its width.
18
+ * - `placeholderURL` — the tiny LQIP URL (32px, q40) for a blur-up / CSS background (URL form, for
19
+ * non-React consumers; `<ResponsiveImage>` inlines its own LQIP instead).
20
+ * - `thumbnailURL` — a 160px focal-cropped square for cards, lists, and feeds.
21
+ * - `blurDataURL` — an on-demand inline LQIP data-URI (gated; see {@link blurDataUrlField}).
22
+ */
23
+ export declare const virtualUrlFields: () => Field[];
24
+ /** Field names produced by {@link virtualUrlFields} — used to wire `defaultPopulate`. */
25
+ export declare const VIRTUAL_URL_FIELDS: readonly ["src", "srcset", "placeholderURL", "thumbnailURL"];
26
+ //# sourceMappingURL=virtualUrls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"virtualUrls.d.ts","sourceRoot":"","sources":["../../src/fields/virtualUrls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,KAAK,EAAa,MAAM,SAAS,CAAA;AAe/C,oGAAoG;AACpG,eAAO,MAAM,kBAAkB,8DAA+D,CAAA;AAoH9F;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,QAAO,KAAK,EA6BxC,CAAA;AAED,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,8DAA+D,CAAA"}
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Virtual (computed, never stored) URL fields for the image doc. An `afterRead` hook builds
3
+ * each URL from the doc's own id + dimensions + filename + focal point, so the optimized URLs
4
+ * ride along in EVERY read — REST, GraphQL, and the Local API — and through relationship
5
+ * population (a populated `heroImage` carries `srcset` / `placeholderURL` ready to render),
6
+ * with no client code and no knowledge of the `/api/img` endpoint.
7
+ *
8
+ * URLs are absolute when `config.serverURL` is set (handy for mobile / email / OG consumers),
9
+ * relative otherwise. The builders are pure/isomorphic, so importing them here is server-safe.
10
+ */ import { buildSrcset, buildVariantUrl, deriveVersion, getImageUrl } from "../utils/urls.js";
11
+ import { parseAspectRatio } from "../transform/params.js";
12
+ /** Fields the virtual URLs are computed from — kept selected via the collection's `forceSelect`. */ export const VIRTUAL_URL_INPUTS = [
13
+ 'width',
14
+ 'height',
15
+ 'filename',
16
+ 'focalX',
17
+ 'focalY'
18
+ ];
19
+ const naturalAspectRatio = (d)=>d.width && d.height ? d.width / d.height : undefined;
20
+ /** Build a single virtual `text` field from a `(doc, baseUrl) => url` computer. */ const virtualUrl = (name, description, compute)=>{
21
+ const afterRead = ({ data, req })=>{
22
+ const doc = data ?? {};
23
+ if (doc.id == null || !doc.filename) return null;
24
+ const cfg = req?.payload?.config;
25
+ // Explicit '' (not undefined) so these fields keep their serverURL-or-relative rule — passing
26
+ // undefined would let getImageUrl's NEXT_PUBLIC_SERVER_URL default leak in for `src`.
27
+ const baseUrl = cfg?.serverURL || '';
28
+ const pixelStep = cfg?.custom?.payloadImages?.pixelStep;
29
+ return compute(doc, baseUrl, pixelStep);
30
+ };
31
+ return {
32
+ name,
33
+ type: 'text',
34
+ virtual: true,
35
+ admin: {
36
+ hidden: true,
37
+ description
38
+ },
39
+ hooks: {
40
+ afterRead: [
41
+ afterRead
42
+ ]
43
+ }
44
+ };
45
+ };
46
+ const numOrUndef = (v)=>{
47
+ const n = Number(v);
48
+ return Number.isFinite(n) ? n : undefined;
49
+ };
50
+ /** Parse an `X-LQIP` header: a bare ratio (`16/9`) or a `;`-list (`16/9; w=48; q=50` / `ar=16/9; w=48`). */ const parseLqipHeader = (h)=>{
51
+ const out = {};
52
+ for (const part of h.split(';')){
53
+ const s = part.trim();
54
+ if (!s) continue;
55
+ const eq = s.indexOf('=');
56
+ if (eq === -1) {
57
+ out.ar = out.ar ?? parseAspectRatio(s);
58
+ continue;
59
+ }
60
+ const k = s.slice(0, eq).trim().toLowerCase();
61
+ const v = s.slice(eq + 1).trim();
62
+ if (k === 'ar') out.ar = parseAspectRatio(v);
63
+ else if (k === 'w' || k === 'width') out.width = numOrUndef(v);
64
+ else if (k === 'q' || k === 'quality') out.quality = numOrUndef(v);
65
+ }
66
+ return out;
67
+ };
68
+ /** Read an LQIP request off the operation: `req.context.lqip = { ar?, fit?, width?, quality? }` (or `true`), or an `X-LQIP` header. */ const readLqipRequest = (req)=>{
69
+ const ctx = req?.context?.lqip;
70
+ if (ctx === true) return {};
71
+ if (ctx && typeof ctx === 'object') {
72
+ const o = ctx;
73
+ return {
74
+ ar: parseAspectRatio(o.ar),
75
+ fit: o.fit,
76
+ width: numOrUndef(o.width),
77
+ quality: numOrUndef(o.quality)
78
+ };
79
+ }
80
+ const header = req?.headers?.get?.('x-lqip');
81
+ if (header) return parseLqipHeader(header);
82
+ return undefined;
83
+ };
84
+ /**
85
+ * A virtual `blurDataURL` field — the external door onto the inline-LQIP engine. It's a cheap
86
+ * no-op on every normal read; only when an operation opts in (`req.context.lqip = { ar, fit }`,
87
+ * or an `X-LQIP: 16/9` header) does it generate a faithful base64 data-URI via the shared variant
88
+ * cache. The engine is dynamic-imported so the collection module never eagerly pulls in Sharp.
89
+ * Deliberately kept OUT of `defaultPopulate` so it never runs during relationship population.
90
+ */ const blurDataUrlField = ()=>{
91
+ const afterRead = async ({ data, req })=>{
92
+ const lqipReq = readLqipRequest(req);
93
+ if (!lqipReq) return null // not requested → no work
94
+ ;
95
+ const doc = data ?? {};
96
+ if (doc.id == null || !doc.filename) return null;
97
+ const cfg = req?.payload?.config;
98
+ if (!cfg) return null;
99
+ try {
100
+ const { generateInlineLqip } = await import("../components/inlineLqip.js");
101
+ // Untrusted door: width/quality come from the caller, so clamp (untrusted: true).
102
+ const uri = await generateInlineLqip({
103
+ config: cfg,
104
+ payload: req.payload,
105
+ source: {
106
+ id: doc.id,
107
+ filename: doc.filename,
108
+ url: doc.url,
109
+ focalX: doc.focalX,
110
+ focalY: doc.focalY
111
+ },
112
+ ar: lqipReq.ar,
113
+ fit: lqipReq.fit ?? 'cover',
114
+ width: lqipReq.width,
115
+ quality: lqipReq.quality,
116
+ untrusted: true
117
+ });
118
+ return uri ?? null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ };
123
+ return {
124
+ name: 'blurDataURL',
125
+ type: 'text',
126
+ virtual: true,
127
+ admin: {
128
+ hidden: true,
129
+ description: 'Inline LQIP data-URI; generated only when a read sets req.context.lqip ({ ar, fit }) or sends an X-LQIP header. Null otherwise.'
130
+ },
131
+ hooks: {
132
+ afterRead: [
133
+ afterRead
134
+ ]
135
+ }
136
+ };
137
+ };
138
+ /**
139
+ * The virtual URL fields appended to the image collection:
140
+ * - `src` — a single optimized URL (capped at 1280px) for a plain `<img>` / OG tag.
141
+ * - `srcset` — a responsive srcset at the image's natural ratio, stepped up to its width.
142
+ * - `placeholderURL` — the tiny LQIP URL (32px, q40) for a blur-up / CSS background (URL form, for
143
+ * non-React consumers; `<ResponsiveImage>` inlines its own LQIP instead).
144
+ * - `thumbnailURL` — a 160px focal-cropped square for cards, lists, and feeds.
145
+ * - `blurDataURL` — an on-demand inline LQIP data-URI (gated; see {@link blurDataUrlField}).
146
+ */ export const virtualUrlFields = ()=>[
147
+ virtualUrl('src', 'Optimized URL (≤1280px) for a plain <img> or OG tag.', (d, baseUrl)=>getImageUrl({
148
+ id: d.id,
149
+ width: d.width,
150
+ filename: d.filename,
151
+ focalX: d.focalX,
152
+ focalY: d.focalY
153
+ }, {
154
+ width: Math.min(d.width ?? 1280, 1280),
155
+ baseUrl
156
+ })),
157
+ virtualUrl('srcset', 'Responsive srcset at the natural ratio, up to the source width.', (d, baseUrl, pixelStep)=>buildSrcset(String(d.id), {
158
+ sourceWidth: d.width ?? undefined,
159
+ aspectRatio: naturalAspectRatio(d),
160
+ version: deriveVersion(d),
161
+ baseUrl,
162
+ pixelStep
163
+ }).srcset),
164
+ virtualUrl('placeholderURL', 'Tiny low-quality placeholder (LQIP) for a blur-up / CSS background.', (d, baseUrl)=>buildVariantUrl(String(d.id), 32, {
165
+ quality: 40,
166
+ aspectRatio: naturalAspectRatio(d),
167
+ version: deriveVersion(d),
168
+ baseUrl
169
+ })),
170
+ virtualUrl('thumbnailURL', 'Small focal-cropped square (160px) for cards, lists, and feeds.', (d, baseUrl)=>buildVariantUrl(String(d.id), 160, {
171
+ fit: 'cover',
172
+ aspectRatio: 1,
173
+ version: deriveVersion(d),
174
+ baseUrl
175
+ })),
176
+ blurDataUrlField()
177
+ ];
178
+ /** Field names produced by {@link virtualUrlFields} — used to wire `defaultPopulate`. */ export const VIRTUAL_URL_FIELDS = [
179
+ 'src',
180
+ 'srcset',
181
+ 'placeholderURL',
182
+ 'thumbnailURL'
183
+ ];
184
+
185
+ //# sourceMappingURL=virtualUrls.js.map