@bygghammar/cms-platform 0.21.0 → 0.23.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 ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@bygghammar/cms-platform`.
4
+
5
+ The package follows semver with the pre-1.0 convention that a **minor** bump may
6
+ contain breaking changes to shared views, adapters or cores; patch releases are
7
+ always compatible. In addition, `PLATFORM_CONTRACT_VERSION` in
8
+ `platform/contract` is bumped whenever a shared screen starts requiring schema
9
+ or adapter capabilities an older site does not have. Sites assert that constant
10
+ in a test and upgrade deliberately.
11
+
12
+ ## 0.23.0
13
+
14
+ - Added `config/site-config`: the typed `SiteConfig` contract, `defineSiteConfig`
15
+ validation, feature flags, the overridable-key whitelist and
16
+ `resolveSiteConfig` (code defaults + database overrides).
17
+ - Added `server/site-settings-core`: load/save/clear for the `site_settings`
18
+ key/value store, running as the caller so RLS applies.
19
+ - Added `backoffice/SiteSettingsView`: shared site settings screen for name,
20
+ company details, module flags and brand tokens, with per-section reset back to
21
+ the code default.
22
+
23
+ ## 0.22.0
24
+
25
+ - Added `docs/NEW-SITE.md`: the supported starter path for a new site (install,
26
+ contract assertion, migrations, theme, host registration, server cores,
27
+ backoffice routes, verification).
28
+ - Added this changelog and a written compatibility policy.
29
+ - Added the `cms-platform-migrations` bin, which copies missing canonical
30
+ migrations into a site's migration directory without overwriting or deleting
31
+ anything.
32
+ - Removed five Voyara-specific content/data migrations from the canonical set
33
+ (SEO copy, a homepage block seed, a locale-domain update, a booking-link
34
+ rewrite and the content seed). Canonical migrations are schema only; site
35
+ content stays in the site's own migration directory. Existing sites keep
36
+ those files locally and remain compatible.
37
+ - No runtime source changes; safe upgrade from 0.21.x.
38
+
39
+ ## 0.21.0
40
+
41
+ - Added `server/forms-core`: host-agnostic forms server rules — effective
42
+ settings, recipient routing, notification templates, submitter detection,
43
+ field-removal safety, version snapshots, audit trail and the list/save/delete/
44
+ restore/purge/duplicate cores plus site defaults, usage and delivery logging.
45
+
46
+ ## 0.20.0
47
+
48
+ - Added `server/media-usage-core` (usage scan, remote media import, reference
49
+ rewriting) and completed `server/media-cleanup-core`.
50
+
51
+ ## 0.19.0
52
+
53
+ - Added `server/guards`, `server/redirects-core` and `server/page-seo-core`.
54
+
55
+ ## 0.18.0
56
+
57
+ - Extracted `FormEditorView`, `FormsIndexView` and `RedirectsView`.
58
+
59
+ ## 0.17.0
60
+
61
+ - Extracted the media library UI, including client-side optimisation and usage
62
+ inspection.
63
+
64
+ ## 0.15.0 – 0.16.0
65
+
66
+ - Extracted `SectionFormView` and `PageEditorView`.
67
+
68
+ ## 0.14.0
69
+
70
+ - Expanded the `PlatformData` host contract to every backoffice module and added
71
+ `assertPlatformData` / `REQUIRED_PLATFORM_DATA`.
72
+
73
+ ## 0.12.0 – 0.13.0
74
+
75
+ - Extracted `ReviewImageUpload` and `PageSeoPanel`.
76
+
77
+ ## 0.5.0 – 0.11.0
78
+
79
+ - Phase 1 and 2: pure forms/media/http/i18n helpers, backup core, trips and
80
+ navigation contracts, and the first host contract.
81
+ - Phase 5 groundwork: canonical migrations shipped under `migrations/` with
82
+ `SCHEMA_MIGRATIONS` and `PLATFORM_CONTRACT_VERSION`.
83
+
84
+ ## 0.1.0
85
+
86
+ - First release: blocks, block types, icons, i18n core, UI primitives and the
87
+ `theme.css` design-token contract.
package/README.md CHANGED
@@ -18,6 +18,24 @@ future sites). **Code only** — no content, no data, no secrets, no theme.
18
18
  active-state helpers. Menu content and visual headers remain per-site.
19
19
  - `src/theme.css` — the design-token contract + neutral starter theme
20
20
 
21
+ ## Starting a new site
22
+
23
+ `docs/NEW-SITE.md` is the supported starter path: install, assert the contract,
24
+ apply the canonical schema with `bunx cms-platform-migrations`, import
25
+ `theme.css`, register host components / platform data / translations, wrap the
26
+ server cores and mount the shared backoffice views.
27
+
28
+ ## Compatibility policy
29
+
30
+ - `CHANGELOG.md` records every release; read it before upgrading.
31
+ - Pre-1.0, a **minor** release may break shared views, adapters or cores; patch
32
+ releases are always compatible.
33
+ - `PLATFORM_CONTRACT_VERSION` (`platform/contract`) is bumped when a shared
34
+ screen starts requiring schema or adapter capabilities an older site lacks.
35
+ Sites pin it in a test.
36
+ - `migrations/` is schema only. Site content, SEO copy and seeds never belong
37
+ there.
38
+
21
39
  ## What must never live here
22
40
 
23
41
  - colours, fonts or spacing values used by a specific site (see `theme.css`)
@@ -0,0 +1,103 @@
1
+ # Starting a new site on @bygghammar/cms-platform
2
+
3
+ This is the supported path from an empty TanStack Start project to a working
4
+ CMS site. Every step is code or configuration in the new site; nothing here
5
+ copies content, media or secrets between sites.
6
+
7
+ ## 0. Prerequisites
8
+
9
+ - TanStack Start v1 (React 19, Vite 7), Tailwind v4, shadcn/ui.
10
+ - A Supabase-compatible backend for the site (its own project/stack — never
11
+ shared with another site, never a production database during authoring).
12
+
13
+ ## 1. Install
14
+
15
+ ```bash
16
+ bun add @bygghammar/cms-platform@<exact version>
17
+ ```
18
+
19
+ Pin an exact version and commit the lockfile. Read `CHANGELOG.md` before every
20
+ upgrade.
21
+
22
+ ## 2. Assert the contract
23
+
24
+ ```ts
25
+ import {
26
+ PLATFORM_CONTRACT_VERSION,
27
+ SCHEMA_MIGRATIONS,
28
+ assertPlatformContract,
29
+ missingMigrations,
30
+ } from "@bygghammar/cms-platform/platform/contract";
31
+ ```
32
+
33
+ Add one test that pins the contract version the site was built against and one
34
+ that asserts `missingMigrations(appliedFilenames)` is empty. See
35
+ `tests/platform-contract.test.ts` in any existing site for the exact shape.
36
+
37
+ ## 3. Apply the canonical schema
38
+
39
+ The package ships the canonical migrations under `migrations/`, oldest first.
40
+
41
+ ```bash
42
+ bunx cms-platform-migrations ./supabase/migrations
43
+ ```
44
+
45
+ The command copies any migration the target directory is missing and never
46
+ overwrites or deletes a file, so it is safe to re-run after an upgrade. Apply
47
+ the copied files with the site's normal migration tooling. Site-specific
48
+ migrations live alongside them with later timestamps and must stay additive.
49
+
50
+ ## 4. Theme
51
+
52
+ ```css
53
+ /* src/styles.css */
54
+ @import "@bygghammar/cms-platform/theme.css";
55
+ ```
56
+
57
+ Then override the design tokens for the new brand in the site's own layer.
58
+ Never edit colours, fonts or spacing inside the package.
59
+
60
+ ## 5. Register the host
61
+
62
+ Three registrations, all at startup:
63
+
64
+ - **Host components** — `registerHostComponents()` from
65
+ `@bygghammar/cms-platform/host` for the site's link, image, video and rich
66
+ text primitives.
67
+ - **Platform data adapter** — `registerHostPlatform({ data, site })` from
68
+ `@bygghammar/cms-platform/platform`. `data` implements every member in
69
+ `REQUIRED_PLATFORM_DATA` (pages, versions, global sections, media, SEO,
70
+ redirects, forms); `site` describes name, `backofficeBasePath` and locales.
71
+ Guard it with a test that calls `assertPlatformData`.
72
+ - **Translations** — the site's own `sv`/`en` maps via the i18n register
73
+ module. Translation data is content and stays in the site.
74
+
75
+ ## 6. Server functions
76
+
77
+ Wrap the shared server cores in the site's own `createServerFn` files:
78
+
79
+ | Package core | Purpose |
80
+ | --- | --- |
81
+ | `server/guards` | `assertStaff`, `assertSuperadmin` |
82
+ | `server/forms-core` | form definitions, versions, settings, delivery log |
83
+ | `server/redirects-core` | path normalisation and redirect writes |
84
+ | `server/page-seo-core` | slug conflicts and slug-change 301s |
85
+ | `server/media-usage-core` | usage scan and remote import |
86
+ | `server/media-cleanup-core` | duplicate/rename planning and apply |
87
+
88
+ The site supplies clients, storage and page-key → path maps; the package owns
89
+ the rules. Keep privileged clients inside handlers.
90
+
91
+ ## 7. Backoffice routes
92
+
93
+ Each backoffice route is a thin adapter around the package view
94
+ (`PageEditorView`, `MediaLibraryView`, `FormEditorView`, `FormsIndexView`,
95
+ `RedirectsView`, `SectionFormView`, `PageSeoPanel`). Routes own the path and
96
+ auth gate only.
97
+
98
+ ## 8. Verify
99
+
100
+ 1. Types pass.
101
+ 2. Full test suite passes, including the contract tests from step 2.
102
+ 3. `/` renders.
103
+ 4. The backoffice signs in, and one page, one form and one media asset save.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.21.0",
3
+ "version": "0.23.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,
@@ -10,7 +10,10 @@
10
10
  },
11
11
  "files": [
12
12
  "README.md",
13
+ "CHANGELOG.md",
14
+ "docs",
13
15
  "migrations",
16
+ "scripts",
14
17
  "src"
15
18
  ],
16
19
  "repository": {
@@ -109,7 +112,10 @@
109
112
  "./server/page-seo-core": "./src/server/page-seo-core.ts",
110
113
  "./server/media-cleanup-core": "./src/server/media-cleanup-core.ts",
111
114
  "./server/media-usage-core": "./src/server/media-usage-core.ts",
112
- "./server/forms-core": "./src/server/forms-core.ts"
115
+ "./server/forms-core": "./src/server/forms-core.ts",
116
+ "./config/site-config": "./src/config/site-config.ts",
117
+ "./server/site-settings-core": "./src/server/site-settings-core.ts",
118
+ "./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx"
113
119
  },
114
120
  "dependencies": {
115
121
  "fflate": "^0.8.3"
@@ -181,5 +187,8 @@
181
187
  "@tiptap/extension-table": {
182
188
  "optional": true
183
189
  }
190
+ },
191
+ "bin": {
192
+ "cms-platform-migrations": "scripts/sync-migrations.mjs"
184
193
  }
185
- }
194
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copy canonical platform migrations into a site's migration directory.
4
+ *
5
+ * Additive only: a file that already exists in the target is left untouched and
6
+ * nothing is ever deleted or overwritten, so this is safe to re-run after an
7
+ * upgrade.
8
+ *
9
+ * Usage: cms-platform-migrations [targetDir] [--check]
10
+ * targetDir defaults to ./supabase/migrations
11
+ * --check exits non-zero when files are missing instead of copying them
12
+ */
13
+ import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const source = resolve(here, "..", "migrations");
19
+
20
+ const args = process.argv.slice(2);
21
+ const checkOnly = args.includes("--check");
22
+ const target = resolve(args.find((a) => !a.startsWith("--")) ?? "supabase/migrations");
23
+
24
+ if (!existsSync(source)) {
25
+ console.error(`No migrations directory in the package at ${source}`);
26
+ process.exit(1);
27
+ }
28
+
29
+ const canonical = readdirSync(source)
30
+ .filter((f) => f.endsWith(".sql"))
31
+ .sort();
32
+
33
+ const present = new Set(existsSync(target) ? readdirSync(target) : []);
34
+ const missing = canonical.filter((f) => !present.has(f));
35
+
36
+ if (missing.length === 0) {
37
+ console.log(`Up to date: ${canonical.length} canonical migrations already present.`);
38
+ process.exit(0);
39
+ }
40
+
41
+ if (checkOnly) {
42
+ console.error(`Missing ${missing.length} canonical migrations:`);
43
+ for (const f of missing) console.error(` ${f}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ mkdirSync(target, { recursive: true });
48
+ for (const f of missing) copyFileSync(join(source, f), join(target, f));
49
+ console.log(`Copied ${missing.length} migration(s) into ${target}. Apply them with your migration tooling.`);
@@ -0,0 +1,258 @@
1
+ import { useEffect, useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { Save, RotateCcw } from "lucide-react";
4
+
5
+ import {
6
+ DEFAULT_FEATURE_FLAGS,
7
+ EMPTY_COMPANY,
8
+ type SiteConfig,
9
+ type SiteFeatureFlags,
10
+ type SiteSettingOverrides,
11
+ } from "../config/site-config";
12
+
13
+ export type SiteSettingsDeps = {
14
+ /** Code config with any stored overrides already applied. */
15
+ config: SiteConfig;
16
+ /** Raw stored overrides, used to show what differs from code. */
17
+ overrides: SiteSettingOverrides;
18
+ saveSettings: (input: { data: SiteSettingOverrides }) => Promise<unknown>;
19
+ clearSetting: (input: { data: { key: string } }) => Promise<unknown>;
20
+ refresh: () => Promise<unknown> | void;
21
+ };
22
+
23
+ const FEATURE_LABELS: Record<keyof SiteFeatureFlags, string> = {
24
+ trips: "Trips & departures",
25
+ courses: "Courses",
26
+ reviews: "Reviews",
27
+ email: "Transactional email",
28
+ tracking: "Code & tracking",
29
+ backup: "Backup & restore",
30
+ domains: "Custom domains",
31
+ forms: "Forms",
32
+ };
33
+
34
+ const COMPANY_LABELS: Record<keyof typeof EMPTY_COMPANY, string> = {
35
+ legalName: "Legal name",
36
+ locationName: "Location name",
37
+ street: "Street",
38
+ postCode: "Post code",
39
+ city: "City",
40
+ country: "Country",
41
+ orgNumber: "Org. number",
42
+ email: "Email",
43
+ phone: "Phone",
44
+ };
45
+
46
+ const inputClass =
47
+ "w-full border border-ink/15 bg-white px-3 py-2 text-sm text-ink focus:border-sea focus:outline-none";
48
+ const labelClass = "block text-xs uppercase tracking-wider text-ink/60 mb-1";
49
+ const cardClass = "border border-ink/10 bg-white p-6";
50
+
51
+ /**
52
+ * Host-agnostic site settings screen. Code config is always the baseline; this
53
+ * screen writes only the overridable subset into the database.
54
+ */
55
+ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
56
+ const { config, overrides, saveSettings, clearSetting, refresh } = deps;
57
+ const [name, setName] = useState(config.name);
58
+ const [company, setCompany] = useState({ ...EMPTY_COMPANY, ...config.company });
59
+ const [features, setFeatures] = useState<SiteFeatureFlags>({ ...config.features });
60
+ const [brand, setBrand] = useState<Record<string, string>>({ ...config.brand });
61
+ const [busy, setBusy] = useState<string | null>(null);
62
+
63
+ useEffect(() => {
64
+ setName(config.name);
65
+ setCompany({ ...EMPTY_COMPANY, ...config.company });
66
+ setFeatures({ ...config.features });
67
+ setBrand({ ...config.brand });
68
+ }, [config]);
69
+
70
+ const save = async (key: string, patch: SiteSettingOverrides) => {
71
+ setBusy(key);
72
+ try {
73
+ await saveSettings({ data: patch });
74
+ toast.success("Saved");
75
+ await refresh();
76
+ } catch (e) {
77
+ toast.error(e instanceof Error ? e.message : "Failed to save");
78
+ } finally {
79
+ setBusy(null);
80
+ }
81
+ };
82
+
83
+ const reset = async (key: string) => {
84
+ setBusy(key);
85
+ try {
86
+ await clearSetting({ data: { key } });
87
+ toast.success("Reset to the value defined in code");
88
+ await refresh();
89
+ } catch (e) {
90
+ toast.error(e instanceof Error ? e.message : "Failed to reset");
91
+ } finally {
92
+ setBusy(null);
93
+ }
94
+ };
95
+
96
+ const overridden = (key: keyof SiteSettingOverrides) => overrides[key] !== undefined;
97
+
98
+ return (
99
+ <div className="p-8 md:p-12 w-full">
100
+ <h1 className="font-serif text-3xl md:text-4xl text-ink">Site settings</h1>
101
+ <p className="mt-3 max-w-2xl text-sm text-ink/60">
102
+ These values come from the site configuration in code. Anything you change here is stored as
103
+ an override in the database and can be reset back to the code default.
104
+ </p>
105
+
106
+ <dl className="mt-8 grid gap-4 md:grid-cols-4">
107
+ {[
108
+ ["Site key", config.key],
109
+ ["Public origin", config.origin],
110
+ ["Locales", config.locales.join(", ")],
111
+ ["Backoffice", `${config.backofficePath} (${config.backofficeLocale})`],
112
+ ].map(([label, value]) => (
113
+ <div key={label} className="border border-ink/10 bg-white p-4">
114
+ <dt className="text-xs uppercase tracking-wider text-ink/50">{label}</dt>
115
+ <dd className="mt-1 text-sm text-ink">{value}</dd>
116
+ </div>
117
+ ))}
118
+ </dl>
119
+
120
+ <section className={`mt-8 ${cardClass}`}>
121
+ <SectionHeader
122
+ title="Site name"
123
+ overridden={overridden("name")}
124
+ onReset={() => reset("name")}
125
+ busy={busy === "name"}
126
+ />
127
+ <div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
128
+ <label className="block">
129
+ <span className={labelClass}>Name</span>
130
+ <input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
131
+ </label>
132
+ <SaveButton busy={busy === "name"} onClick={() => save("name", { name })} />
133
+ </div>
134
+ </section>
135
+
136
+ <section className={`mt-6 ${cardClass}`}>
137
+ <SectionHeader
138
+ title="Company & contact details"
139
+ overridden={overridden("company")}
140
+ onReset={() => reset("company")}
141
+ busy={busy === "company"}
142
+ />
143
+ <div className="mt-4 grid gap-3 md:grid-cols-3">
144
+ {(Object.keys(COMPANY_LABELS) as (keyof typeof EMPTY_COMPANY)[]).map((key) => (
145
+ <label key={key} className="block">
146
+ <span className={labelClass}>{COMPANY_LABELS[key]}</span>
147
+ <input
148
+ className={inputClass}
149
+ value={company[key] ?? ""}
150
+ onChange={(e) => setCompany({ ...company, [key]: e.target.value })}
151
+ />
152
+ </label>
153
+ ))}
154
+ </div>
155
+ <div className="mt-4">
156
+ <SaveButton busy={busy === "company"} onClick={() => save("company", { company })} />
157
+ </div>
158
+ </section>
159
+
160
+ <section className={`mt-6 ${cardClass}`}>
161
+ <SectionHeader
162
+ title="Modules"
163
+ overridden={overridden("features")}
164
+ onReset={() => reset("features")}
165
+ busy={busy === "features"}
166
+ />
167
+ <div className="mt-4 grid gap-3 md:grid-cols-2">
168
+ {(Object.keys(DEFAULT_FEATURE_FLAGS) as (keyof SiteFeatureFlags)[]).map((flag) => (
169
+ <label key={flag} className="flex items-center gap-3 text-sm text-ink">
170
+ <input
171
+ type="checkbox"
172
+ checked={features[flag]}
173
+ onChange={(e) => setFeatures({ ...features, [flag]: e.target.checked })}
174
+ />
175
+ {FEATURE_LABELS[flag]}
176
+ </label>
177
+ ))}
178
+ </div>
179
+ <div className="mt-4">
180
+ <SaveButton busy={busy === "features"} onClick={() => save("features", { features })} />
181
+ </div>
182
+ </section>
183
+
184
+ {Object.keys(brand).length > 0 && (
185
+ <section className={`mt-6 ${cardClass}`}>
186
+ <SectionHeader
187
+ title="Brand tokens"
188
+ overridden={overridden("brand")}
189
+ onReset={() => reset("brand")}
190
+ busy={busy === "brand"}
191
+ />
192
+ <div className="mt-4 grid gap-3 md:grid-cols-3">
193
+ {Object.keys(brand).map((token) => (
194
+ <label key={token} className="block">
195
+ <span className={labelClass}>{token}</span>
196
+ <div className="flex items-center gap-2">
197
+ <span
198
+ className="size-8 shrink-0 border border-ink/15"
199
+ style={{ background: brand[token] }}
200
+ />
201
+ <input
202
+ className={inputClass}
203
+ value={brand[token] ?? ""}
204
+ onChange={(e) => setBrand({ ...brand, [token]: e.target.value })}
205
+ />
206
+ </div>
207
+ </label>
208
+ ))}
209
+ </div>
210
+ <div className="mt-4">
211
+ <SaveButton busy={busy === "brand"} onClick={() => save("brand", { brand })} />
212
+ </div>
213
+ </section>
214
+ )}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ function SectionHeader({
220
+ title,
221
+ overridden,
222
+ onReset,
223
+ busy,
224
+ }: {
225
+ title: string;
226
+ overridden: boolean;
227
+ onReset: () => void;
228
+ busy: boolean;
229
+ }) {
230
+ return (
231
+ <div className="flex items-center justify-between gap-4">
232
+ <h2 className="font-serif text-xl text-ink">{title}</h2>
233
+ {overridden && (
234
+ <button
235
+ type="button"
236
+ onClick={onReset}
237
+ disabled={busy}
238
+ className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-wider text-ink/70 hover:border-sea disabled:opacity-50"
239
+ >
240
+ <RotateCcw className="size-3.5" /> Reset to code default
241
+ </button>
242
+ )}
243
+ </div>
244
+ );
245
+ }
246
+
247
+ function SaveButton({ busy, onClick }: { busy: boolean; onClick: () => void }) {
248
+ return (
249
+ <button
250
+ type="button"
251
+ onClick={onClick}
252
+ disabled={busy}
253
+ className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-wider text-white hover:bg-sea disabled:opacity-50"
254
+ >
255
+ <Save className="size-4" /> {busy ? "Saving…" : "Save"}
256
+ </button>
257
+ );
258
+ }