@bygghammar/cms-platform 0.92.2 → 0.93.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.
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/src/backoffice/media/ReuploadOverwrite.tsx +2 -2
- package/src/backoffice/media/ReviewImageUpload.tsx +8 -4
- package/src/media/FocalImage.tsx +12 -16
- package/src/media/optimize.ts +8 -6
- package/src/media/review-image.ts +22 -8
- package/src/media/srcset.ts +13 -6
- package/src/server/media-library-core.ts +5 -3
- package/src/server/review-image-validate.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,32 @@ matches `package.json` exactly. See
|
|
|
8
8
|
Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
|
|
9
9
|
from the published npm tarballs.
|
|
10
10
|
|
|
11
|
+
## 0.93.0
|
|
12
|
+
|
|
13
|
+
- **Breaking for callers of `responsiveImage`, and for transparent images in
|
|
14
|
+
browsers without WebP:** responsive images now display as WebP only.
|
|
15
|
+
`responsiveImage` used to derive a `-fallback.png` for every responsive WebP
|
|
16
|
+
and `FocalImage` rendered it as the `<img src>`; the optimizer writes that
|
|
17
|
+
PNG only for transparent uploads, so opaque images carried a missing object
|
|
18
|
+
that crawlers and browsers without WebP requested. `FocalImage` now renders
|
|
19
|
+
the stored URL as `src` with the WebP widths as `srcSet`, and uses
|
|
20
|
+
`<picture>` only for art direction. `ResponsiveImage.fallbackSrc` is
|
|
21
|
+
deprecated and never set. Transparent uploads still get their PNG copy, but
|
|
22
|
+
responsive page markup does not serve it, so a browser without WebP support
|
|
23
|
+
does not receive it, even when the upload generated one. **Host side:** drop
|
|
24
|
+
any branch that reads `fallbackSrc`; it is now always undefined (Voyara's
|
|
25
|
+
site hero has one).
|
|
26
|
+
- Eager responsive images are now preloaded by React's server renderer,
|
|
27
|
+
because they are no longer inside a `<picture>`.
|
|
28
|
+
- Fix review-image uploads. The browser prepared five WebP widths (including
|
|
29
|
+
2560) while the server accepts exactly four (480, 768, 1200, 1920), so every
|
|
30
|
+
review upload was rejected before storage. Review images are now prepared at
|
|
31
|
+
the four review widths. The upload help text now says what happens:
|
|
32
|
+
proportions are usually kept, a very tall image may be cropped at the top
|
|
33
|
+
and bottom to the 2400px height limit, and no PNG copy is made.
|
|
34
|
+
- Upload and re-upload help text no longer promises a PNG fallback; review
|
|
35
|
+
image uploads never made one.
|
|
36
|
+
|
|
11
37
|
## 0.92.2
|
|
12
38
|
|
|
13
39
|
- Fix rich-text attribute escaping. `sanitizeHtml` read attribute values as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -139,8 +139,8 @@ export function ReuploadOverwrite({
|
|
|
139
139
|
<strong className="font-medium text-ink">Re-upload & overwrite</strong> — pick the
|
|
140
140
|
original source files again. Matching library files are regenerated at the current quality
|
|
141
141
|
(480/768/1200/1920/2560, q0.90) and written over the same filenames, so nothing needs
|
|
142
|
-
re-linking. Transparent PNGs/WebPs keep alpha
|
|
143
|
-
Superadmin only.
|
|
142
|
+
re-linking. Transparent PNGs/WebPs keep alpha in the WebP files; their PNG copy is
|
|
143
|
+
regenerated too, but responsive page images use the WebP files only. Superadmin only.
|
|
144
144
|
</div>
|
|
145
145
|
<label className="inline-flex items-center gap-2 text-[0.7rem] text-ink/70">
|
|
146
146
|
<input
|
|
@@ -30,8 +30,10 @@ export type ReviewImageUploadProps = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Uploads a review image:
|
|
34
|
-
*
|
|
33
|
+
* Uploads a review image: re-encoded in the browser to WebP at 480/768/1200/1920,
|
|
34
|
+
* usually with its proportions kept (a variant taller than the 2400px stored-height
|
|
35
|
+
* limit is center-cropped vertically; pages crop around the focus point when
|
|
36
|
+
* shown), then re-validated server-side before storage.
|
|
35
37
|
*
|
|
36
38
|
* Host-agnostic: storage happens through the injected `upload` function.
|
|
37
39
|
*/
|
|
@@ -96,8 +98,10 @@ export function ReviewImageUpload({
|
|
|
96
98
|
{busy ? "Optimizing…" : "Upload image"}
|
|
97
99
|
</button>
|
|
98
100
|
<p className="mt-1.5 text-[0.7rem] text-ink/55">
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
Usually keeps its proportions, but a very tall image may be cropped at the top and
|
|
102
|
+
bottom to the 2400px height limit; pages crop it around the focus point. Compressed
|
|
103
|
+
to WebP (480/768/1200/1920). Max 15 MB. PNGs with
|
|
104
|
+
transparency keep their alpha channel in the WebP files; no PNG copy is made.
|
|
101
105
|
</p>
|
|
102
106
|
</div>
|
|
103
107
|
);
|
package/src/media/FocalImage.tsx
CHANGED
|
@@ -103,9 +103,12 @@ export type FocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> &
|
|
|
103
103
|
* `decoding="async"`, so an image nobody asked to prioritize can never compete
|
|
104
104
|
* with the first screen. The one image that should compete says `priority`.
|
|
105
105
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
106
|
+
* A responsive WebP variant renders as one `<img>` whose `src` is the stored URL
|
|
107
|
+
* and whose `srcSet` holds the sibling widths. Only art direction adds a
|
|
108
|
+
* `<picture>`. Display is WebP-only: no `-fallback.png` is ever referenced,
|
|
109
|
+
* even for a transparent upload whose optimizer run wrote one, because nothing
|
|
110
|
+
* stored says which uploads have one. A browser without WebP support gets no
|
|
111
|
+
* PNG from this markup.
|
|
109
112
|
*/
|
|
110
113
|
export function FocalImage({
|
|
111
114
|
src,
|
|
@@ -158,17 +161,6 @@ export function FocalImage({
|
|
|
158
161
|
fetchPriority: fetchPriorityAttr,
|
|
159
162
|
};
|
|
160
163
|
|
|
161
|
-
// Only wrap in <picture> when a fallback PNG is advertised. The fallback URL
|
|
162
|
-
// is always derived, but it only exists for transparent uploads; using it for
|
|
163
|
-
// every image would cause unnecessary 404s on opaque assets.
|
|
164
|
-
const hasFallback =
|
|
165
|
-
"fallbackSrc" in img &&
|
|
166
|
-
img.fallbackSrc &&
|
|
167
|
-
// A missing fallback is indistinguishable from an opaque asset, so we only
|
|
168
|
-
// render the <source> when the primary URL is definitely a responsive WebP.
|
|
169
|
-
img.type === "image/webp" &&
|
|
170
|
-
img.srcSet;
|
|
171
|
-
|
|
172
164
|
const artSources =
|
|
173
165
|
responsive && !srcSetFailed
|
|
174
166
|
? (artDirected ?? [])
|
|
@@ -176,7 +168,11 @@ export function FocalImage({
|
|
|
176
168
|
.filter((a) => a.img.src)
|
|
177
169
|
: [];
|
|
178
170
|
|
|
179
|
-
|
|
171
|
+
// Only art direction needs a <picture>. Its <img src> is the stored URL, the
|
|
172
|
+
// one file known to exist: a `-fallback.png` sibling exists only for
|
|
173
|
+
// transparent uploads, so pointing at it broke every opaque image for
|
|
174
|
+
// crawlers and for any browser that falls back to `src`.
|
|
175
|
+
if (artSources.length) {
|
|
180
176
|
// The <picture> is only a source selector: it must not take the layout or
|
|
181
177
|
// focus classes, or the inner <img> loses `object-position` and the crop
|
|
182
178
|
// ignores the editor-chosen focus point.
|
|
@@ -195,7 +191,7 @@ export function FocalImage({
|
|
|
195
191
|
<img
|
|
196
192
|
{...imgProps}
|
|
197
193
|
className={className}
|
|
198
|
-
src={
|
|
194
|
+
src={img.src}
|
|
199
195
|
alt={rest.alt}
|
|
200
196
|
style={merged}
|
|
201
197
|
onError={(event) => {
|
package/src/media/optimize.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Client-side media optimization run at upload time in the backoffice.
|
|
3
3
|
* - Images → WebP variants, quality 0.90, max width 2560 (preserves aspect).
|
|
4
4
|
* PNG/WebP/AVIF sources with an alpha channel keep their transparency in
|
|
5
|
-
* every generated WebP file
|
|
6
|
-
*
|
|
5
|
+
* every generated WebP file. They also get a single `-fallback.png` copy,
|
|
6
|
+
* but responsive page markup never references it: those images display as
|
|
7
|
+
* WebP only, so a browser without WebP support does not receive the PNG.
|
|
7
8
|
* - Videos → original file + a JPG poster grabbed from ~10% into the clip.
|
|
8
9
|
* - Everything else → passthrough.
|
|
9
10
|
*
|
|
@@ -18,7 +19,7 @@ export type OptimizedFile = {
|
|
|
18
19
|
|
|
19
20
|
export type OptimizedBundle = {
|
|
20
21
|
primary: OptimizedFile;
|
|
21
|
-
/** Optional sibling files uploaded alongside (e.g. video poster, PNG
|
|
22
|
+
/** Optional sibling files uploaded alongside (e.g. video poster, transparent PNG copy). */
|
|
22
23
|
extras: OptimizedFile[];
|
|
23
24
|
};
|
|
24
25
|
|
|
@@ -96,9 +97,10 @@ export async function optimizeForUpload(
|
|
|
96
97
|
const primary = files.find((f) => f.name.endsWith("-2560.webp")) ?? files[files.length - 1];
|
|
97
98
|
const extras = files.filter((f) => f !== primary);
|
|
98
99
|
|
|
99
|
-
// Transparent sources also get a single PNG
|
|
100
|
-
//
|
|
101
|
-
//
|
|
100
|
+
// Transparent sources also get a single PNG copy at the largest width,
|
|
101
|
+
// stored as an extra so the primary WebP URL stays stable. Responsive
|
|
102
|
+
// markup does not reference it (nothing stored says which uploads have
|
|
103
|
+
// one), so no page serves it as a fallback.
|
|
102
104
|
if (transparent) {
|
|
103
105
|
const fallback = await encodePngAt(bmp, largestW, largestH);
|
|
104
106
|
extras.push({ name: `${base}-fallback.png`, blob: fallback, contentType: "image/png" });
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side preparation of review (testimonial) images.
|
|
3
3
|
*
|
|
4
|
-
* Uploaded review images keep their original aspect ratio
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Uploaded review images usually keep their original aspect ratio. The exception
|
|
5
|
+
* is a source tall enough that a variant would exceed `MAX_STORED_HEIGHT`: that
|
|
6
|
+
* variant is center-cropped vertically to the limit. Display may crop again,
|
|
7
|
+
* around the editor-chosen focus point. Variants are re-encoded to WebP at the
|
|
8
|
+
* four review widths (`REVIEW_IMAGE_WIDTHS`)
|
|
9
|
+
* so review cards stay fast. The server re-validates every produced variant
|
|
10
|
+
* (see `server/review-image-validate.ts`) — this step is an optimisation, not a
|
|
11
|
+
* trust boundary.
|
|
9
12
|
*
|
|
10
13
|
* Browser-only (uses Canvas). Do NOT import from server code.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The review-image upload contract: exactly these widths, in this order. The
|
|
18
|
+
* server accepts nothing else (`mediaSchemas.reviewImage` requires four
|
|
19
|
+
* variants and `validateReviewVariant` checks each width against
|
|
20
|
+
* `REVIEW_WIDTHS`) and stores the 1920 file as the primary. Deliberately not
|
|
21
|
+
* the general `RESPONSIVE_WIDTHS`, which added 2560 for page media: five
|
|
22
|
+
* variants fail the server schema, so no review image could be stored.
|
|
23
|
+
*/
|
|
24
|
+
export const REVIEW_IMAGE_WIDTHS = [480, 768, 1200, 1920] as const;
|
|
14
25
|
|
|
15
26
|
export const REVIEW_ASPECT = 16 / 10;
|
|
16
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Height ceiling for every stored variant. When a variant's proportional height
|
|
29
|
+
* would exceed it, `encodeCoverWebp` center-crops the image vertically to fit.
|
|
30
|
+
*/
|
|
17
31
|
export const MAX_STORED_HEIGHT = 2400;
|
|
18
32
|
export const REVIEW_QUALITY = 0.8;
|
|
19
33
|
/** Hard client-side guard before we even decode the file. */
|
|
@@ -63,7 +77,7 @@ export async function prepareReviewImage(file: File): Promise<PreparedReviewImag
|
|
|
63
77
|
}
|
|
64
78
|
const variants: PreparedVariant[] = [];
|
|
65
79
|
let optimizedBytes = 0;
|
|
66
|
-
for (const targetW of
|
|
80
|
+
for (const targetW of REVIEW_IMAGE_WIDTHS) {
|
|
67
81
|
const width = Math.min(targetW, Math.max(320, bmp.width));
|
|
68
82
|
let height = Math.max(1, Math.round((width * bmp.height) / bmp.width));
|
|
69
83
|
if (height > MAX_STORED_HEIGHT) height = MAX_STORED_HEIGHT;
|
package/src/media/srcset.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Responsive image helpers. Works with files produced by `optimizeForUpload`,
|
|
3
3
|
* which uploads a base slug plus WebP variants: `-480`, `-768`, `-1200`, `-1920`
|
|
4
4
|
* and (for assets uploaded after the HiDPI change) `-2560`. Transparent uploads
|
|
5
|
-
* also produce a single `-fallback.png
|
|
5
|
+
* also produce a single `-fallback.png`, but only those: nothing a page stores
|
|
6
|
+
* says which uploads were transparent, so that file is never advertised here
|
|
7
|
+
* and responsive images display as WebP only.
|
|
6
8
|
*
|
|
7
9
|
* Given the largest variant URL (`…-1920.webp`), derives sibling URLs and returns
|
|
8
|
-
* a ready-to-use `srcSet`/`sizes` pair
|
|
9
|
-
*
|
|
10
|
-
* with no responsive scaling.
|
|
10
|
+
* a ready-to-use `srcSet`/`sizes` pair. For any other URL shape, returns the
|
|
11
|
+
* original `src` so legacy assets keep working with no responsive scaling.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { resolveMediaSrc } from "./urls";
|
|
@@ -23,7 +24,14 @@ export type ResponsiveImage = {
|
|
|
23
24
|
sizes?: string;
|
|
24
25
|
/** MIME type of the responsive srcSet (`image/webp`). */
|
|
25
26
|
type?: string;
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* @deprecated Never set. It used to be derived from the WebP name, but the
|
|
29
|
+
* optimizer writes `-fallback.png` only for transparent uploads, so for every
|
|
30
|
+
* opaque one the derived URL was a missing object rendered as `<img src>`.
|
|
31
|
+
* Kept so existing readers still compile; they now take their non-fallback
|
|
32
|
+
* path, and a transparent upload's PNG is no longer offered to browsers
|
|
33
|
+
* without WebP support.
|
|
34
|
+
*/
|
|
27
35
|
fallbackSrc?: string;
|
|
28
36
|
};
|
|
29
37
|
|
|
@@ -53,7 +61,6 @@ export function responsiveImage(input: string | undefined, sizes?: string): Resp
|
|
|
53
61
|
srcSet: parts.join(", "),
|
|
54
62
|
sizes: sizes ?? "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 1200px",
|
|
55
63
|
type: "image/webp",
|
|
56
|
-
fallbackSrc: `${base}-fallback.png${query}`,
|
|
57
64
|
};
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -393,9 +393,11 @@ export async function uploadMediaBundleCore(
|
|
|
393
393
|
/**
|
|
394
394
|
* Upload a review (testimonial) image.
|
|
395
395
|
*
|
|
396
|
-
* The browser
|
|
397
|
-
* variant
|
|
398
|
-
*
|
|
396
|
+
* The browser re-encodes to WebP at the four review widths, keeping proportions
|
|
397
|
+
* unless a variant would exceed the 2400px stored-height limit, which it
|
|
398
|
+
* center-crops vertically. Every variant is independently validated here (magic
|
|
399
|
+
* number, real dimensions, aspect, compression ratio, byte ceiling) before
|
|
400
|
+
* anything is stored.
|
|
399
401
|
*/
|
|
400
402
|
export async function uploadReviewImageCore(
|
|
401
403
|
deps: MediaLibraryDeps,
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* never trusts that: every variant is decoded here, sniffed for a real WebP magic
|
|
6
6
|
* number, measured, and checked against the canonical review constraints
|
|
7
7
|
* (expected width, sane aspect bounds, height ceiling, byte ceiling) before
|
|
8
|
-
* anything is written to storage. Images keep their original aspect
|
|
9
|
-
*
|
|
8
|
+
* anything is written to storage. Images usually keep their original aspect
|
|
9
|
+
* ratio: the browser center-crops a variant only when it would exceed the height
|
|
10
|
+
* ceiling, and display crops around the saved focus point.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
export const REVIEW_WIDTHS = [480, 768, 1200, 1920] as const;
|