@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,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conditional logic + validation engine for managed forms.
|
|
3
|
+
*
|
|
4
|
+
* Pure and isomorphic on purpose: the exact same rules run in the browser
|
|
5
|
+
* (live show/hide + inline errors) and on the server (authoritative check),
|
|
6
|
+
* so a form can never be bypassed by disabling JavaScript.
|
|
7
|
+
*/
|
|
8
|
+
import type { LocalizedString } from "../i18n";
|
|
9
|
+
import type { ConditionRule, FieldCondition, FormFieldDef } from "./field-sets";
|
|
10
|
+
|
|
11
|
+
/* ------------------------------- conditions ------------------------------- */
|
|
12
|
+
|
|
13
|
+
export const CONDITION_OPERATORS: {
|
|
14
|
+
value: FieldCondition["op"];
|
|
15
|
+
label: string;
|
|
16
|
+
/** Operators that do not need a comparison value. */
|
|
17
|
+
unary?: boolean;
|
|
18
|
+
}[] = [
|
|
19
|
+
{ value: "equals", label: "is" },
|
|
20
|
+
{ value: "not_equals", label: "is not" },
|
|
21
|
+
{ value: "contains", label: "contains" },
|
|
22
|
+
{ value: "not_contains", label: "does not contain" },
|
|
23
|
+
{ value: "starts_with", label: "starts with" },
|
|
24
|
+
{ value: "gt", label: "is greater than" },
|
|
25
|
+
{ value: "lt", label: "is less than" },
|
|
26
|
+
{ value: "is_empty", label: "is empty", unary: true },
|
|
27
|
+
{ value: "is_not_empty", label: "is answered", unary: true },
|
|
28
|
+
{ value: "checked", label: "is checked", unary: true },
|
|
29
|
+
{ value: "unchecked", label: "is not checked", unary: true },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const UNARY = new Set(CONDITION_OPERATORS.filter((o) => o.unary).map((o) => o.value));
|
|
33
|
+
|
|
34
|
+
export const isUnaryOperator = (op: FieldCondition["op"]) => UNARY.has(op);
|
|
35
|
+
|
|
36
|
+
const isTruthyCheckbox = (v: string) => ["true", "1", "on", "yes"].includes(v.trim().toLowerCase());
|
|
37
|
+
|
|
38
|
+
const num = (v: string) => Number(String(v).replace(",", ".").trim());
|
|
39
|
+
|
|
40
|
+
function testCondition(c: FieldCondition, values: Record<string, string>): boolean {
|
|
41
|
+
const raw = (values[c.field] ?? "").toString();
|
|
42
|
+
const a = raw.trim().toLowerCase();
|
|
43
|
+
const b = (c.value ?? "").toString().trim().toLowerCase();
|
|
44
|
+
switch (c.op) {
|
|
45
|
+
case "equals":
|
|
46
|
+
return a === b;
|
|
47
|
+
case "not_equals":
|
|
48
|
+
return a !== b;
|
|
49
|
+
case "contains":
|
|
50
|
+
return b !== "" && a.includes(b);
|
|
51
|
+
case "not_contains":
|
|
52
|
+
return b === "" || !a.includes(b);
|
|
53
|
+
case "starts_with":
|
|
54
|
+
return b !== "" && a.startsWith(b);
|
|
55
|
+
case "gt":
|
|
56
|
+
return Number.isFinite(num(raw)) && Number.isFinite(num(b)) && num(raw) > num(b);
|
|
57
|
+
case "lt":
|
|
58
|
+
return Number.isFinite(num(raw)) && Number.isFinite(num(b)) && num(raw) < num(b);
|
|
59
|
+
case "is_empty":
|
|
60
|
+
return a === "";
|
|
61
|
+
case "is_not_empty":
|
|
62
|
+
return a !== "";
|
|
63
|
+
case "checked":
|
|
64
|
+
return isTruthyCheckbox(raw);
|
|
65
|
+
case "unchecked":
|
|
66
|
+
return !isTruthyCheckbox(raw);
|
|
67
|
+
default:
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An empty / missing rule always passes. */
|
|
73
|
+
export function evaluateRule(
|
|
74
|
+
rule: ConditionRule | undefined | null,
|
|
75
|
+
values: Record<string, string>,
|
|
76
|
+
): boolean {
|
|
77
|
+
const list = (rule?.conditions ?? []).filter((c) => c && c.field);
|
|
78
|
+
if (list.length === 0) return true;
|
|
79
|
+
return rule?.mode === "any"
|
|
80
|
+
? list.some((c) => testCondition(c, values))
|
|
81
|
+
: list.every((c) => testCondition(c, values));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const hasRule = (rule: ConditionRule | undefined | null) =>
|
|
85
|
+
(rule?.conditions ?? []).filter((c) => c && c.field).length > 0;
|
|
86
|
+
|
|
87
|
+
/** A field renders when it is enabled and its show-rule passes. */
|
|
88
|
+
export function isFieldVisible(f: FormFieldDef, values: Record<string, string>): boolean {
|
|
89
|
+
if (!f.enabled) return false;
|
|
90
|
+
return evaluateRule(f.showWhen, values);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Required either always, or because a require-rule matched. */
|
|
94
|
+
export function isFieldRequired(f: FormFieldDef, values: Record<string, string>): boolean {
|
|
95
|
+
if (!isFieldVisible(f, values)) return false;
|
|
96
|
+
if (f.required) return true;
|
|
97
|
+
return hasRule(f.requireWhen) ? evaluateRule(f.requireWhen, values) : false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fields that are actually rendered for the current answers. */
|
|
101
|
+
export function visibleFields(
|
|
102
|
+
fields: FormFieldDef[],
|
|
103
|
+
values: Record<string, string>,
|
|
104
|
+
): FormFieldDef[] {
|
|
105
|
+
return fields.filter((f) => isFieldVisible(f, values));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ------------------------------- validation ------------------------------- */
|
|
109
|
+
|
|
110
|
+
export type FieldFormat =
|
|
111
|
+
| "none"
|
|
112
|
+
| "email"
|
|
113
|
+
| "phone"
|
|
114
|
+
| "phone_se"
|
|
115
|
+
| "url"
|
|
116
|
+
| "digits"
|
|
117
|
+
| "letters"
|
|
118
|
+
| "alphanumeric"
|
|
119
|
+
| "postal_se"
|
|
120
|
+
| "orgnr_se"
|
|
121
|
+
| "custom";
|
|
122
|
+
|
|
123
|
+
export const FIELD_FORMATS: { value: FieldFormat; label: string }[] = [
|
|
124
|
+
{ value: "none", label: "No extra format check" },
|
|
125
|
+
{ value: "email", label: "Email address" },
|
|
126
|
+
{ value: "phone", label: "Phone (international)" },
|
|
127
|
+
{ value: "phone_se", label: "Phone (Swedish)" },
|
|
128
|
+
{ value: "url", label: "Web address" },
|
|
129
|
+
{ value: "digits", label: "Digits only" },
|
|
130
|
+
{ value: "letters", label: "Letters only" },
|
|
131
|
+
{ value: "alphanumeric", label: "Letters and digits" },
|
|
132
|
+
{ value: "postal_se", label: "Swedish postcode" },
|
|
133
|
+
{ value: "orgnr_se", label: "Swedish org. number" },
|
|
134
|
+
{ value: "custom", label: "Custom pattern (regex)" },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
export const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/;
|
|
138
|
+
|
|
139
|
+
const FORMAT_RE: Record<Exclude<FieldFormat, "none" | "custom">, RegExp> = {
|
|
140
|
+
email: EMAIL_RE,
|
|
141
|
+
phone: /^\+?[0-9][0-9()\s./-]{4,29}$/,
|
|
142
|
+
phone_se: /^(\+46|0)[\s-]?7?[0-9][0-9\s-]{5,13}$/,
|
|
143
|
+
url: /^https?:\/\/[^\s.]+\.\S{2,}$/i,
|
|
144
|
+
digits: /^[0-9]+$/,
|
|
145
|
+
letters: /^[\p{L}\s'-]+$/u,
|
|
146
|
+
alphanumeric: /^[\p{L}0-9\s'-]+$/u,
|
|
147
|
+
postal_se: /^[0-9]{3}\s?[0-9]{2}$/,
|
|
148
|
+
orgnr_se: /^[0-9]{6}-?[0-9]{4}$/,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
type Msgs = {
|
|
152
|
+
required: string;
|
|
153
|
+
requiredCheck: string;
|
|
154
|
+
min: string;
|
|
155
|
+
max: string;
|
|
156
|
+
minLength: string;
|
|
157
|
+
maxLength: string;
|
|
158
|
+
number: string;
|
|
159
|
+
date: string;
|
|
160
|
+
time: string;
|
|
161
|
+
choice: string;
|
|
162
|
+
format: Record<Exclude<FieldFormat, "none" | "custom">, string> & { custom: string };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const SV: Msgs = {
|
|
166
|
+
required: '"{label}" måste fyllas i.',
|
|
167
|
+
requiredCheck: '"{label}" måste kryssas i.',
|
|
168
|
+
min: '"{label}" måste vara minst {n}.',
|
|
169
|
+
max: '"{label}" får vara högst {n}.',
|
|
170
|
+
minLength: '"{label}" måste vara minst {n} tecken.',
|
|
171
|
+
maxLength: '"{label}" får vara högst {n} tecken.',
|
|
172
|
+
number: '"{label}" måste vara ett tal.',
|
|
173
|
+
date: '"{label}" måste vara ett giltigt datum.',
|
|
174
|
+
time: '"{label}" måste vara en giltig tid.',
|
|
175
|
+
choice: '"{label}" har ett ogiltigt val.',
|
|
176
|
+
format: {
|
|
177
|
+
email: '"{label}" måste vara en giltig e-postadress.',
|
|
178
|
+
phone: '"{label}" måste vara ett giltigt telefonnummer.',
|
|
179
|
+
phone_se: '"{label}" måste vara ett giltigt svenskt telefonnummer.',
|
|
180
|
+
url: '"{label}" måste vara en giltig webbadress.',
|
|
181
|
+
digits: '"{label}" får bara innehålla siffror.',
|
|
182
|
+
letters: '"{label}" får bara innehålla bokstäver.',
|
|
183
|
+
alphanumeric: '"{label}" får bara innehålla bokstäver och siffror.',
|
|
184
|
+
postal_se: '"{label}" måste vara ett giltigt postnummer, t.ex. 123 45.',
|
|
185
|
+
orgnr_se: '"{label}" måste vara ett giltigt organisationsnummer.',
|
|
186
|
+
custom: '"{label}" har fel format.',
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const EN: Msgs = {
|
|
191
|
+
required: '"{label}" is required.',
|
|
192
|
+
requiredCheck: '"{label}" must be checked.',
|
|
193
|
+
min: '"{label}" must be at least {n}.',
|
|
194
|
+
max: '"{label}" must be at most {n}.',
|
|
195
|
+
minLength: '"{label}" must be at least {n} characters.',
|
|
196
|
+
maxLength: '"{label}" must be at most {n} characters.',
|
|
197
|
+
number: '"{label}" must be a number.',
|
|
198
|
+
date: '"{label}" must be a valid date.',
|
|
199
|
+
time: '"{label}" must be a valid time.',
|
|
200
|
+
choice: '"{label}" has an invalid choice.',
|
|
201
|
+
format: {
|
|
202
|
+
email: '"{label}" must be a valid email address.',
|
|
203
|
+
phone: '"{label}" must be a valid phone number.',
|
|
204
|
+
phone_se: '"{label}" must be a valid Swedish phone number.',
|
|
205
|
+
url: '"{label}" must be a valid web address.',
|
|
206
|
+
digits: '"{label}" may only contain digits.',
|
|
207
|
+
letters: '"{label}" may only contain letters.',
|
|
208
|
+
alphanumeric: '"{label}" may only contain letters and digits.',
|
|
209
|
+
postal_se: '"{label}" must be a valid postcode, e.g. 123 45.',
|
|
210
|
+
orgnr_se: '"{label}" must be a valid organisation number.',
|
|
211
|
+
custom: '"{label}" has an invalid format.',
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const msgs = (locale: string): Msgs => (locale.startsWith("en") ? EN : SV);
|
|
216
|
+
|
|
217
|
+
const fill = (tpl: string, label: string, n?: number | string) =>
|
|
218
|
+
tpl.replace(/\{label\}/g, label).replace(/\{n\}/g, String(n ?? ""));
|
|
219
|
+
|
|
220
|
+
export function fieldLabel(f: FormFieldDef, locale = "sv"): string {
|
|
221
|
+
const l = f.label as Partial<LocalizedString>;
|
|
222
|
+
return (l?.[locale as keyof LocalizedString] ?? l?.sv ?? l?.en ?? f.key) as string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function customMessage(f: FormFieldDef, locale: string): string | null {
|
|
226
|
+
const m = f.errorMessage as Partial<LocalizedString> | undefined;
|
|
227
|
+
const v = (m?.[locale as keyof LocalizedString] ?? m?.sv ?? m?.en ?? "") as string;
|
|
228
|
+
return v.trim() ? v.trim() : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Normalise a checkbox answer to a stable stored string. */
|
|
232
|
+
export const normalizeCheckbox = (v: string) => (isTruthyCheckbox(v) ? "true" : "false");
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Validate one field against the whole answer set (so conditional
|
|
236
|
+
* requirements can be resolved). Returns an error message or `null`.
|
|
237
|
+
*/
|
|
238
|
+
export function validateField(
|
|
239
|
+
f: FormFieldDef,
|
|
240
|
+
values: Record<string, string>,
|
|
241
|
+
locale = "sv",
|
|
242
|
+
): string | null {
|
|
243
|
+
if (!isFieldVisible(f, values)) return null;
|
|
244
|
+
|
|
245
|
+
const M = msgs(locale);
|
|
246
|
+
const label = fieldLabel(f, locale);
|
|
247
|
+
const raw = (values[f.key] ?? "").toString();
|
|
248
|
+
const v = raw.trim();
|
|
249
|
+
const required = isFieldRequired(f, values);
|
|
250
|
+
const custom = customMessage(f, locale);
|
|
251
|
+
|
|
252
|
+
if (f.type === "checkbox") {
|
|
253
|
+
if (required && !isTruthyCheckbox(v)) return custom ?? fill(M.requiredCheck, label);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!v) return required ? (custom ?? fill(M.required, label)) : null;
|
|
258
|
+
|
|
259
|
+
switch (f.type) {
|
|
260
|
+
case "number": {
|
|
261
|
+
const n = num(v);
|
|
262
|
+
if (!Number.isFinite(n)) return custom ?? fill(M.number, label);
|
|
263
|
+
if (f.min != null && n < f.min) return custom ?? fill(M.min, label, f.min);
|
|
264
|
+
if (f.max != null && n > f.max) return custom ?? fill(M.max, label, f.max);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "date": {
|
|
268
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v)))
|
|
269
|
+
return custom ?? fill(M.date, label);
|
|
270
|
+
if (f.minDate && v < f.minDate) return custom ?? fill(M.min, label, f.minDate);
|
|
271
|
+
if (f.maxDate && v > f.maxDate) return custom ?? fill(M.max, label, f.maxDate);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case "time":
|
|
275
|
+
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(v)) return custom ?? fill(M.time, label);
|
|
276
|
+
break;
|
|
277
|
+
case "trip":
|
|
278
|
+
case "course":
|
|
279
|
+
// Choices come from the live Courses section; an answer captured before
|
|
280
|
+
// a course changed must never be rejected.
|
|
281
|
+
break;
|
|
282
|
+
case "select": {
|
|
283
|
+
const opts = (f.options ?? []).map((o) => o.value);
|
|
284
|
+
if (opts.length && !opts.includes(raw.trim())) return custom ?? fill(M.choice, label);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "email":
|
|
288
|
+
if (!EMAIL_RE.test(v)) return custom ?? fill(M.format.email, label);
|
|
289
|
+
break;
|
|
290
|
+
case "tel":
|
|
291
|
+
if (!FORMAT_RE.phone.test(v)) return custom ?? fill(M.format.phone, label);
|
|
292
|
+
break;
|
|
293
|
+
case "url":
|
|
294
|
+
if (!FORMAT_RE.url.test(v)) return custom ?? fill(M.format.url, label);
|
|
295
|
+
break;
|
|
296
|
+
default:
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (f.minLength && v.length < f.minLength) return custom ?? fill(M.minLength, label, f.minLength);
|
|
301
|
+
if (f.maxLength && v.length > f.maxLength) return custom ?? fill(M.maxLength, label, f.maxLength);
|
|
302
|
+
|
|
303
|
+
const fmt = (f.format ?? "none") as FieldFormat;
|
|
304
|
+
if (fmt !== "none" && fmt !== "custom") {
|
|
305
|
+
const re = FORMAT_RE[fmt];
|
|
306
|
+
if (re && !re.test(v)) return custom ?? fill(M.format[fmt], label);
|
|
307
|
+
}
|
|
308
|
+
if (f.pattern) {
|
|
309
|
+
let re: RegExp | null = null;
|
|
310
|
+
try {
|
|
311
|
+
re = new RegExp(f.pattern);
|
|
312
|
+
} catch {
|
|
313
|
+
re = null; // an invalid saved pattern must never block a real customer
|
|
314
|
+
}
|
|
315
|
+
if (re && !re.test(v)) return custom ?? fill(M.format.custom, label);
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Validate every field. Returns a map of field key -> error message. */
|
|
321
|
+
export function validateAll(
|
|
322
|
+
fields: FormFieldDef[],
|
|
323
|
+
values: Record<string, string>,
|
|
324
|
+
locale = "sv",
|
|
325
|
+
): Record<string, string> {
|
|
326
|
+
const errors: Record<string, string> = {};
|
|
327
|
+
for (const f of fields) {
|
|
328
|
+
const err = validateField(f, values, locale);
|
|
329
|
+
if (err) errors[f.key] = err;
|
|
330
|
+
}
|
|
331
|
+
return errors;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Clean a privacy policy URL that a human typed or pasted.
|
|
336
|
+
* Strips wrapping brackets/parentheses/quotes and whitespace, and upgrades a
|
|
337
|
+
* bare domain to https:// so the link always resolves.
|
|
338
|
+
*/
|
|
339
|
+
export function normalizePolicyUrl(raw: string | undefined | null): string {
|
|
340
|
+
let v = (raw ?? "").trim();
|
|
341
|
+
for (let i = 0; i < 4; i++) {
|
|
342
|
+
const before = v;
|
|
343
|
+
v = v.replace(/^[([{"'<\s]+/, "").replace(/[)\]}"'>\s]+$/, "");
|
|
344
|
+
if (v === before) break;
|
|
345
|
+
}
|
|
346
|
+
if (!v) return "";
|
|
347
|
+
if (/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(v)) return v;
|
|
348
|
+
if (/^[\w-]+(\.[\w-]+)+(\/|$)/.test(v)) return `https://${v}`;
|
|
349
|
+
return `/${v.replace(/^\/+/, "")}`;
|
|
350
|
+
}
|