@bygghammar/cms-platform 0.10.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.10.0",
3
+ "version": "0.11.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": false,
@@ -89,7 +89,8 @@
89
89
  "./backoffice/media/FocusPicker": "./src/backoffice/media/FocusPicker.tsx",
90
90
  "./backoffice/media/MediaMetaDialog": "./src/backoffice/media/MediaMetaDialog.tsx",
91
91
  "./backoffice/SectionThumb": "./src/backoffice/SectionThumb.tsx",
92
- "./backoffice/PageImagePicker": "./src/backoffice/PageImagePicker.tsx"
92
+ "./backoffice/PageImagePicker": "./src/backoffice/PageImagePicker.tsx",
93
+ "./backoffice/seo/page-seo-form": "./src/backoffice/seo/page-seo-form.ts"
93
94
  },
94
95
  "dependencies": {
95
96
  "fflate": "^0.8.3"
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Host-agnostic form logic for the page SEO editor.
3
+ *
4
+ * Pure functions only — no React, no data access. A host wires these to its
5
+ * own page registry, CMS overrides and save/reset server functions so the SEO
6
+ * screen behaves identically on every site.
7
+ */
8
+
9
+ export type LocalizedField = Record<string, string>;
10
+
11
+ export type PageSeoFormShape = {
12
+ slugs: LocalizedField;
13
+ seo: {
14
+ title: LocalizedField;
15
+ description: LocalizedField;
16
+ ogTitle: LocalizedField;
17
+ ogDescription: LocalizedField;
18
+ keywords: LocalizedField;
19
+ ogImageSrc: string;
20
+ ogImageAlt: LocalizedField;
21
+ robots: string;
22
+ };
23
+ };
24
+
25
+ /** Shape shared by a registry page definition and a CMS override row. */
26
+ export type PageSeoSource = {
27
+ slug?: Partial<LocalizedField> | null;
28
+ seo?: {
29
+ title?: Partial<LocalizedField> | null;
30
+ description?: Partial<LocalizedField> | null;
31
+ ogTitle?: Partial<LocalizedField> | null;
32
+ ogDescription?: Partial<LocalizedField> | null;
33
+ keywords?: Partial<LocalizedField> | null;
34
+ ogImage?: { src?: string | null; alt?: Partial<LocalizedField> | null } | null;
35
+ robots?: string | null;
36
+ } | null;
37
+ };
38
+
39
+ /** Override rows store slugs under `slugs` rather than `slug`. */
40
+ export type PageSeoOverrideSource = Omit<PageSeoSource, "slug"> & {
41
+ slugs?: Partial<LocalizedField> | null;
42
+ };
43
+
44
+ export type PageSeoSavePayload = {
45
+ pageKey: string;
46
+ slugs: LocalizedField;
47
+ seo: {
48
+ title?: LocalizedField;
49
+ description?: LocalizedField;
50
+ ogTitle?: LocalizedField;
51
+ ogDescription?: LocalizedField;
52
+ keywords?: LocalizedField;
53
+ ogImage?: { src: string; alt: LocalizedField };
54
+ robots?: string;
55
+ };
56
+ };
57
+
58
+ export function emptyLocalized(locales: readonly string[]): LocalizedField {
59
+ const out: LocalizedField = {};
60
+ for (const l of locales) out[l] = "";
61
+ return out;
62
+ }
63
+
64
+ /** Drop empty strings; returns undefined when nothing is left. */
65
+ export function stripEmptyLocalized(obj: LocalizedField): LocalizedField | undefined {
66
+ const out: LocalizedField = {};
67
+ for (const [k, v] of Object.entries(obj)) {
68
+ if (v !== "" && v != null) out[k] = v;
69
+ }
70
+ return Object.keys(out).length ? out : undefined;
71
+ }
72
+
73
+ /**
74
+ * Build the initial form state: registry defaults first, then any CMS override
75
+ * value that is explicitly present (including a deliberate empty string).
76
+ */
77
+ export function buildPageSeoForm(args: {
78
+ locales: readonly string[];
79
+ def?: PageSeoSource | null;
80
+ override?: PageSeoOverrideSource | null;
81
+ }): PageSeoFormShape {
82
+ const { locales, def, override } = args;
83
+ const form: PageSeoFormShape = {
84
+ slugs: emptyLocalized(locales),
85
+ seo: {
86
+ title: emptyLocalized(locales),
87
+ description: emptyLocalized(locales),
88
+ ogTitle: emptyLocalized(locales),
89
+ ogDescription: emptyLocalized(locales),
90
+ keywords: emptyLocalized(locales),
91
+ ogImageSrc: "",
92
+ ogImageAlt: emptyLocalized(locales),
93
+ robots: "",
94
+ },
95
+ };
96
+
97
+ if (def) {
98
+ for (const l of locales) {
99
+ form.slugs[l] = def.slug?.[l] ?? "";
100
+ form.seo.title[l] = def.seo?.title?.[l] ?? "";
101
+ form.seo.description[l] = def.seo?.description?.[l] ?? "";
102
+ form.seo.ogTitle[l] = def.seo?.ogTitle?.[l] ?? "";
103
+ form.seo.ogDescription[l] = def.seo?.ogDescription?.[l] ?? "";
104
+ form.seo.keywords[l] = def.seo?.keywords?.[l] ?? "";
105
+ form.seo.ogImageAlt[l] = def.seo?.ogImage?.alt?.[l] ?? "";
106
+ }
107
+ form.seo.ogImageSrc = def.seo?.ogImage?.src ?? "";
108
+ form.seo.robots = def.seo?.robots ?? "";
109
+ }
110
+
111
+ if (override) {
112
+ for (const l of locales) {
113
+ if (override.slugs?.[l] !== undefined) form.slugs[l] = override.slugs[l] ?? "";
114
+ if (override.seo?.title?.[l] !== undefined) form.seo.title[l] = override.seo.title[l] ?? "";
115
+ if (override.seo?.description?.[l] !== undefined)
116
+ form.seo.description[l] = override.seo.description[l] ?? "";
117
+ if (override.seo?.ogTitle?.[l] !== undefined)
118
+ form.seo.ogTitle[l] = override.seo.ogTitle[l] ?? "";
119
+ if (override.seo?.ogDescription?.[l] !== undefined)
120
+ form.seo.ogDescription[l] = override.seo.ogDescription[l] ?? "";
121
+ if (override.seo?.keywords?.[l] !== undefined)
122
+ form.seo.keywords[l] = override.seo.keywords[l] ?? "";
123
+ if (override.seo?.ogImage?.alt?.[l] !== undefined)
124
+ form.seo.ogImageAlt[l] = override.seo.ogImage.alt[l] ?? "";
125
+ }
126
+ if (override.seo?.ogImage?.src) form.seo.ogImageSrc = override.seo.ogImage.src;
127
+ if (override.seo?.robots !== undefined) form.seo.robots = override.seo.robots ?? "";
128
+ }
129
+
130
+ return form;
131
+ }
132
+
133
+ /** Build the save payload, omitting fields the editor left empty. */
134
+ export function buildPageSeoSavePayload(args: {
135
+ pageKey: string;
136
+ locales: readonly string[];
137
+ form: PageSeoFormShape;
138
+ }): PageSeoSavePayload {
139
+ const { pageKey, locales, form } = args;
140
+ const payload: PageSeoSavePayload = { pageKey, slugs: {}, seo: {} };
141
+
142
+ for (const l of locales) {
143
+ if (form.slugs[l] !== undefined) payload.slugs[l] = form.slugs[l];
144
+ }
145
+
146
+ const title = stripEmptyLocalized(form.seo.title);
147
+ const description = stripEmptyLocalized(form.seo.description);
148
+ const ogTitle = stripEmptyLocalized(form.seo.ogTitle);
149
+ const ogDescription = stripEmptyLocalized(form.seo.ogDescription);
150
+ const keywords = stripEmptyLocalized(form.seo.keywords);
151
+ const ogImageAlt = stripEmptyLocalized(form.seo.ogImageAlt);
152
+
153
+ if (title) payload.seo.title = title;
154
+ if (description) payload.seo.description = description;
155
+ if (ogTitle) payload.seo.ogTitle = ogTitle;
156
+ if (ogDescription) payload.seo.ogDescription = ogDescription;
157
+ if (keywords) payload.seo.keywords = keywords;
158
+ if (form.seo.robots) payload.seo.robots = form.seo.robots;
159
+ if (form.seo.ogImageSrc && ogImageAlt) {
160
+ payload.seo.ogImage = { src: form.seo.ogImageSrc, alt: ogImageAlt };
161
+ }
162
+
163
+ return payload;
164
+ }
165
+
166
+ /**
167
+ * Merge media-library metadata (alt / OG title / description) into the form.
168
+ * `overwrite` replaces values the editor already typed.
169
+ */
170
+ export function mergeMediaMetaIntoForm(args: {
171
+ form: PageSeoFormShape;
172
+ locales: readonly string[];
173
+ meta: {
174
+ alt?: Partial<LocalizedField> | null;
175
+ title?: Partial<LocalizedField> | null;
176
+ description?: Partial<LocalizedField> | null;
177
+ };
178
+ overwrite: boolean;
179
+ }): PageSeoFormShape {
180
+ const { form, locales, meta, overwrite } = args;
181
+ const merge = (current: LocalizedField, incoming?: Partial<LocalizedField> | null) => {
182
+ const next = { ...current };
183
+ if (!incoming) return next;
184
+ for (const l of locales) {
185
+ const value = incoming[l];
186
+ if (!value) continue;
187
+ if (overwrite || !next[l]) next[l] = value;
188
+ }
189
+ return next;
190
+ };
191
+ return {
192
+ ...form,
193
+ seo: {
194
+ ...form.seo,
195
+ ogImageAlt: merge(form.seo.ogImageAlt, meta.alt),
196
+ ogTitle: merge(form.seo.ogTitle, meta.title),
197
+ ogDescription: merge(form.seo.ogDescription, meta.description),
198
+ },
199
+ };
200
+ }
201
+
202
+ /** Locale-aware preview path: default locale has no prefix, empty slug = root. */
203
+ export function pageSeoPreviewPath(args: {
204
+ slug: string;
205
+ locale: string;
206
+ defaultLocale: string;
207
+ }): string {
208
+ const slug = args.slug ?? "";
209
+ if (args.locale === args.defaultLocale) return slug === "" ? "/" : `/${slug}`;
210
+ return slug === "" ? `/${args.locale}` : `/${args.locale}/${slug}`;
211
+ }
212
+
213
+ /** Absolute canonical preview URL; falls back to the path when no domain is known. */
214
+ export function pageSeoPreviewCanonical(args: { path: string; origin?: string | null }): string {
215
+ return args.origin ? `https://${args.origin}${args.path}` : args.path;
216
+ }