@bygghammar/cms-platform 0.56.0 → 0.57.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 +11 -0
- package/package.json +6 -3
- package/src/server/page-seo-save-core.ts +99 -0
- package/src/server/schemas.ts +245 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.57.0
|
|
4
|
+
|
|
5
|
+
- Server-function inputs are now platform contracts: `server/schemas` ships the
|
|
6
|
+
shared zod validators for the page builder, administration, media library,
|
|
7
|
+
redirects and the page SEO editor. Sites keep declaring their own server
|
|
8
|
+
functions (the build step requires it) but stop re-writing the shapes.
|
|
9
|
+
- New `server/page-seo-save-core`: slug-conflict rejection, manual-edit
|
|
10
|
+
stamping, automatic 301 redirects and the page SEO upsert now live in the
|
|
11
|
+
package. Sites supply their locales, registry slugs and client only.
|
|
12
|
+
- `zod` is declared as a peer dependency.
|
|
13
|
+
|
|
3
14
|
## 0.56.0
|
|
4
15
|
|
|
5
16
|
- Public site chrome is now package-owned: `site/SiteHeader` and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.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": [
|
|
@@ -205,7 +205,9 @@
|
|
|
205
205
|
"./util/use-hash-scroll": "./src/util/use-hash-scroll.ts",
|
|
206
206
|
"./site/site-url": "./src/site/site-url.ts",
|
|
207
207
|
"./site/SiteHeader": "./src/site/SiteHeader.tsx",
|
|
208
|
-
"./site/SiteFooter": "./src/site/SiteFooter.tsx"
|
|
208
|
+
"./site/SiteFooter": "./src/site/SiteFooter.tsx",
|
|
209
|
+
"./server/schemas": "./src/server/schemas.ts",
|
|
210
|
+
"./server/page-seo-save-core": "./src/server/page-seo-save-core.ts"
|
|
209
211
|
},
|
|
210
212
|
"dependencies": {
|
|
211
213
|
"fflate": "^0.8.3"
|
|
@@ -232,7 +234,8 @@
|
|
|
232
234
|
"@tiptap/extension-table": ">=3",
|
|
233
235
|
"@dnd-kit/core": ">=6",
|
|
234
236
|
"@dnd-kit/sortable": ">=3",
|
|
235
|
-
"@dnd-kit/utilities": ">=3"
|
|
237
|
+
"@dnd-kit/utilities": ">=3",
|
|
238
|
+
"zod": ">=3"
|
|
236
239
|
},
|
|
237
240
|
"peerDependenciesMeta": {
|
|
238
241
|
"@supabase/supabase-js": {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic page SEO write path: slug-conflict rejection, manual-edit
|
|
3
|
+
* stamping, automatic 301 redirects for changed slugs, and the upsert itself.
|
|
4
|
+
*
|
|
5
|
+
* The site owns only its page registry, its locales and its authenticated
|
|
6
|
+
* client; every rule below is shared by every site.
|
|
7
|
+
*/
|
|
8
|
+
import { stampManualEdits } from "../i18n/translation-meta";
|
|
9
|
+
import { slugRedirectRows, type SlugConflict } from "./page-seo-core";
|
|
10
|
+
|
|
11
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
12
|
+
type Client = {
|
|
13
|
+
from: (table: string) => any;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PageSeoWriteContext = { supabase: Client; userId: string };
|
|
17
|
+
|
|
18
|
+
export type PageSeoSaveInput = {
|
|
19
|
+
pageKey: string;
|
|
20
|
+
slugs: Record<string, unknown>;
|
|
21
|
+
seo: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type PageSeoSaveResult =
|
|
25
|
+
| { ok: true; pageKey: string }
|
|
26
|
+
| { ok: false; reason: "slug_conflict"; conflicts: SlugConflict[] };
|
|
27
|
+
|
|
28
|
+
export type PageSeoSaveDeps = {
|
|
29
|
+
context: PageSeoWriteContext;
|
|
30
|
+
locales: readonly string[];
|
|
31
|
+
/** Slug map declared in the site's route registry for this page, if any. */
|
|
32
|
+
registrySlugs?: Record<string, string> | undefined;
|
|
33
|
+
/** Site-side conflict lookup (it reads the stored overrides). */
|
|
34
|
+
detectConflicts: (input: PageSeoSaveInput) => Promise<SlugConflict[]>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export async function savePageSeoCore(
|
|
38
|
+
deps: PageSeoSaveDeps,
|
|
39
|
+
data: PageSeoSaveInput,
|
|
40
|
+
): Promise<PageSeoSaveResult> {
|
|
41
|
+
const { context } = deps;
|
|
42
|
+
|
|
43
|
+
const conflicts = await deps.detectConflicts(data);
|
|
44
|
+
if (conflicts.length > 0) return { ok: false, reason: "slug_conflict", conflicts };
|
|
45
|
+
|
|
46
|
+
const { data: prevRow } = await context.supabase
|
|
47
|
+
.from("page_seo")
|
|
48
|
+
.select("slugs, seo")
|
|
49
|
+
.eq("page_key", data.pageKey)
|
|
50
|
+
.maybeSingle();
|
|
51
|
+
|
|
52
|
+
const stampedSlugs = stampManualEdits(data.slugs, prevRow?.slugs ?? null);
|
|
53
|
+
const stampedSeo = stampManualEdits(data.seo, prevRow?.seo ?? null);
|
|
54
|
+
|
|
55
|
+
// Any previous public URL keeps working: create 301s to the new one.
|
|
56
|
+
const rows = slugRedirectRows({
|
|
57
|
+
locales: deps.locales,
|
|
58
|
+
previousSlugs: (prevRow?.slugs as Record<string, string> | null) ?? null,
|
|
59
|
+
registrySlugs: deps.registrySlugs,
|
|
60
|
+
nextSlugs: data.slugs as Record<string, string>,
|
|
61
|
+
userId: context.userId,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (rows.length > 0) {
|
|
65
|
+
// Retarget stale chains that pointed at the old path, then insert.
|
|
66
|
+
for (const r of rows) {
|
|
67
|
+
await context.supabase
|
|
68
|
+
.from("redirects")
|
|
69
|
+
.update({ to_path: r.to_path })
|
|
70
|
+
.eq("to_path", r.from_path);
|
|
71
|
+
await context.supabase.from("redirects").delete().eq("from_path", r.from_path);
|
|
72
|
+
}
|
|
73
|
+
const { error: redirErr } = await context.supabase.from("redirects").insert(rows);
|
|
74
|
+
if (redirErr) console.error("[cms] redirect create", redirErr);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { error } = await context.supabase.from("page_seo").upsert(
|
|
78
|
+
{
|
|
79
|
+
page_key: data.pageKey,
|
|
80
|
+
slugs: stampedSlugs,
|
|
81
|
+
seo: stampedSeo,
|
|
82
|
+
updated_by: context.userId,
|
|
83
|
+
},
|
|
84
|
+
{ onConflict: "page_key" },
|
|
85
|
+
);
|
|
86
|
+
if (error) throw new Error(error.message);
|
|
87
|
+
|
|
88
|
+
return { ok: true, pageKey: data.pageKey };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Remove a page's stored override, falling back to the code registry. */
|
|
92
|
+
export async function deletePageSeoOverrideCore(
|
|
93
|
+
context: PageSeoWriteContext,
|
|
94
|
+
pageKey: string,
|
|
95
|
+
): Promise<{ ok: true; pageKey: string }> {
|
|
96
|
+
const { error } = await context.supabase.from("page_seo").delete().eq("page_key", pageKey);
|
|
97
|
+
if (error) throw new Error(error.message);
|
|
98
|
+
return { ok: true, pageKey };
|
|
99
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared input validators for the platform's server functions.
|
|
3
|
+
*
|
|
4
|
+
* Every site declares its server functions locally (TanStack's build step must
|
|
5
|
+
* see `createServerFn` in the site's own source), but the *shapes* those
|
|
6
|
+
* functions accept are platform contracts. They live here so a validator is
|
|
7
|
+
* written, reviewed and fixed exactly once.
|
|
8
|
+
*
|
|
9
|
+
* These modules are pure zod schemas: no database access, no auth, no request
|
|
10
|
+
* state, so they are safe to import from either side of the boundary.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
/* ------------------------------------------------------------------ shared */
|
|
15
|
+
|
|
16
|
+
export const uuid = z.string().uuid();
|
|
17
|
+
export const uuidInput = <K extends string>(key: K) => z.object({ [key]: uuid } as Record<K, typeof uuid>);
|
|
18
|
+
|
|
19
|
+
/** Optional email that accepts an empty string and stores it as null. */
|
|
20
|
+
export const nullableEmail = z
|
|
21
|
+
.string()
|
|
22
|
+
.email()
|
|
23
|
+
.nullable()
|
|
24
|
+
.optional()
|
|
25
|
+
.or(z.literal("").transform(() => null));
|
|
26
|
+
|
|
27
|
+
/** CMS key: lowercase, digits, dash and underscore only. */
|
|
28
|
+
export const cmsKey = z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(64)
|
|
32
|
+
.regex(/^[a-z0-9_-]+$/);
|
|
33
|
+
|
|
34
|
+
export const listLimit = z.object({ limit: z.number().min(1).max(500).default(200) });
|
|
35
|
+
|
|
36
|
+
/* --------------------------------------------------------------- page builder */
|
|
37
|
+
|
|
38
|
+
/** One block instance as it travels over the wire. */
|
|
39
|
+
export const blockInstanceSchema = z.object({
|
|
40
|
+
id: z.string().min(1),
|
|
41
|
+
type: z.string().min(1),
|
|
42
|
+
name: z.string().max(120).optional(),
|
|
43
|
+
props: z.record(z.string(), z.any()).optional(),
|
|
44
|
+
visible: z.boolean().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const pageSchemas = {
|
|
48
|
+
key: z.object({ key: z.string() }),
|
|
49
|
+
pageId: z.object({ pageId: uuid }),
|
|
50
|
+
versionId: z.object({ versionId: uuid }),
|
|
51
|
+
sectionId: z.object({ sectionId: uuid }),
|
|
52
|
+
publicationId: z.object({ publicationId: uuid }),
|
|
53
|
+
createPage: z.object({ key: cmsKey, name: z.string().min(1).max(120) }),
|
|
54
|
+
renamePage: z.object({ pageId: uuid, name: z.string().min(1).max(120) }),
|
|
55
|
+
savePageVersion: z.object({
|
|
56
|
+
pageId: uuid,
|
|
57
|
+
blocks: z.array(blockInstanceSchema),
|
|
58
|
+
publish: z.boolean().optional(),
|
|
59
|
+
note: z.string().max(500).optional(),
|
|
60
|
+
}),
|
|
61
|
+
createGlobalSection: z.object({
|
|
62
|
+
key: cmsKey,
|
|
63
|
+
name: z.string().min(1).max(120),
|
|
64
|
+
blocks: z.array(blockInstanceSchema).optional(),
|
|
65
|
+
kind: z.enum(["block", "section"]).optional(),
|
|
66
|
+
}),
|
|
67
|
+
setGlobalSectionKind: z.object({ sectionId: uuid, kind: z.enum(["block", "section"]) }),
|
|
68
|
+
saveGlobalSectionVersion: z.object({
|
|
69
|
+
sectionId: uuid,
|
|
70
|
+
blocks: z.array(blockInstanceSchema),
|
|
71
|
+
note: z.string().max(500).optional(),
|
|
72
|
+
}),
|
|
73
|
+
renameGlobalSection: z.object({ sectionId: uuid, name: z.string().min(1).max(120) }),
|
|
74
|
+
schedulePageVersion: z.object({
|
|
75
|
+
versionId: uuid,
|
|
76
|
+
publishAt: z.string().datetime(),
|
|
77
|
+
unpublishAt: z.string().datetime().nullable().optional(),
|
|
78
|
+
note: z.string().max(500).optional(),
|
|
79
|
+
}),
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
/* ------------------------------------------------------------- administration */
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Administration inputs. Roles and provider kinds come from the platform's
|
|
86
|
+
* own enums, which the caller passes so a site cannot drift from them.
|
|
87
|
+
*/
|
|
88
|
+
export function adminSchemas(input: {
|
|
89
|
+
roles: readonly [string, ...string[]];
|
|
90
|
+
providerKinds: readonly [string, ...string[]];
|
|
91
|
+
}) {
|
|
92
|
+
const role = z.enum(input.roles);
|
|
93
|
+
return {
|
|
94
|
+
role,
|
|
95
|
+
createUser: z.object({
|
|
96
|
+
email: z.string().email(),
|
|
97
|
+
password: z.string().min(8).max(128).optional(),
|
|
98
|
+
roles: z.array(role).default([]),
|
|
99
|
+
}),
|
|
100
|
+
userId: z.object({ userId: uuid }),
|
|
101
|
+
setUserPassword: z.object({ userId: uuid, password: z.string().min(8).max(128) }),
|
|
102
|
+
setUserRoles: z.object({ userId: uuid, roles: z.array(role) }),
|
|
103
|
+
email: z.object({ email: z.string().email() }),
|
|
104
|
+
listLimit,
|
|
105
|
+
emailSettings: z.object({
|
|
106
|
+
primary_from_name: z.string().max(200).nullable().optional(),
|
|
107
|
+
primary_from_email: nullableEmail,
|
|
108
|
+
backup_from_email: nullableEmail,
|
|
109
|
+
reply_to: nullableEmail,
|
|
110
|
+
admin_notify_to: z.string().max(500).nullable().optional(),
|
|
111
|
+
default_provider_id: uuid.nullable().optional(),
|
|
112
|
+
fallback_provider_id: uuid.nullable().optional(),
|
|
113
|
+
}),
|
|
114
|
+
emailProvider: z.object({
|
|
115
|
+
id: uuid.optional(),
|
|
116
|
+
name: z.string().min(1).max(120),
|
|
117
|
+
kind: z.enum(input.providerKinds),
|
|
118
|
+
is_active: z.boolean().default(true),
|
|
119
|
+
sort_order: z.number().int().min(0).max(9999).default(100),
|
|
120
|
+
from_name: z.string().max(200).nullable().optional(),
|
|
121
|
+
from_email: nullableEmail,
|
|
122
|
+
reply_to: nullableEmail,
|
|
123
|
+
config: z.record(z.string(), z.unknown()).default({}),
|
|
124
|
+
secret_ref_name: z.string().max(200).nullable().optional(),
|
|
125
|
+
notes: z.string().max(2000).nullable().optional(),
|
|
126
|
+
}),
|
|
127
|
+
id: z.object({ id: uuid }),
|
|
128
|
+
sendTestEmail: z.object({ provider_id: uuid, to: z.string().email().max(320) }),
|
|
129
|
+
} as const;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* --------------------------------------------------------------------- media */
|
|
133
|
+
|
|
134
|
+
export const mediaSchemas = {
|
|
135
|
+
path: z.object({ path: z.string().min(1) }),
|
|
136
|
+
id: z.object({ id: uuid }),
|
|
137
|
+
uploadBundle: z.object({
|
|
138
|
+
files: z
|
|
139
|
+
.array(
|
|
140
|
+
z.object({
|
|
141
|
+
name: z.string().min(1),
|
|
142
|
+
contentType: z.string().min(1),
|
|
143
|
+
bytes: z.array(z.number().int().min(0).max(255)),
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
.min(1)
|
|
147
|
+
.max(50),
|
|
148
|
+
upsert: z.boolean().optional(),
|
|
149
|
+
}),
|
|
150
|
+
reviewImage: z.object({
|
|
151
|
+
slug: z.string().trim().min(1).max(80),
|
|
152
|
+
variants: z
|
|
153
|
+
.array(z.object({ width: z.number().int(), data: z.string().min(16).max(4_000_000) }))
|
|
154
|
+
.length(4),
|
|
155
|
+
}),
|
|
156
|
+
prepareOverwrite: z.object({ bases: z.array(z.string().min(1)).max(500) }),
|
|
157
|
+
logOverwrite: z.object({
|
|
158
|
+
paths: z.array(z.string()).max(3000),
|
|
159
|
+
skipped: z.array(z.string()).max(500),
|
|
160
|
+
failed: z.array(z.string()).max(500),
|
|
161
|
+
}),
|
|
162
|
+
} as const;
|
|
163
|
+
|
|
164
|
+
/* ------------------------------------------------------------------ redirects */
|
|
165
|
+
|
|
166
|
+
export const redirectSchemas = {
|
|
167
|
+
path: z.object({ path: z.string().max(512) }),
|
|
168
|
+
id: z.object({ id: uuid }),
|
|
169
|
+
save: z.object({
|
|
170
|
+
id: uuid.optional(),
|
|
171
|
+
from_path: z.string().min(1).max(512),
|
|
172
|
+
to_path: z.string().min(1).max(512),
|
|
173
|
+
status: z
|
|
174
|
+
.number()
|
|
175
|
+
.int()
|
|
176
|
+
.refine((n) => n === 301 || n === 302, "301 or 302"),
|
|
177
|
+
}),
|
|
178
|
+
} as const;
|
|
179
|
+
|
|
180
|
+
/* --------------------------------------------------------------------- page SEO */
|
|
181
|
+
|
|
182
|
+
const localeMetaMap = z
|
|
183
|
+
.record(
|
|
184
|
+
z.string(),
|
|
185
|
+
z
|
|
186
|
+
.object({
|
|
187
|
+
origin: z.enum(["auto", "manual"]),
|
|
188
|
+
svHash: z.string().optional(),
|
|
189
|
+
updatedAt: z.string().optional(),
|
|
190
|
+
})
|
|
191
|
+
.passthrough(),
|
|
192
|
+
)
|
|
193
|
+
.optional();
|
|
194
|
+
|
|
195
|
+
const localizedString = z.object({ _meta: localeMetaMap }).catchall(z.string());
|
|
196
|
+
|
|
197
|
+
const localizedImage = z
|
|
198
|
+
.object({
|
|
199
|
+
src: z.string().url().or(z.string().startsWith("/")),
|
|
200
|
+
alt: localizedString,
|
|
201
|
+
srcByLocale: z.object({ _meta: localeMetaMap }).catchall(z.string()).optional(),
|
|
202
|
+
width: z.number().int().positive().optional(),
|
|
203
|
+
height: z.number().int().positive().optional(),
|
|
204
|
+
})
|
|
205
|
+
.optional();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Page SEO editor inputs. The site supplies its own page keys and locales;
|
|
209
|
+
* everything else — the localized string/image shape, the allowed Open Graph
|
|
210
|
+
* and Twitter values, the slug map — is a platform contract.
|
|
211
|
+
*/
|
|
212
|
+
export function pageSeoSchemas(input: {
|
|
213
|
+
pageKeys: readonly [string, ...string[]];
|
|
214
|
+
locales: readonly [string, ...string[]];
|
|
215
|
+
}) {
|
|
216
|
+
const seo = z
|
|
217
|
+
.object({
|
|
218
|
+
title: localizedString.optional(),
|
|
219
|
+
description: localizedString.optional(),
|
|
220
|
+
ogTitle: localizedString.optional(),
|
|
221
|
+
ogDescription: localizedString.optional(),
|
|
222
|
+
ogImage: localizedImage,
|
|
223
|
+
ogType: z.enum(["website", "article", "product"]).optional(),
|
|
224
|
+
twitterCard: z.enum(["summary", "summary_large_image"]).optional(),
|
|
225
|
+
keywords: localizedString.optional(),
|
|
226
|
+
robots: z.string().max(120).optional(),
|
|
227
|
+
jsonLd: z.record(z.enum(input.locales), z.record(z.string(), z.unknown())).optional(),
|
|
228
|
+
})
|
|
229
|
+
.strict();
|
|
230
|
+
|
|
231
|
+
const slugs = z.object({ _meta: localeMetaMap }).catchall(z.string().max(120));
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
seo,
|
|
235
|
+
slugs,
|
|
236
|
+
save: z.object({ pageKey: z.enum(input.pageKeys), slugs, seo }),
|
|
237
|
+
delete: z.object({ pageKey: z.enum(input.pageKeys) }),
|
|
238
|
+
} as const;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export type PageSeoSaveInput = {
|
|
242
|
+
pageKey: string;
|
|
243
|
+
slugs: Record<string, unknown>;
|
|
244
|
+
seo: Record<string, unknown>;
|
|
245
|
+
};
|