@bygghammar/cms-platform 0.92.2 → 0.94.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 +93 -0
- package/package.json +2 -1
- package/src/admin/module-inventory.ts +67 -0
- package/src/admin/registry-metadata.ts +256 -0
- package/src/admin/semver.ts +111 -0
- package/src/admin/version-core.ts +233 -25
- package/src/admin/version-report.ts +295 -0
- package/src/backoffice/VersionView.tsx +747 -66
- package/src/backoffice/media/ReuploadOverwrite.tsx +2 -2
- package/src/backoffice/media/ReviewImageUpload.tsx +8 -4
- package/src/backoffice/routes/index.tsx +94 -9
- package/src/backoffice/shell/use-resolved-config.tsx +30 -5
- package/src/config/modules.ts +11 -0
- 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/popups/package-name.ts +8 -0
- package/src/server/media-library-core.ts +5 -3
- package/src/server/review-image-validate.ts +3 -2
|
@@ -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
|
);
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
Users,
|
|
27
27
|
} from "lucide-react";
|
|
28
28
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
29
|
-
import { useCallback, useRef } from "react";
|
|
29
|
+
import { useCallback, useRef, useState } from "react";
|
|
30
30
|
import { useServerFn } from "@tanstack/react-start";
|
|
31
31
|
|
|
32
32
|
import { MISSING_CREATED_ID } from "../../tracking/injections";
|
|
@@ -48,13 +48,16 @@ import { SitemapView, type SitemapCheck } from "../SitemapView";
|
|
|
48
48
|
import { SiteSettingsView } from "../SiteSettingsView";
|
|
49
49
|
import { TrackingView, type TrackingDeps } from "../TrackingView";
|
|
50
50
|
import { UsersView } from "../UsersView";
|
|
51
|
-
import { VersionView } from "../VersionView";
|
|
51
|
+
import { VersionView, type PlatformBuildInfo, type PlatformRelease } from "../VersionView";
|
|
52
|
+
import { moduleInventory, type ModuleSettingsRead } from "../../admin/module-inventory";
|
|
53
|
+
import type { VersionReport } from "../../admin/version-report";
|
|
52
54
|
import { parseEffectiveAdminHost } from "../../site/locale-domains";
|
|
53
55
|
import {
|
|
54
56
|
useFeatureEnabled,
|
|
55
57
|
useFeatureGate,
|
|
56
58
|
useModuleGate,
|
|
57
59
|
useMyRoles,
|
|
60
|
+
useResolvedSiteConfig,
|
|
58
61
|
} from "../shell/use-resolved-config";
|
|
59
62
|
import { backofficeHost, backofficePaths } from "./host";
|
|
60
63
|
|
|
@@ -127,14 +130,96 @@ export function UsersRoute() {
|
|
|
127
130
|
);
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
|
|
133
|
+
type SystemVersionPayload = {
|
|
134
|
+
build?: PlatformBuildInfo;
|
|
135
|
+
releases?: PlatformRelease[];
|
|
136
|
+
/** Present from hosts that build it with `buildVersionReport`. */
|
|
137
|
+
report?: VersionReport;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const SYSTEM_VERSION_QUERY_KEY = ["admin", "system-version"] as const;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Release, package and module screen.
|
|
144
|
+
*
|
|
145
|
+
* Read-only: it shows the module switches, it never changes them. The version
|
|
146
|
+
* read is not polled — no refetch on focus, a five-minute stale time — and a
|
|
147
|
+
* manual check goes through the server, which applies its own minimum
|
|
148
|
+
* interval before asking the registry again.
|
|
149
|
+
*/
|
|
131
150
|
export function VersionRoute() {
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
151
|
+
const host = backofficeHost();
|
|
152
|
+
const paths = backofficePaths(host);
|
|
153
|
+
const fetchVersion = useServerFn(host.fns.getSystemVersion);
|
|
154
|
+
const queryClient = useQueryClient();
|
|
155
|
+
const q = useQuery({
|
|
156
|
+
queryKey: SYSTEM_VERSION_QUERY_KEY,
|
|
157
|
+
queryFn: () => fetchVersion({ data: { refresh: false } }) as Promise<SystemVersionPayload>,
|
|
158
|
+
staleTime: 5 * 60_000,
|
|
159
|
+
refetchOnWindowFocus: false,
|
|
160
|
+
retry: 1,
|
|
161
|
+
});
|
|
162
|
+
const refreshing = useRef(false);
|
|
163
|
+
const [isRefreshing, setRefreshing] = useState(false);
|
|
164
|
+
// The `dataUpdatedAt` of the answer that was on screen when a "Check again"
|
|
165
|
+
// failed. The failure notice shows while that same answer is still shown, so
|
|
166
|
+
// any later successful answer (a retry or a re-read) clears it.
|
|
167
|
+
const [refreshFailedFor, setRefreshFailedFor] = useState<number | null>(null);
|
|
168
|
+
const refresh = useCallback(async () => {
|
|
169
|
+
if (refreshing.current) return;
|
|
170
|
+
refreshing.current = true;
|
|
171
|
+
setRefreshing(true);
|
|
172
|
+
try {
|
|
173
|
+
const next = (await fetchVersion({ data: { refresh: true } })) as SystemVersionPayload;
|
|
174
|
+
queryClient.setQueryData(SYSTEM_VERSION_QUERY_KEY, next);
|
|
175
|
+
setRefreshFailedFor(null);
|
|
176
|
+
} catch {
|
|
177
|
+
// Keep the last answer on screen, but say the check failed. The error
|
|
178
|
+
// itself (an expired session, a network failure) is not shown.
|
|
179
|
+
setRefreshFailedFor(queryClient.getQueryState(SYSTEM_VERSION_QUERY_KEY)?.dataUpdatedAt ?? 0);
|
|
180
|
+
} finally {
|
|
181
|
+
refreshing.current = false;
|
|
182
|
+
setRefreshing(false);
|
|
183
|
+
}
|
|
184
|
+
}, [fetchVersion, queryClient]);
|
|
185
|
+
|
|
186
|
+
const settings = useResolvedSiteConfig();
|
|
187
|
+
const settingsRead: ModuleSettingsRead =
|
|
188
|
+
settings.status === "loading"
|
|
189
|
+
? { state: "loading" }
|
|
190
|
+
: settings.status === "error"
|
|
191
|
+
? { state: "error" }
|
|
192
|
+
: { state: "ready", features: settings.config.features, stale: settings.status === "stale" };
|
|
193
|
+
const report = q.data?.report && q.data.report.schemaVersion === 1 ? q.data.report : null;
|
|
194
|
+
const cmsVersion =
|
|
195
|
+
report?.packages.find((p) => p.role === "cms")?.installed ?? q.data?.build?.platformVersion ?? null;
|
|
196
|
+
// The latest request failed while an earlier answer is still on screen:
|
|
197
|
+
// a failed "Check again", or a failed re-read of cached data.
|
|
198
|
+
const latestCheckFailed =
|
|
199
|
+
q.data !== undefined &&
|
|
200
|
+
((refreshFailedFor !== null && refreshFailedFor === q.dataUpdatedAt) || q.isError);
|
|
201
|
+
|
|
135
202
|
return (
|
|
136
203
|
<VersionView
|
|
137
|
-
deps={{
|
|
204
|
+
deps={{
|
|
205
|
+
build: q.data?.build,
|
|
206
|
+
releases: q.data?.releases ?? [],
|
|
207
|
+
loading: q.isLoading,
|
|
208
|
+
error: q.isError && !q.data,
|
|
209
|
+
latestCheckFailed,
|
|
210
|
+
onRetry: () => void q.refetch(),
|
|
211
|
+
report,
|
|
212
|
+
modules: moduleInventory(settingsRead, report?.packages, q.isLoading ? null : cmsVersion),
|
|
213
|
+
onRefresh: report ? () => void refresh() : undefined,
|
|
214
|
+
refreshing: isRefreshing,
|
|
215
|
+
siteName: host.siteConfig.name,
|
|
216
|
+
settingsHref: paths.settings,
|
|
217
|
+
renderLink: ({ href, className, children }) => (
|
|
218
|
+
<Link to={href} className={className}>
|
|
219
|
+
{children}
|
|
220
|
+
</Link>
|
|
221
|
+
),
|
|
222
|
+
}}
|
|
138
223
|
/>
|
|
139
224
|
);
|
|
140
225
|
}
|
|
@@ -466,8 +551,8 @@ export function AdminOverviewRoute() {
|
|
|
466
551
|
},
|
|
467
552
|
{
|
|
468
553
|
to: paths.version,
|
|
469
|
-
title: "
|
|
470
|
-
body: "
|
|
554
|
+
title: "Version & modules",
|
|
555
|
+
body: "The live site release, installed CMS and module packages, module status and available updates.",
|
|
471
556
|
icon: Package,
|
|
472
557
|
},
|
|
473
558
|
...(isSuper && host.siteConfig.features.backup
|
|
@@ -21,11 +21,27 @@ import { backofficeNav, type BackofficeNavItem, type BackofficeNavOptions } from
|
|
|
21
21
|
/** Query key shared with the site-settings screen. */
|
|
22
22
|
export const SITE_SETTINGS_QUERY_KEY = ["admin", "site-settings"] as const;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Where the resolved configuration came from:
|
|
26
|
+
*
|
|
27
|
+
* - `loading`: no answer yet; `config` is the compiled code config.
|
|
28
|
+
* - `error`: the read failed (or answered without settings) and no earlier
|
|
29
|
+
* answer is held; `config` is the compiled code config, not the live one.
|
|
30
|
+
* - `ready`: `config` includes the saved overrides.
|
|
31
|
+
* - `stale`: a re-read failed; `config` is from the last successful read.
|
|
32
|
+
*/
|
|
33
|
+
export type SiteSettingsStatus = "loading" | "error" | "ready" | "stale";
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
36
|
* Code config merged with the stored overrides. While loading, the code config
|
|
26
|
-
* is returned so the shell renders without a flash of empty navigation
|
|
37
|
+
* is returned so the shell renders without a flash of empty navigation; a
|
|
38
|
+
* caller that must not present that fallback as the live answer reads `status`.
|
|
27
39
|
*/
|
|
28
|
-
export function useResolvedSiteConfig(): {
|
|
40
|
+
export function useResolvedSiteConfig(): {
|
|
41
|
+
config: SiteConfig;
|
|
42
|
+
isLoading: boolean;
|
|
43
|
+
status: SiteSettingsStatus;
|
|
44
|
+
} {
|
|
29
45
|
const host = backofficeHost();
|
|
30
46
|
const load = useServerFn(host.fns.getSiteSettings);
|
|
31
47
|
const q = useQuery({
|
|
@@ -34,13 +50,22 @@ export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolea
|
|
|
34
50
|
staleTime: 30_000,
|
|
35
51
|
});
|
|
36
52
|
|
|
37
|
-
const
|
|
53
|
+
const fromSettings =
|
|
38
54
|
(q.data?.config as SiteConfig | undefined) ??
|
|
39
55
|
(q.data?.overrides
|
|
40
56
|
? resolveSiteConfig(host.siteConfig, q.data.overrides as never)
|
|
41
|
-
:
|
|
57
|
+
: undefined);
|
|
58
|
+
const resolved = fromSettings ?? host.siteConfig;
|
|
59
|
+
|
|
60
|
+
const status: SiteSettingsStatus = q.isLoading
|
|
61
|
+
? "loading"
|
|
62
|
+
: !fromSettings
|
|
63
|
+
? "error"
|
|
64
|
+
: q.isError
|
|
65
|
+
? "stale"
|
|
66
|
+
: "ready";
|
|
42
67
|
|
|
43
|
-
return { config: resolved, isLoading: q.isLoading };
|
|
68
|
+
return { config: resolved, isLoading: q.isLoading, status };
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
/** Sidebar model built from the live (override-aware) configuration. */
|
package/src/config/modules.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
MessageSquareQuote,
|
|
24
24
|
} from "lucide-react";
|
|
25
25
|
|
|
26
|
+
import { POPUP_PACKAGE } from "../popups/package-name";
|
|
27
|
+
|
|
26
28
|
/**
|
|
27
29
|
* Every module a site can switch on.
|
|
28
30
|
*
|
|
@@ -90,6 +92,14 @@ export type CmsModuleDefinition = {
|
|
|
90
92
|
* enabled, so a disabled module mounts nothing.
|
|
91
93
|
*/
|
|
92
94
|
readonly hasPublicRuntime?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* A separately published first-party package the module's runtime is built
|
|
97
|
+
* on, beyond the CMS package itself. The version screen reports its installed
|
|
98
|
+
* and published versions; every other module ships inside the CMS package and
|
|
99
|
+
* updates with it. Naming a package here also allows the server to look it up
|
|
100
|
+
* in the public registry, so only first-party packages belong here.
|
|
101
|
+
*/
|
|
102
|
+
readonly package?: `@bygghammar/${string}`;
|
|
93
103
|
};
|
|
94
104
|
|
|
95
105
|
/**
|
|
@@ -145,6 +155,7 @@ export const CMS_MODULES = [
|
|
|
145
155
|
nav: { path: "/popups", label: "Popups", icon: MessageSquare },
|
|
146
156
|
requiresRole: "staff",
|
|
147
157
|
hasPublicRuntime: true,
|
|
158
|
+
package: POPUP_PACKAGE,
|
|
148
159
|
},
|
|
149
160
|
] as const satisfies readonly CmsModuleDefinition[];
|
|
150
161
|
|
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
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The separately published package the Popups module is built on.
|
|
3
|
+
*
|
|
4
|
+
* Named here, inside the module's own directory, so the module table can
|
|
5
|
+
* declare it without the rest of the CMS mentioning the popup package. This
|
|
6
|
+
* file holds only the name: importing it loads nothing from the package.
|
|
7
|
+
*/
|
|
8
|
+
export const POPUP_PACKAGE = "@bygghammar/lightweight-popup" as const;
|
|
@@ -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;
|