@bygghammar/cms-platform 0.33.0 → 0.34.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0
4
+
5
+ - Added `server/content-sections-core` — public reads and staff-only upserts of
6
+ site-wide content records, with manual-edit stamping.
7
+ - Added `server/translate-db-core` — host-agnostic auto-translation sweep for
8
+ CMS localized JSONB fields, with brand prompt details and table specs supplied
9
+ by the host.
10
+
11
+ ## 0.33.0
12
+
13
+ - Added `server/pages-core` — host-agnostic page-builder logic: pages, versions,
14
+ publications and scheduling, the page trash bin, global blocks/sections, the
15
+ global-block usage scan and the block-type reference-image scan.
16
+ - Added `server/resolve-globals` — recursive resolution of global block
17
+ references, usable with either an authenticated or a privileged client.
18
+
19
+
20
+
3
21
  ## 0.31.0
4
22
 
5
23
  - Added `backoffice/SharedContentListView` — the shared-content library screen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.33.0",
3
+ "version": "0.34.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,
@@ -143,7 +143,9 @@
143
143
  "./server/media-library-core": "./src/server/media-library-core.ts",
144
144
  "./server/admin-core": "./src/server/admin-core.ts",
145
145
  "./server/pages-core": "./src/server/pages-core.ts",
146
- "./server/resolve-globals": "./src/server/resolve-globals.ts"
146
+ "./server/resolve-globals": "./src/server/resolve-globals.ts",
147
+ "./server/content-sections-core": "./src/server/content-sections-core.ts",
148
+ "./server/translate-db-core": "./src/server/translate-db-core.ts"
147
149
  },
148
150
  "dependencies": {
149
151
  "fflate": "^0.8.3"
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Host-agnostic logic for site-wide content records (`content_sections`).
3
+ *
4
+ * The host owns the server-function wrappers, input validation and the clients;
5
+ * this module owns the queries and the manual-edit stamping so every site keeps
6
+ * identical behaviour: reads are public, writes are staff-only and manual
7
+ * translations are never clobbered.
8
+ */
9
+
10
+ import type { Json } from "../i18n/json";
11
+ import { stampManualEdits } from "../i18n/translation-meta";
12
+ import { assertStaff, type PlatformAuthContext } from "./guards";
13
+
14
+ /** Structural subset of a Supabase-shaped client used by this core. */
15
+ export type ContentClient = {
16
+ from: (table: string) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
17
+ };
18
+
19
+ export type ContentOverrides = Record<string, Json>;
20
+
21
+ /**
22
+ * Public read of every stored content record, keyed by record key.
23
+ *
24
+ * Never throws: a backend hiccup degrades to "no overrides" so the site still
25
+ * renders its in-code defaults.
26
+ */
27
+ export async function getContentOverridesCore(client: ContentClient): Promise<ContentOverrides> {
28
+ const { data, error } = await client.from("content_sections").select("key, data");
29
+ if (error) {
30
+ console.error("[cms] getContentOverrides", error);
31
+ return {};
32
+ }
33
+ const out: ContentOverrides = {};
34
+ for (const row of (data ?? []) as { key: string; data: Json }[]) out[row.key] = row.data;
35
+ return out;
36
+ }
37
+
38
+ /** True when the caller passes the host's `is_staff` check. */
39
+ export async function isStaffCore(context: PlatformAuthContext): Promise<boolean> {
40
+ const { data, error } = await context.supabase.rpc("is_staff", { _user_id: context.userId });
41
+ if (error) throw new Error(error.message);
42
+ return Boolean(data);
43
+ }
44
+
45
+ /**
46
+ * Staff-only upsert of one content record. Runs as the signed-in user, so RLS
47
+ * still applies. Manually edited locale values in the previous row are
48
+ * preserved by `stampManualEdits`.
49
+ */
50
+ export async function saveContentSectionCore(
51
+ context: PlatformAuthContext & { supabase: ContentClient },
52
+ input: { key: string; data: unknown },
53
+ ): Promise<{ ok: true; key: string }> {
54
+ await assertStaff(context);
55
+
56
+ const { data: prevRow } = await context.supabase
57
+ .from("content_sections")
58
+ .select("data")
59
+ .eq("key", input.key)
60
+ .maybeSingle();
61
+ const stamped = stampManualEdits(input.data, (prevRow?.data as Json) ?? null);
62
+
63
+ const { error } = await context.supabase
64
+ .from("content_sections")
65
+ .upsert({ key: input.key, data: stamped as Json }, { onConflict: "key" });
66
+ if (error) throw new Error(error.message);
67
+ return { ok: true, key: input.key };
68
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Host-agnostic auto-translation runner for CMS-backed localized JSONB fields.
3
+ *
4
+ * Rules (owned by `i18n/translation-meta`): manual translations are never
5
+ * overwritten, the source locale is never touched and no customer data is read.
6
+ * The host supplies its auth context, its brand prompt details and the tables to
7
+ * sweep; everything else is identical across sites.
8
+ */
9
+
10
+ import type { Json } from "../i18n/json";
11
+ import { collectMissing } from "../i18n/translation-meta";
12
+ import { assertStaff, type PlatformAuthContext } from "./guards";
13
+
14
+ export type TranslateMode = "missing" | "stale" | "force";
15
+
16
+ /** One table to sweep: its key column plus the localized JSONB columns. */
17
+ export type TranslateTableSpec = {
18
+ table: string;
19
+ keyCol: string;
20
+ jsonCols: string[];
21
+ };
22
+
23
+ /** Default sweep: content records and page SEO. */
24
+ export const DEFAULT_TRANSLATE_TABLES: TranslateTableSpec[] = [
25
+ { table: "content_sections", keyCol: "key", jsonCols: ["data"] },
26
+ { table: "page_seo", keyCol: "page_key", jsonCols: ["slugs", "seo"] },
27
+ ];
28
+
29
+ export type TranslateBrand = {
30
+ /** Brand name, kept unchanged by the translator. */
31
+ name: string;
32
+ /** One short sentence describing the site, for translation tone. */
33
+ description?: string;
34
+ /** Extra proper nouns that must stay untouched (places, product names). */
35
+ properNouns?: string[];
36
+ /** Human-readable source language, defaults to Swedish. */
37
+ sourceLanguage?: string;
38
+ };
39
+
40
+ export type TranslateOptions = {
41
+ target: string;
42
+ mode: TranslateMode;
43
+ brand: TranslateBrand;
44
+ tables?: TranslateTableSpec[];
45
+ /** AI gateway model id. */
46
+ model?: string;
47
+ /** Strings per request. */
48
+ batchSize?: number;
49
+ /** Gateway key; the host reads it from its own runtime env. */
50
+ apiKey: string;
51
+ /** Gateway chat-completions endpoint. */
52
+ endpoint?: string;
53
+ };
54
+
55
+ export type TranslateResult = {
56
+ ok: true;
57
+ translated: number;
58
+ updatedRows: number;
59
+ skippedManual: number;
60
+ mode: TranslateMode;
61
+ };
62
+
63
+ const DEFAULT_MODEL = "google/gemini-2.5-flash";
64
+ const DEFAULT_BATCH = 25;
65
+ const DEFAULT_ENDPOINT = "https://ai.gateway.lovable.dev/v1/chat/completions";
66
+
67
+ function systemPrompt(brand: TranslateBrand, target: string): string {
68
+ const source = brand.sourceLanguage ?? "Swedish";
69
+ const nouns = [brand.name, ...(brand.properNouns ?? [])].join(", ");
70
+ return [
71
+ `You are a professional translator for ${brand.name}${brand.description ? ` — ${brand.description}` : ""}.`,
72
+ `Translate each ${source} source string to ${target === "en" ? "natural, marketing-quality English" : target}.`,
73
+ "Preserve line breaks, HTML tags, {placeholders}, URLs, emails, prices, currency.",
74
+ `Keep proper nouns unchanged: ${nouns}.`,
75
+ "Do not translate URL slugs or numeric-only strings — return unchanged.",
76
+ 'Return ONLY: { "t": [ ...translations in the same order... ] }.',
77
+ ].join("\n");
78
+ }
79
+
80
+ /** Translate one batch of strings through the AI gateway. */
81
+ export async function translateBatchCore(
82
+ texts: string[],
83
+ opts: Pick<TranslateOptions, "apiKey" | "brand" | "target" | "model" | "endpoint">,
84
+ ): Promise<string[]> {
85
+ const res = await fetch(opts.endpoint ?? DEFAULT_ENDPOINT, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.apiKey}` },
88
+ body: JSON.stringify({
89
+ model: opts.model ?? DEFAULT_MODEL,
90
+ messages: [
91
+ { role: "system", content: systemPrompt(opts.brand, opts.target) },
92
+ { role: "user", content: JSON.stringify({ strings: texts }) },
93
+ ],
94
+ response_format: { type: "json_object" },
95
+ }),
96
+ });
97
+ if (!res.ok) {
98
+ const body = await res.text();
99
+ if (res.status === 429) throw new Error("Rate limited — try again shortly.");
100
+ if (res.status === 402) throw new Error("AI credits exhausted.");
101
+ throw new Error(`AI gateway ${res.status}: ${body.slice(0, 300)}`);
102
+ }
103
+ const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
104
+ const content = json.choices?.[0]?.message?.content ?? "{}";
105
+ const parsed = JSON.parse(content) as { t?: unknown };
106
+ if (!Array.isArray(parsed.t) || parsed.t.length !== texts.length) {
107
+ throw new Error("Translation shape mismatch");
108
+ }
109
+ return parsed.t.map((v) => String(v));
110
+ }
111
+
112
+ type RowPlan = {
113
+ table: string;
114
+ keyCol: string;
115
+ keyVal: string | number;
116
+ patches: { col: string; patch: (m: Map<string, string>) => Json }[];
117
+ };
118
+
119
+ /**
120
+ * Staff-only: fill missing or stale translations for the target locale across
121
+ * every configured table. Runs as the signed-in user, so RLS applies.
122
+ */
123
+ export async function translateMissingCmsCore(
124
+ context: PlatformAuthContext & {
125
+ supabase: { from: (table: string) => any }; // eslint-disable-line @typescript-eslint/no-explicit-any
126
+ },
127
+ opts: TranslateOptions,
128
+ ): Promise<TranslateResult> {
129
+ await assertStaff(context);
130
+ if (!opts.apiKey) throw new Error("AI gateway key missing");
131
+
132
+ const tables = opts.tables ?? DEFAULT_TRANSLATE_TABLES;
133
+ const { target, mode } = opts;
134
+
135
+ // 1) Collect every string needing translation across all rows.
136
+ const needSet = new Set<string>();
137
+ const plans: RowPlan[] = [];
138
+ let skippedManual = 0;
139
+
140
+ for (const spec of tables) {
141
+ const cols = [spec.keyCol, ...spec.jsonCols].join(", ");
142
+ const { data: rows, error } = (await context.supabase.from(spec.table).select(cols)) as {
143
+ data: Record<string, unknown>[] | null;
144
+ error: { message: string } | null;
145
+ };
146
+ if (error) throw new Error(`${spec.table}: ${error.message}`);
147
+ for (const row of rows ?? []) {
148
+ const plan: RowPlan = {
149
+ table: spec.table,
150
+ keyCol: spec.keyCol,
151
+ keyVal: row[spec.keyCol] as string | number,
152
+ patches: [],
153
+ };
154
+ let rowHasWork = false;
155
+ for (const col of spec.jsonCols) {
156
+ const { needs, skippedManual: sm, patch } = collectMissing(row[col], target, mode);
157
+ skippedManual += sm;
158
+ if (needs.length > 0) rowHasWork = true;
159
+ for (const n of needs) needSet.add(n);
160
+ plan.patches.push({ col, patch });
161
+ }
162
+ if (rowHasWork) plans.push(plan);
163
+ }
164
+ }
165
+
166
+ const needs = [...needSet];
167
+ if (needs.length === 0) {
168
+ return { ok: true, translated: 0, updatedRows: 0, skippedManual, mode };
169
+ }
170
+
171
+ // 2) Translate in batches.
172
+ const map = new Map<string, string>();
173
+ const batchSize = opts.batchSize ?? DEFAULT_BATCH;
174
+ for (let i = 0; i < needs.length; i += batchSize) {
175
+ const chunk = needs.slice(i, i + batchSize);
176
+ const out = await translateBatchCore(chunk, opts);
177
+ chunk.forEach((s, idx) => map.set(s, out[idx]));
178
+ }
179
+
180
+ // 3) Patch and write rows back.
181
+ let updatedRows = 0;
182
+ for (const p of plans) {
183
+ const update: Record<string, Json> = {};
184
+ for (const { col, patch } of p.patches) update[col] = patch(map);
185
+ const { error } = (await context.supabase
186
+ .from(p.table)
187
+ .update(update)
188
+ .eq(p.keyCol, p.keyVal)) as { error: { message: string } | null };
189
+ if (error) throw new Error(`${p.table} ${p.keyVal}: ${error.message}`);
190
+ updatedRows += 1;
191
+ }
192
+
193
+ return { ok: true, translated: map.size, updatedRows, skippedManual, mode };
194
+ }