@bygghammar/cms-platform 0.3.0 → 0.4.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/README.md +5 -0
- package/package.json +46 -9
- package/src/backup/browser-media.ts +18 -1
- package/src/backup/datasets.ts +249 -0
- package/src/backup/export-core.ts +350 -0
- package/src/backup/media-export.ts +24 -0
- package/src/backup/restore-core.ts +465 -0
- package/src/cms/shared-draft-store.ts +66 -0
- package/src/cms/version-diff.ts +89 -0
- package/src/forms/conditions.ts +350 -0
- package/src/forms/field-sets.ts +590 -0
- package/src/forms/form-audit.ts +202 -0
- package/src/forms/rich-text.tsx +76 -0
- package/src/http/cache-policy.ts +110 -0
- package/src/http/compression.ts +122 -0
- package/src/http/response-headers.ts +126 -0
- package/src/i18n/json.ts +7 -0
- package/src/i18n/resolve-locale.ts +14 -0
- package/src/i18n/translation-meta.ts +207 -0
- package/src/media/filename.ts +44 -0
- package/src/media/focus.ts +101 -0
- package/src/media/internal-paths.ts +30 -0
- package/src/media/media-meta.ts +50 -0
- package/src/media/optimize.ts +209 -0
- package/src/media/review-image.ts +107 -0
- package/src/media/srcset.ts +78 -0
- package/src/media/storage.ts +139 -0
- package/src/media/urls.ts +55 -0
- package/src/media/usage-types.ts +24 -0
- package/src/platform/index.ts +90 -0
- package/src/trips/index.ts +27 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the translation "origin" system.
|
|
3
|
+
*
|
|
4
|
+
* Every `Localized<string>` map may carry a sibling `_meta` object:
|
|
5
|
+
*
|
|
6
|
+
* { sv: "Boka nu", en: "Book now",
|
|
7
|
+
* _meta: { en: { origin: "auto", svHash: "…", updatedAt: "…" } } }
|
|
8
|
+
*
|
|
9
|
+
* Rules the auto-translator enforces:
|
|
10
|
+
* - `origin: "manual"` → NEVER overwrite
|
|
11
|
+
* - `origin: "auto"` + svHash matches current sv → up-to-date, skip
|
|
12
|
+
* - `origin: "auto"` + svHash differs → stale, refresh
|
|
13
|
+
* - No _meta but locale value present → treat as MANUAL
|
|
14
|
+
* (someone typed it into the editor, so we don't clobber it)
|
|
15
|
+
* - Locale value missing → fill (auto)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Json } from "./json";
|
|
19
|
+
|
|
20
|
+
export type TranslationOrigin = "auto" | "manual";
|
|
21
|
+
export type LocaleMetaEntry = {
|
|
22
|
+
origin: TranslationOrigin;
|
|
23
|
+
svHash?: string;
|
|
24
|
+
updatedAt?: string;
|
|
25
|
+
};
|
|
26
|
+
export type LocaleMeta = Record<string, LocaleMetaEntry>;
|
|
27
|
+
|
|
28
|
+
const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
29
|
+
|
|
30
|
+
/** True when `v` looks like a `{ sv: string, en?: string, _meta?: … }` map. */
|
|
31
|
+
export function isLocaleMap(v: unknown): v is Record<string, unknown> {
|
|
32
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
33
|
+
const keys = Object.keys(v as Record<string, unknown>);
|
|
34
|
+
if (keys.length === 0) return false;
|
|
35
|
+
const localeKeys = keys.filter((k) => k !== "_meta");
|
|
36
|
+
if (localeKeys.length === 0) return false;
|
|
37
|
+
return localeKeys.every((k) => LOCALE_RE.test(k));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Short, deterministic hash of a string. Non-crypto, edge-safe. */
|
|
41
|
+
export function svHash(s: string): string {
|
|
42
|
+
// FNV-1a 64-bit → hex; small, stable, no Web Crypto async cost.
|
|
43
|
+
let h1 = 0x811c9dc5 | 0;
|
|
44
|
+
let h2 = 0xdeadbeef | 0;
|
|
45
|
+
for (let i = 0; i < s.length; i++) {
|
|
46
|
+
const c = s.charCodeAt(i);
|
|
47
|
+
h1 = Math.imul(h1 ^ c, 0x01000193);
|
|
48
|
+
h2 = Math.imul(h2 ^ c, 0x85ebca6b);
|
|
49
|
+
}
|
|
50
|
+
const hex = (n: number) => (n >>> 0).toString(16).padStart(8, "0");
|
|
51
|
+
return hex(h1) + hex(h2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type FieldStatus = "missing" | "stale" | "manual" | "fresh";
|
|
55
|
+
|
|
56
|
+
export function statusFor(map: Record<string, unknown>, target: string): FieldStatus {
|
|
57
|
+
const sv = map.sv;
|
|
58
|
+
if (typeof sv !== "string" || sv.trim().length === 0) return "fresh";
|
|
59
|
+
const meta = (map._meta as LocaleMeta | undefined)?.[target];
|
|
60
|
+
const value = map[target];
|
|
61
|
+
if (typeof value !== "string") return "missing";
|
|
62
|
+
if (!meta) return "manual"; // pre-existing value with no provenance = human
|
|
63
|
+
if (meta.origin === "manual") return "manual";
|
|
64
|
+
if (meta.origin === "auto" && meta.svHash === svHash(sv)) return "fresh";
|
|
65
|
+
return "stale";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Walk any JSON value and detect locale maps that need translating,
|
|
70
|
+
* based on `mode`:
|
|
71
|
+
* - "missing" → only fill absent locale values
|
|
72
|
+
* - "stale" → also refresh auto entries whose svHash no longer matches
|
|
73
|
+
* - "force" → refresh every auto entry (still preserves manual)
|
|
74
|
+
*/
|
|
75
|
+
export function collectMissing(
|
|
76
|
+
value: unknown,
|
|
77
|
+
target: string,
|
|
78
|
+
mode: "missing" | "stale" | "force",
|
|
79
|
+
): {
|
|
80
|
+
needs: string[];
|
|
81
|
+
skippedManual: number;
|
|
82
|
+
patch: (m: Map<string, string>) => Json;
|
|
83
|
+
} {
|
|
84
|
+
const needs = new Set<string>();
|
|
85
|
+
let skippedManual = 0;
|
|
86
|
+
|
|
87
|
+
function shouldTranslate(status: FieldStatus): boolean {
|
|
88
|
+
if (status === "manual") return false;
|
|
89
|
+
if (status === "missing") return true;
|
|
90
|
+
if (status === "stale") return mode === "stale" || mode === "force";
|
|
91
|
+
// fresh
|
|
92
|
+
return mode === "force";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scan(v: unknown): void {
|
|
96
|
+
if (Array.isArray(v)) return void v.forEach(scan);
|
|
97
|
+
if (isLocaleMap(v)) {
|
|
98
|
+
const status = statusFor(v, target);
|
|
99
|
+
if (status === "manual") skippedManual += 1;
|
|
100
|
+
const sv = v.sv;
|
|
101
|
+
if (typeof sv === "string" && sv.trim().length > 0 && shouldTranslate(status)) {
|
|
102
|
+
needs.add(sv);
|
|
103
|
+
}
|
|
104
|
+
for (const [k, leaf] of Object.entries(v)) {
|
|
105
|
+
if (k !== "_meta" && typeof leaf !== "string") scan(leaf);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (v && typeof v === "object") {
|
|
110
|
+
for (const leaf of Object.values(v as Record<string, unknown>)) scan(leaf);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
scan(value);
|
|
114
|
+
|
|
115
|
+
function patch(m: Map<string, string>): Json {
|
|
116
|
+
const nowIso = new Date().toISOString();
|
|
117
|
+
function apply(v: unknown): unknown {
|
|
118
|
+
if (Array.isArray(v)) return v.map(apply);
|
|
119
|
+
if (isLocaleMap(v)) {
|
|
120
|
+
const out: Record<string, unknown> = { ...v };
|
|
121
|
+
const sv = v.sv;
|
|
122
|
+
const status = statusFor(v, target);
|
|
123
|
+
if (
|
|
124
|
+
typeof sv === "string" &&
|
|
125
|
+
sv.trim().length > 0 &&
|
|
126
|
+
m.has(sv) &&
|
|
127
|
+
shouldTranslate(status)
|
|
128
|
+
) {
|
|
129
|
+
out[target] = m.get(sv);
|
|
130
|
+
const meta = { ...((out._meta as LocaleMeta | undefined) ?? {}) };
|
|
131
|
+
meta[target] = {
|
|
132
|
+
origin: "auto",
|
|
133
|
+
svHash: svHash(sv),
|
|
134
|
+
updatedAt: nowIso,
|
|
135
|
+
};
|
|
136
|
+
out._meta = meta;
|
|
137
|
+
}
|
|
138
|
+
for (const [k, leaf] of Object.entries(v)) {
|
|
139
|
+
if (k !== "_meta" && typeof leaf !== "string") out[k] = apply(leaf);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
if (v && typeof v === "object") {
|
|
144
|
+
const out: Record<string, unknown> = {};
|
|
145
|
+
for (const [k, leaf] of Object.entries(v as Record<string, unknown>)) {
|
|
146
|
+
out[k] = apply(leaf);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
return v;
|
|
151
|
+
}
|
|
152
|
+
return apply(value) as Json;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { needs: [...needs], skippedManual, patch };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* After a manual save, mark any locale value that changed vs `prev`
|
|
160
|
+
* as `origin: "manual"` unless the incoming payload already carries
|
|
161
|
+
* explicit `_meta` for that locale. `sv` edits leave `_meta` untouched
|
|
162
|
+
* so staleness detection keeps working.
|
|
163
|
+
*/
|
|
164
|
+
export function stampManualEdits(next: unknown, prev: unknown): Json {
|
|
165
|
+
function walk(n: unknown, p: unknown): unknown {
|
|
166
|
+
if (Array.isArray(n)) {
|
|
167
|
+
const pa = Array.isArray(p) ? p : [];
|
|
168
|
+
return n.map((el, i) => walk(el, pa[i]));
|
|
169
|
+
}
|
|
170
|
+
if (isLocaleMap(n)) {
|
|
171
|
+
const out: Record<string, unknown> = { ...n };
|
|
172
|
+
const prevMap = isLocaleMap(p) ? (p as Record<string, unknown>) : {};
|
|
173
|
+
const incomingMeta = (n._meta as LocaleMeta | undefined) ?? {};
|
|
174
|
+
const prevMeta = (prevMap._meta as LocaleMeta | undefined) ?? {};
|
|
175
|
+
const meta: LocaleMeta = { ...prevMeta, ...incomingMeta };
|
|
176
|
+
const nowIso = new Date().toISOString();
|
|
177
|
+
for (const [k, v] of Object.entries(n)) {
|
|
178
|
+
if (k === "_meta" || k === "sv" || typeof v !== "string") continue;
|
|
179
|
+
const prevVal = prevMap[k];
|
|
180
|
+
const changed = prevVal !== v;
|
|
181
|
+
const explicit = k in incomingMeta;
|
|
182
|
+
if (changed && !explicit) {
|
|
183
|
+
meta[k] = { origin: "manual", updatedAt: nowIso };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (Object.keys(meta).length > 0) out._meta = meta;
|
|
187
|
+
for (const [k, leaf] of Object.entries(n)) {
|
|
188
|
+
if (k !== "_meta" && typeof leaf !== "string") {
|
|
189
|
+
out[k] = walk(leaf, (prevMap as Record<string, unknown>)[k]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
if (n && typeof n === "object") {
|
|
195
|
+
const out: Record<string, unknown> = {};
|
|
196
|
+
const po = (
|
|
197
|
+
p && typeof p === "object" && !Array.isArray(p) ? (p as Record<string, unknown>) : {}
|
|
198
|
+
) as Record<string, unknown>;
|
|
199
|
+
for (const [k, leaf] of Object.entries(n as Record<string, unknown>)) {
|
|
200
|
+
out[k] = walk(leaf, po[k]);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
return n;
|
|
205
|
+
}
|
|
206
|
+
return walk(next, prev) as Json;
|
|
207
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media filenames are SEO-relevant, so uploads keep the original, human-readable
|
|
3
|
+
* name — never a timestamp or random prefix. Collisions get a `-2`, `-3`, … suffix.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function splitExt(name: string) {
|
|
7
|
+
const dot = name.lastIndexOf(".");
|
|
8
|
+
if (dot <= 0) return { base: name, ext: "" };
|
|
9
|
+
return { base: name.slice(0, dot), ext: name.slice(dot).toLowerCase() };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Slugify a filename while preserving its readable words and extension. */
|
|
13
|
+
export function cleanFileName(name: string, fallback = "file") {
|
|
14
|
+
const { base, ext } = splitExt(name);
|
|
15
|
+
const cleaned = base
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.normalize("NFD")
|
|
18
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "")
|
|
21
|
+
// Strip legacy timestamp/random prefixes such as `1785305764867-name`.
|
|
22
|
+
.replace(/^\d{10,16}-(?=[a-z0-9])/, "")
|
|
23
|
+
.replace(/^-+|-+$/g, "")
|
|
24
|
+
.slice(0, 80);
|
|
25
|
+
return `${cleaned || fallback}${ext}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return `name` if free, otherwise `name-2`, `name-3`, … using `taken`
|
|
30
|
+
* (a set of existing filenames, or a predicate).
|
|
31
|
+
*/
|
|
32
|
+
export function uniqueFileName(
|
|
33
|
+
name: string,
|
|
34
|
+
taken: Set<string> | ((candidate: string) => boolean),
|
|
35
|
+
) {
|
|
36
|
+
const isTaken = typeof taken === "function" ? taken : (c: string) => taken.has(c);
|
|
37
|
+
if (!isTaken(name)) return name;
|
|
38
|
+
const { base, ext } = splitExt(name);
|
|
39
|
+
for (let i = 2; i < 1000; i++) {
|
|
40
|
+
const candidate = `${base}-${i}${ext}`;
|
|
41
|
+
if (!isTaken(candidate)) return candidate;
|
|
42
|
+
}
|
|
43
|
+
return `${base}-${Math.floor(Math.random() * 1e6)}${ext}`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image focus points ("focal point crop").
|
|
3
|
+
*
|
|
4
|
+
* Every cropped image on the site uses `object-fit: cover`, which defaults to a
|
|
5
|
+
* centred crop. A focus point lets an editor pick which part of the image must
|
|
6
|
+
* stay visible; we translate it into `object-position`.
|
|
7
|
+
*
|
|
8
|
+
* Focus is stored per media asset (not per placement), keyed by a canonical
|
|
9
|
+
* path so all responsive variants (`-480/-768/-1200/-1920.webp`) of the same
|
|
10
|
+
* upload share one setting.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type FocalPoint = { x: number; y: number };
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_FOCAL: FocalPoint = { x: 50, y: 50 };
|
|
16
|
+
|
|
17
|
+
/** Stored focus for one asset: desktop point plus an optional mobile override. */
|
|
18
|
+
export type FocalEntry = FocalPoint & { mobile?: FocalPoint | null };
|
|
19
|
+
|
|
20
|
+
export type FocusDevice = "desktop" | "mobile";
|
|
21
|
+
|
|
22
|
+
export type FocusMap = Record<string, FocalEntry>;
|
|
23
|
+
|
|
24
|
+
const VARIANT_RE = /-(480|768|1200|1920)\.webp$/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonical storage key for any media URL or storage path.
|
|
28
|
+
* - Strips origin / `storage/v1/object/public/media/` prefix and query strings.
|
|
29
|
+
* - Collapses responsive variants onto a single key.
|
|
30
|
+
* - Non-library URLs (external assets) keep their full URL as the key.
|
|
31
|
+
*/
|
|
32
|
+
export function focusKey(input: string | undefined | null): string {
|
|
33
|
+
if (!input) return "";
|
|
34
|
+
let value = String(input).trim();
|
|
35
|
+
if (!value) return "";
|
|
36
|
+
|
|
37
|
+
// Drop query / hash.
|
|
38
|
+
value = value.split("#")[0].split("?")[0];
|
|
39
|
+
|
|
40
|
+
const marker = "/storage/v1/object/public/media/";
|
|
41
|
+
const at = value.indexOf(marker);
|
|
42
|
+
if (at >= 0) {
|
|
43
|
+
value = value.slice(at + marker.length);
|
|
44
|
+
} else if (/^https?:\/\//i.test(value)) {
|
|
45
|
+
// External URL — key on the whole thing (still collapse variants below).
|
|
46
|
+
return value.replace(VARIANT_RE, ".webp");
|
|
47
|
+
} else {
|
|
48
|
+
value = value.replace(/^\/+/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
value = decodeURIComponent(value);
|
|
53
|
+
} catch {
|
|
54
|
+
/* keep raw */
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return value.replace(VARIANT_RE, ".webp");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function clampFocal(point: Partial<FocalPoint> | undefined): FocalPoint {
|
|
61
|
+
const clamp = (n: number | undefined, fallback: number) =>
|
|
62
|
+
Math.min(100, Math.max(0, Math.round(Number.isFinite(n as number) ? (n as number) : fallback)));
|
|
63
|
+
return { x: clamp(point?.x, 50), y: clamp(point?.y, 50) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function focalToObjectPosition(point: Partial<FocalPoint> | undefined): string {
|
|
67
|
+
const p = clampFocal(point);
|
|
68
|
+
return `${p.x}% ${p.y}%`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function lookupEntry(map: FocusMap | undefined, src: string | undefined): FocalEntry {
|
|
72
|
+
if (!map || !src) return { ...DEFAULT_FOCAL };
|
|
73
|
+
return map[focusKey(src)] ?? { ...DEFAULT_FOCAL };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function lookupFocal(
|
|
77
|
+
map: FocusMap | undefined,
|
|
78
|
+
src: string | undefined,
|
|
79
|
+
device: FocusDevice = "desktop",
|
|
80
|
+
): FocalPoint {
|
|
81
|
+
const entry = lookupEntry(map, src);
|
|
82
|
+
if (device === "mobile") return clampFocal(entry.mobile ?? entry);
|
|
83
|
+
return clampFocal(entry);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Inline CSS custom properties for a focus entry. Combine with the
|
|
88
|
+
* `focal-pos` class so mobile viewports pick up the mobile override.
|
|
89
|
+
*/
|
|
90
|
+
export function focalCssVars(entry: FocalEntry | undefined): Record<string, string> {
|
|
91
|
+
const vars: Record<string, string> = {
|
|
92
|
+
"--focal": focalToObjectPosition(entry),
|
|
93
|
+
};
|
|
94
|
+
if (entry?.mobile) vars["--focal-mobile"] = focalToObjectPosition(entry.mobile);
|
|
95
|
+
return vars;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isDefaultFocal(point: Partial<FocalPoint> | undefined): boolean {
|
|
99
|
+
const p = clampFocal(point);
|
|
100
|
+
return p.x === 50 && p.y === 50;
|
|
101
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reserved, internal paths inside the media volume.
|
|
3
|
+
*
|
|
4
|
+
* Migration state (staging directories and the activation journal) lives
|
|
5
|
+
* INSIDE the persistent media volume so that:
|
|
6
|
+
* - staging and destination share one filesystem (atomic rename works),
|
|
7
|
+
* - the journal and staged files survive container replacement,
|
|
8
|
+
* - `resume-media` can recover after a deployment restart.
|
|
9
|
+
*
|
|
10
|
+
* Because these files sit next to real media, every path that lists, serves,
|
|
11
|
+
* exports, cleans or scans media must skip them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Directory (relative to MEDIA_DIR) holding all migration state. */
|
|
15
|
+
export const MIGRATION_DIR = ".migration";
|
|
16
|
+
|
|
17
|
+
/** Journal file name inside MIGRATION_DIR. */
|
|
18
|
+
export const MEDIA_ACTIVATION_JOURNAL = "media-activation.json";
|
|
19
|
+
|
|
20
|
+
/** Any path segment starting with one of these is internal, never user media. */
|
|
21
|
+
export const RESERVED_SEGMENT_PREFIXES = [".migration", ".restore-", ".staging-"] as const;
|
|
22
|
+
|
|
23
|
+
/** Relative media path (POSIX style) that must never be treated as user media. */
|
|
24
|
+
export function isInternalMediaPath(relPath: string): boolean {
|
|
25
|
+
const normalized = relPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
26
|
+
if (!normalized) return false;
|
|
27
|
+
return normalized
|
|
28
|
+
.split("/")
|
|
29
|
+
.some((seg) => RESERVED_SEGMENT_PREFIXES.some((p) => seg === p || seg.startsWith(p)));
|
|
30
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-asset SEO metadata for the Media Library.
|
|
3
|
+
*
|
|
4
|
+
* Alt text, title and description are localized maps (`{ sv: "…" }`) so every
|
|
5
|
+
* language can carry its own copy — the same rule as all site content.
|
|
6
|
+
* Metadata is keyed by the same canonical path used for focus points, so all
|
|
7
|
+
* responsive variants of one upload share a single record.
|
|
8
|
+
*/
|
|
9
|
+
import { focusKey } from "./focus";
|
|
10
|
+
|
|
11
|
+
export type LocalizedText = Record<string, string>;
|
|
12
|
+
|
|
13
|
+
export type MediaMeta = {
|
|
14
|
+
alt: LocalizedText;
|
|
15
|
+
title: LocalizedText;
|
|
16
|
+
description: LocalizedText;
|
|
17
|
+
credit: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MediaMetaMap = Record<string, MediaMeta>;
|
|
21
|
+
|
|
22
|
+
export const EMPTY_MEDIA_META: MediaMeta = {
|
|
23
|
+
alt: {},
|
|
24
|
+
title: {},
|
|
25
|
+
description: {},
|
|
26
|
+
credit: "",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Same canonical key as focus points (variant-collapsed storage path). */
|
|
30
|
+
export const metaKey = focusKey;
|
|
31
|
+
|
|
32
|
+
export function lookupMediaMeta(map: MediaMetaMap | undefined, src: string | undefined): MediaMeta {
|
|
33
|
+
if (!map || !src) return { ...EMPTY_MEDIA_META };
|
|
34
|
+
return map[metaKey(src)] ?? { ...EMPTY_MEDIA_META };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hasMediaMeta(meta: MediaMeta | undefined): boolean {
|
|
38
|
+
if (!meta) return false;
|
|
39
|
+
const any = (t: LocalizedText) => Object.values(t ?? {}).some((v) => v?.trim());
|
|
40
|
+
return any(meta.alt) || any(meta.title) || any(meta.description) || Boolean(meta.credit?.trim());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Strip empty strings so we never store blank locale keys. */
|
|
44
|
+
export function cleanLocalized(text: LocalizedText | undefined): LocalizedText {
|
|
45
|
+
const out: LocalizedText = {};
|
|
46
|
+
for (const [k, v] of Object.entries(text ?? {})) {
|
|
47
|
+
if (typeof v === "string" && v.trim()) out[k] = v.trim();
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side media optimization run at upload time in the backoffice.
|
|
3
|
+
* - Images → WebP variants, quality 0.90, max width 2560 (preserves aspect).
|
|
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.
|
|
7
|
+
* - Videos → original file + a JPG poster grabbed from ~10% into the clip.
|
|
8
|
+
* - Everything else → passthrough.
|
|
9
|
+
*
|
|
10
|
+
* Runs only in the browser (uses Canvas / <video>). Do NOT import from server code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type OptimizedFile = {
|
|
14
|
+
name: string;
|
|
15
|
+
blob: Blob;
|
|
16
|
+
contentType: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type OptimizedBundle = {
|
|
20
|
+
primary: OptimizedFile;
|
|
21
|
+
/** Optional sibling files uploaded alongside (e.g. video poster, PNG fallback). */
|
|
22
|
+
extras: OptimizedFile[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const RESPONSIVE_WIDTHS = [480, 768, 1200, 1920, 2560] as const;
|
|
26
|
+
const IMAGE_QUALITY = 0.9;
|
|
27
|
+
const OPTIMIZABLE_IMAGE = /^image\/(jpeg|jpg|png|webp|avif|bmp|tiff?)$/i;
|
|
28
|
+
|
|
29
|
+
function extOf(name: string): string {
|
|
30
|
+
const i = name.lastIndexOf(".");
|
|
31
|
+
return i > 0 ? name.slice(i).toLowerCase() : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stripExt(name: string): string {
|
|
35
|
+
const i = name.lastIndexOf(".");
|
|
36
|
+
return i > 0 ? name.slice(0, i) : name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type OptimizeOptions = {
|
|
40
|
+
/** Timestamp (seconds) to grab the video poster from. Defaults to ~10% in. */
|
|
41
|
+
posterTime?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** True when the decoded image contains any non-opaque pixel. */
|
|
45
|
+
async function hasTransparency(bmp: ImageBitmap): Promise<boolean> {
|
|
46
|
+
const canvas = document.createElement("canvas");
|
|
47
|
+
// Sample at a reduced resolution — enough to catch typical rounded corners,
|
|
48
|
+
// logos and cut-out subjects without reading millions of pixels.
|
|
49
|
+
const w = Math.min(bmp.width, 128);
|
|
50
|
+
const h = Math.min(bmp.height, 128);
|
|
51
|
+
canvas.width = w;
|
|
52
|
+
canvas.height = h;
|
|
53
|
+
const ctx = canvas.getContext("2d", { alpha: true });
|
|
54
|
+
if (!ctx) return false;
|
|
55
|
+
ctx.drawImage(bmp, 0, 0, w, h);
|
|
56
|
+
const data = ctx.getImageData(0, 0, w, h).data;
|
|
57
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
58
|
+
if (data[i] < 255) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function optimizeForUpload(
|
|
64
|
+
file: File,
|
|
65
|
+
slug: string,
|
|
66
|
+
options: OptimizeOptions = {},
|
|
67
|
+
): Promise<OptimizedBundle> {
|
|
68
|
+
// Images: transcode to WebP at 5 widths for srcset (480 / 768 / 1200 / 1920 / 2560).
|
|
69
|
+
if (OPTIMIZABLE_IMAGE.test(file.type)) {
|
|
70
|
+
try {
|
|
71
|
+
const base = stripExt(slug);
|
|
72
|
+
const bmp = await createImageBitmap(file);
|
|
73
|
+
try {
|
|
74
|
+
const transparent = await hasTransparency(bmp);
|
|
75
|
+
const files: OptimizedFile[] = [];
|
|
76
|
+
let largestW = 1;
|
|
77
|
+
let largestH = 1;
|
|
78
|
+
for (const targetW of RESPONSIVE_WIDTHS) {
|
|
79
|
+
// Every width is always written, even for small sources: the image is
|
|
80
|
+
// never upscaled (the file just repeats the capped size), but the full
|
|
81
|
+
// set of variant URLs is guaranteed to exist, so a responsive srcSet
|
|
82
|
+
// can never point at a file that was skipped at upload time.
|
|
83
|
+
const scale = Math.min(1, targetW / bmp.width);
|
|
84
|
+
const w = Math.max(1, Math.round(bmp.width * scale));
|
|
85
|
+
const h = Math.max(1, Math.round(bmp.height * scale));
|
|
86
|
+
largestW = w;
|
|
87
|
+
largestH = h;
|
|
88
|
+
// The fresh canvas starts fully transparent, so drawing an image with
|
|
89
|
+
// alpha preserves its transparency rather than compositing onto a
|
|
90
|
+
// solid background. WebP encoding keeps the alpha channel.
|
|
91
|
+
const blob = await encodeWebpAt(bmp, w, h);
|
|
92
|
+
files.push({ name: `${base}-${targetW}.webp`, blob, contentType: "image/webp" });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Primary = largest variant so its URL can derive the srcset via `-2560.webp` suffix.
|
|
96
|
+
const primary = files.find((f) => f.name.endsWith("-2560.webp")) ?? files[files.length - 1];
|
|
97
|
+
const extras = files.filter((f) => f !== primary);
|
|
98
|
+
|
|
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.
|
|
102
|
+
if (transparent) {
|
|
103
|
+
const fallback = await encodePngAt(bmp, largestW, largestH);
|
|
104
|
+
extras.push({ name: `${base}-fallback.png`, blob: fallback, contentType: "image/png" });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { primary, extras };
|
|
108
|
+
} finally {
|
|
109
|
+
bmp.close?.();
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Fallback: upload original untouched.
|
|
113
|
+
return {
|
|
114
|
+
primary: { name: `${slug}${extOf(file.name)}`, blob: file, contentType: file.type },
|
|
115
|
+
extras: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Videos: keep original, generate poster.
|
|
120
|
+
if (file.type.startsWith("video/")) {
|
|
121
|
+
const primary: OptimizedFile = {
|
|
122
|
+
name: `${slug}${extOf(file.name)}`,
|
|
123
|
+
blob: file,
|
|
124
|
+
contentType: file.type,
|
|
125
|
+
};
|
|
126
|
+
const poster = await extractVideoPoster(file, options.posterTime).catch(() => null);
|
|
127
|
+
return {
|
|
128
|
+
primary,
|
|
129
|
+
extras: poster
|
|
130
|
+
? [{ name: `${stripExt(slug)}-poster.jpg`, blob: poster, contentType: "image/jpeg" }]
|
|
131
|
+
: [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Everything else: passthrough.
|
|
136
|
+
return {
|
|
137
|
+
primary: {
|
|
138
|
+
name: `${slug}${extOf(file.name)}`,
|
|
139
|
+
blob: file,
|
|
140
|
+
contentType: file.type || "application/octet-stream",
|
|
141
|
+
},
|
|
142
|
+
extras: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async function encodeWebpAt(bmp: ImageBitmap, w: number, h: number): Promise<Blob> {
|
|
146
|
+
const canvas = document.createElement("canvas");
|
|
147
|
+
canvas.width = w;
|
|
148
|
+
canvas.height = h;
|
|
149
|
+
const ctx = canvas.getContext("2d");
|
|
150
|
+
if (!ctx) throw new Error("2d ctx unavailable");
|
|
151
|
+
ctx.drawImage(bmp, 0, 0, w, h);
|
|
152
|
+
return await new Promise<Blob>((resolve, reject) => {
|
|
153
|
+
canvas.toBlob(
|
|
154
|
+
(b) => (b ? resolve(b) : reject(new Error("webp encode failed"))),
|
|
155
|
+
"image/webp",
|
|
156
|
+
IMAGE_QUALITY,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function encodePngAt(bmp: ImageBitmap, w: number, h: number): Promise<Blob> {
|
|
162
|
+
const canvas = document.createElement("canvas");
|
|
163
|
+
canvas.width = w;
|
|
164
|
+
canvas.height = h;
|
|
165
|
+
const ctx = canvas.getContext("2d", { alpha: true });
|
|
166
|
+
if (!ctx) throw new Error("2d ctx unavailable");
|
|
167
|
+
ctx.drawImage(bmp, 0, 0, w, h);
|
|
168
|
+
return await new Promise<Blob>((resolve, reject) => {
|
|
169
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("png encode failed"))), "image/png");
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function extractVideoPoster(file: File, atSeconds?: number): Promise<Blob | null> {
|
|
174
|
+
const url = URL.createObjectURL(file);
|
|
175
|
+
try {
|
|
176
|
+
const video = document.createElement("video");
|
|
177
|
+
video.src = url;
|
|
178
|
+
video.muted = true;
|
|
179
|
+
video.playsInline = true;
|
|
180
|
+
video.preload = "auto";
|
|
181
|
+
await new Promise<void>((resolve, reject) => {
|
|
182
|
+
video.onloadedmetadata = () => resolve();
|
|
183
|
+
video.onerror = () => reject(new Error("video metadata failed"));
|
|
184
|
+
});
|
|
185
|
+
const duration = video.duration || 1;
|
|
186
|
+
const target =
|
|
187
|
+
typeof atSeconds === "number" && Number.isFinite(atSeconds)
|
|
188
|
+
? Math.max(0, Math.min(atSeconds, Math.max(0, duration - 0.05)))
|
|
189
|
+
: Math.min(1, duration * 0.1);
|
|
190
|
+
await new Promise<void>((resolve, reject) => {
|
|
191
|
+
video.onseeked = () => resolve();
|
|
192
|
+
video.onerror = () => reject(new Error("seek failed"));
|
|
193
|
+
video.currentTime = target;
|
|
194
|
+
});
|
|
195
|
+
const w = video.videoWidth || 1280;
|
|
196
|
+
const h = video.videoHeight || 720;
|
|
197
|
+
const canvas = document.createElement("canvas");
|
|
198
|
+
canvas.width = w;
|
|
199
|
+
canvas.height = h;
|
|
200
|
+
const ctx = canvas.getContext("2d");
|
|
201
|
+
if (!ctx) return null;
|
|
202
|
+
ctx.drawImage(video, 0, 0, w, h);
|
|
203
|
+
return await new Promise<Blob | null>((resolve) =>
|
|
204
|
+
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.82),
|
|
205
|
+
);
|
|
206
|
+
} finally {
|
|
207
|
+
URL.revokeObjectURL(url);
|
|
208
|
+
}
|
|
209
|
+
}
|