@bygghammar/cms-platform 0.92.1 → 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 CHANGED
@@ -8,6 +8,45 @@ 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
+
37
+ ## 0.92.2
38
+
39
+ - Fix rich-text attribute escaping. `sanitizeHtml` read attribute values as
40
+ raw text and escaped them again, so every save and every render added an
41
+ `amp;` to each `&` in a link: `?calendardates=…&trp=…` reached the browser
42
+ with a query key named `amp;trp`. Attribute values are now decoded once,
43
+ checked, and escaped exactly once, so sanitizing is idempotent through save
44
+ and render. The URL allowlist, style checks and target check judge the
45
+ decoded value, the one the browser will use. Text content is unchanged.
46
+ - Links stored before this release keep their extra levels until an editor
47
+ saves the correct URL; they no longer grow on each save.
48
+ - Two scheduling test suites no longer depend on the date they run on.
49
+
11
50
  ## 0.92.1
12
51
 
13
52
  - Fix the page-builder save and publish RPC call by removing the unsupported
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.92.1",
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 &amp; 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 and regenerate their PNG fallback.
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: cover-cropped to 16:10 and re-encoded to WebP at
34
- * 480/768/1200/1920 in the browser, then re-validated server-side before storage.
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
- Auto-cropped to 16:10 and compressed to WebP (480/768/1200/1920). Max 15 MB. PNGs with
100
- transparency keep their alpha channel and receive a PNG fallback.
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
  );
@@ -85,6 +85,48 @@ function escapeAttr(v: string): string {
85
85
  return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
86
86
  }
87
87
 
88
+ /** The named references the editor and browsers write into attribute values. */
89
+ const NAMED_REFERENCES: Record<string, string> = {
90
+ amp: "&",
91
+ AMP: "&",
92
+ lt: "<",
93
+ LT: "<",
94
+ gt: ">",
95
+ GT: ">",
96
+ quot: '"',
97
+ QUOT: '"',
98
+ apos: "'",
99
+ nbsp: "\u00a0",
100
+ };
101
+
102
+ /**
103
+ * An attribute value as a browser reads it: its character references decoded.
104
+ *
105
+ * Stored markup is HTML, so `?a=1&amp;b=2` means `?a=1&b=2`. Everything below
106
+ * judges the decoded value, and `escapeAttr` encodes it exactly once on the
107
+ * way out. Reading the raw text instead let `escapeAttr` encode the editor's
108
+ * own `&amp;` a second time, so every save and every render added a level and
109
+ * a link's `&b=` arrived in the browser as a key named `amp;b`.
110
+ *
111
+ * Numeric references, and the named ones above, are decoded. Any other name
112
+ * is left as written, so it is escaped as text and reaches the browser as that
113
+ * literal text. Either way the browser ends up with exactly the string that
114
+ * was checked: `&#106;avascript:` is judged as `javascript:`, and an
115
+ * undecoded `javascript&colon;` never becomes one.
116
+ */
117
+ function decodeAttr(value: string): string {
118
+ return value.replace(
119
+ /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{0,31}));/g,
120
+ (reference, decimal?: string, hex?: string, name?: string) => {
121
+ if (name !== undefined) return NAMED_REFERENCES[name] ?? reference;
122
+ const code = decimal !== undefined ? Number.parseInt(decimal, 10) : Number.parseInt(hex ?? "", 16);
123
+ // What a browser does with these: the replacement character.
124
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return "\ufffd";
125
+ return String.fromCodePoint(code);
126
+ },
127
+ );
128
+ }
129
+
88
130
  const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
89
131
 
90
132
  function sanitizeAttrs(tag: string, raw: string): string {
@@ -94,7 +136,7 @@ function sanitizeAttrs(tag: string, raw: string): string {
94
136
  ATTR_RE.lastIndex = 0;
95
137
  while ((m = ATTR_RE.exec(raw))) {
96
138
  const name = m[1].toLowerCase();
97
- let value = m[3] ?? m[4] ?? m[5] ?? "";
139
+ let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
98
140
  if (name.startsWith("on")) continue;
99
141
  if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
100
142
  if (name === "href" || name === "src" || name === "srcset") {
@@ -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
- * When the source is a responsive WebP variant with a `-fallback.png` sibling,
107
- * renders a `<picture>` so browsers that do not support WebP (or need explicit
108
- * alpha handling) still receive a transparent PNG.
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
- if (artSources.length || (hasFallback && "fallbackSrc" in img)) {
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={hasFallback && "fallbackSrc" in img ? img.fallbackSrc : img.src}
194
+ src={img.src}
199
195
  alt={rest.alt}
200
196
  style={merged}
201
197
  onError={(event) => {
@@ -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, and a single `-fallback.png` is produced so
6
- * older browsers still see a transparent image.
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 fallback). */
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 fallback at the largest
100
- // width. It is stored as an extra so the primary WebP URL stays stable
101
- // and every existing page reference remains valid.
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 (no crop at upload
5
- * time — cropping happens at display time around the editor-chosen focus point)
6
- * and are re-encoded to WebP at the four standard responsive widths so review
7
- * cards stay fast. The server re-validates every produced variant
8
- * (see `review-image.server.ts`) — this step is an optimisation, not a trust boundary.
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
- import { RESPONSIVE_WIDTHS } from "./srcset";
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
- /** Safety ceiling for the stored image height (very tall panoramas). */
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 RESPONSIVE_WIDTHS) {
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;
@@ -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` for older browsers.
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 plus a PNG fallback when one exists. For
9
- * any other URL shape, returns the original `src` so legacy assets keep working
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
- /** PNG fallback URL for browsers that do not support WebP or need alpha. */
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 crops and re-encodes to WebP at the four responsive widths; every
397
- * variant is independently validated here (magic number, real dimensions,
398
- * aspect, compression ratio, byte ceiling) before anything is stored.
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 ratio;
9
- * cropping is done at display time around the saved focus point.
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;